使用JNI和Native代码交互

106 阅读17分钟

JNI是什么

JNI是Java提供的一种工具。里面包含了一些用C/C++实现的方法,但是却可以向普通Java方法一样被调用。这些Native方法可以像Java一样使用Java对象。Native方法可以创建新的Java对象或者使用Java创建的对象,例如检查,修改和调用方法来完成指定的功能。

从简单例子讲起

通过后续的例子可以学到以下几个概念:
  1. native方法如何被Java方法调用
  2. 声明native方法
  3. 在shared library中加载native模块
  4. 用C/C++实现native方法

可以看一个最简单的helloJNI项目:

/* this is used to load the 'hello-jni' library on application
 * startup. The library has already been unpacked into
 * /data/data/com.example.HelloJni/lib/libhello-jni.so at
 * installation time by the package manager.
 */
static {
    System.loadLibrary("hello-jni");
}

public void onCreate(){
    super.onCreate(savedInstanceState);
    
    /* Create a TextView and set its content.
     * the text is retrieved by calling a native
     * function.
     */
    TextView tv = new TextView(this);
    tv.setText( stringFromJNI() );
    setContentView(tv);
}

/* A native method that is implemented by the
 * 'hello-jni' native library, which is packaged
 * with this application.
 */
public native String stringFromJNI();


声明Native 方法

在上面的例子中,`stringFromJNI` 包含了 `native` 关键字用来给Java compiler表明这个方法的实现是由其他语言实现的。这个方法以分号结尾表示结束,因为native方法没有实现body。

这时候JVM知道了这个方法是由native实现的,但是还不知道具体在哪里找到这个实现。

加载Shared library

前一节讲过,native方法被编译成一个shared library。这个shared library需要被首先被JVM加载这样才能找到native实现的方法。`java.lang.System`提供了两个静态方法`load` 和 `loadLibrary` 在运行时加载shared library。前面例子中 `System.loadLibrary("hello-jni");` 就是用来加载hello-jni共享库的。

loadLibrary 在静态代码块中被调用,这样就可以在类加载的时候加载动态库,随后就可以在代码中访问native实现的方法。

注意,Java设计用于实现平台独立。loadLibrary作为Java framework的一个API同样做到了这一点。尽管实际提供的共享库是libhello-jni.so ,但是loadLibrary只穿入了hello-jni参数,这个方法会根据JVM底层的操作系统添加适当的前缀和后缀。前一节讲过,动态库的名称同Android.mk中的module name (变量:LOCAL_MODULE定义)。

loadLibrary方法同样没有指明加载动态库的具体位置。Java的java.library.path 属性包含了loadLibrary方法可以搜索的具体文件夹位置。在Android中包含/vendor/lib 和 ·/system/lib 两个文件夹。

loadLibrary会根据动态库名称是否相同来确定需要加载哪一个动态库。由于Android系统文件夹排在前面,所用开发者不要使用和系统相同的动态库名称,防止加载错误的动态库。

实现Native方法

打开hello-jin.c源文件,可以下详细的代码:
#include<string.h>
#include<jni.h>

jstring
Java_com_example_hellojni_HelloJni_stringFromJNI( JNIEnv* env,
                                                 jobject thiz )
{
    return (*env)->NewStringUTF(env, "Hello from JNI !");
}

stringFromJNI native方法在这里使用一个完全限定的方法名称Java_com_example_hellojni_HelloJni_stringFromJNI.这种显式方法名称允许JVM在动态库中自动找到native方法。

C/C++头文件生成器:javah

很明显,C中的这个方法声明非常复杂。所以JDK中提供了一个叫javah的工具来帮助我们执行这个过程。javah工具可以通过解析Java文件中的native表明的方法,并在头文件中自动生成这个方法的名称。

在命令行方式下运行

用javah对编译过的Java类文件进行操作:
javah –classpath bin/classes com.example.hellojni.HelloJni

会生成com_example_hellojni_HelloJni.h 头文件:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include< jni.h>
/* Header for class com_example_hellojni_HelloJni */
#ifndef _Included_com_example_hellojni_HelloJni
#define _Included_com_example_hellojni_HelloJni
#ifdef __cplusplus
extern "C" {
#endif

/*
 * Class: com_example_hellojni_HelloJni
 * Method: stringFromJNI
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_stringFromJNI
 (JNIEnv *, jobject);

/*
 * Class: com_example_hellojni_HelloJni
 * Method: unimplementedStringFromJNI
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_unimplementedStringFromJNI
 (JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif

C/C++的源文件只需要include这个头文件然后提供对应的实现即可:

#include "com_example_hellojni_HelloJni.h"

JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_stringFromJNI
 (JNIEnv * env, jobject thiz)
{
 return (*env)->NewStringUTF(env, "Hello from JNI !");
}

当然实际开发中不可能每次都需要手动调用javah,通常这个工具会在IDE中集成,自动完成这个过程。点击运行按钮,代码就可以跑起来了。

方法声明

看一下Java的`stringFromJNI`方法没有任何传参,但是native方法中有两个参数:
JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_stringFromJNI
 (JNIEnv *, jobject);
  1. JNIEnv是一个指向JNI方法table的指针
  2. jobject是Java对象HelloJni类的实例(即上面例子中的Activity)

JNIEnv接口指针

native代码通过JNIEnv接口指针提供的各种函数来使用JVM的功能。JNIEnv是一个指向thread-local数据的指针,数据中包含指向函数表的指针。native方法将JNIEnv作为第一个参数。

注意:传递给每个native方法的JNIEnv接口指针在线程相关的方法中也有效,但是不能被缓存或者在其他线程中使用。

根据native代码的实现是C还是C++,调用JNI的方法夜有所不同。

在C代码中,JNIEnv是一个指向JNINativeInterface 结构体的指针。这个指针在访问具体的JNI方前首先需要被解引用。C代码并不清楚当前的JNI环境,所以JNIEnv实例需要被作为第一个参数传入每一个方法调用:

return (*env)->NewStringUTF(env, "Hello from JNI !");

在C++代码中,JNIEnv是一个C++类的实例。JNI功能作为方法被暴露出来。所以直接直接调用这个实例对象的方法即可:

return env->NewStringUTF("Hello from JNI !");

实例和静态方法

Java中提供了两种方法:对象实例的方法和静态方法。对象实例方法和一个对象关联,必须先获取一个对象再调用相关方法。静态方法就不需要获取对象,直接调用即可。实例方法和静态方法都可以被声明为native方法。那么对应的native方法就有两种类型的实现:
JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_stringFromJNI
 (JNIEnv * env, jobject thiz);

JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_stringFromJNI
 (JNIEnv * env, jclass clazz);

重点区别在于第二个参数,实例方法的第二个参数是一个jobject对象,是调用类的对象实例引用。而静态方法的第二个参数是jclass,不是一个具体的实例对象引用,而是一个class类的引用。

通过返回值jstring可以看出JNI提供特定的类型用于在Java和native之间进行类型转换。

数据类型

Java中有两种数据类型:
  1. 基本数据类型:boolean,byte,char,short,int,long,float 和double
  2. 引用类型:String,arrays和其他类型

基本数据类型

基本数据类型提供了直接的映射关系,JNI使用类型映射使得这个过程对于开发者是透明的。
Java TypeJNI TypeC/C++ TypeSize
BooleanJbooleanunsigned charunsigned 8 bits
ByteJbytecharsigned 8 bits
CharJcharunsigned shortUnsigned 16 bits
ShortJShortshortSigned 16 bits
IntJintIntSigned 32 bits
LongJlonglong longSigned 64 bits
FloatJfloatfloat32 bits
DoubleJdoubledouble64 bits

引用类型:

| Java Type | Native Type | | --- | --- | | java.lang.class | jclass | | java.lang.Throwable | jthrowable | | java.lang.String | jstring | | Other objects | jobject | | java.lang.Object[] | jobjectArray | | boolean[] | jbooleanArray | | byte[] | jbyteArray | | char[] | jcharArray | | short[] | jshortArray | | int[] | jintArray | | long[] | jlongArray | | float[] | jfloatArray | | double[] | jdoubleArray | | Other Array | Jarray |

引用类型的操作

引用类型以不透明的方式传递给native代码而不是native 数据类型的方式,并且这些数据不能被直接使用和修改。JNI提供一系列API用于和引用类型交互。native方法可以通过JNIEnv接口指针中的API实现交互操作。接下来将会简单的过一下如下类型的API:
  1. Strings
  2. Arrays
  3. NIO Buffers
  4. Fields
  5. Methods

操作使用String

Java中的String在JNI中作为引用类型处理。这些引用类型不能像C中的string一样直接被使用。JNI提供了必要的方法来在Java String和C strings之间来回转换。因为Java中的String是不变的,JNI也就不会提供修改现有Java String的方法。

New String

native 代码可以通过NewString和NewStringUTF方法创建Unicode 和UTF-8字符串。这个方法传入C String返回Java String引用类型jstring:
jstring javaString;
javaString = (*env)->NewStringUTF(env, "Hello World!");

为了防止内存溢出,这些方法可以通过返回NULL来提示native代码已经向JVM抛出了异常,这样native 代码就不应该再继续了。后续会有专门的章节介绍异常处理。

把Java String转化成C String

为了使用Java String,必须首先将其转化为C string。可以通过GetStringChars和GetStringUTFChars来返回Unicode和UTF-8字符串(当然与之对应的是释放内存方法ReleaseStringChars和ReleaseStringUTFChars)。这两个方法可以在第三个参数位置传入一个可选引用类型参数 isCopy来让调用者决定返回的C string地址指向一个copy还是堆中的固定对象。
const jbyte* str;
jboolean isCopy;
str = (*env)->GetStringUTFChars(env, javaString, &isCopy);
if (0 != str) {
    printf("Java string: %s", str);
    if (JNI_TRUE == isCopy) {
        printf("C string is a copy of the Java string.");
    } else {
        printf("C string points to actual string.");
    }
}

(*env)->ReleaseStringUTFChars(env, javaString, str);

操作使用Array

Java的Array在JNI中作为引用类型被使用。JNI同样提供了操作和访问Java array的方法。

New Array

native 代码可以通过NewArray的方式创建新的array对象实例,这里的指的是Int,Char,Boolean等等,例如NewIntArray。在调用这些方法的时候需要传入一个int值用于表示生成array的大小:
jintArray javaArray;
javaArray = (*env)->NewIntArray(env, 10);

if (0 != javaArray) {
 /* You can now use the array. */
}

和NewString一样,为了防止内存溢出,NewArray方法会返回NULL来提示native代码已经向JVM抛出了一个异常并且native不应该再继续跑下去。

访问Array中的element

JNI提供了两个访问Java array元素的方式。native代码可以获取一个C array作为原Java array的copy 或者调用JNI方法获取一个指针访问array中的元素。
Copy array
`GetArrayRegion` 方法把原始Java array拷贝到C array中:
jint nativeArray[10];
(*env)->GetIntArrayRegion(env, javaArray, 0, 10, nativeArray);

后续native 代码就可以像正常的C array一样使用或者修改array中的元素。当native代码需要把修改后的array commit给Java array时,Set<Type>ArrayRegion 方法可以将C array copy回Java array:

(*env)->SetIntArrayRegion(env, javaArray, 0, 10, nativeArray);

当处理的array size非常大,copy这个过程会产生严重的性能问题。在这种情况下,只拷贝一小部分数据,然后逐步的处理而不是copy整个array就会非常有用。当然,在尽可能的情况下,JNI也提供了一系列方法用于获取直接指向array 元素的指针而不是他们的copy。

通过指针直接操作
`GetArrayElements` 方法允许native代码在可能得情况下获得直接指向array 元素的指针。这个方法可以在第三个位置传入一个可选引用类型参数,用于让调用方决定是返回指向C array的一个copy还是堆中的array:
jint* nativeDirectArray;
jboolean isCopy;

nativeDirectArray = (*env)->GetIntArrayElements(env, javaArray, &isCopy);

(*env)->ReleaseIntArrayElements(env, javaArray, nativeDirectArray, 0);

因为可以像普通的C array进行访问和操作,JNI不再提供相关方法。JNI要求native代码在结束后释放相关指针,否则会造成内存泄漏。JNI提供了Release<Type>ArrayElements 方法来释放Get<Type>ArrayElements 返回的C array。这个方法有第四个传参表明release mode,下表显示了支持的release mode:

Release ModeAction
0将内容copy回去并且释放native array
JNI_COMMIT将内容copy回去但是不释放native array。这个可以用作周期性的更新Java array
JNI_ABORT释放native array但是不将内容copy回去

new直接字节缓冲区 (New Direct Byte Buffer)

Native代码可以创建一个native C byte array作为基础的直接byte buffer被Java应用使用,详见下面代码:
unsigned char* buffer = (unsigned char*) malloc(1024);
...
jobject directBuffer;
directBuffer = (*env)->NewDirectByteBuffer(env, buffer, 1024);

注意:native方法的内存分配不在JVM的控制范围内。Native方法应该通过释放不再使用的分配内存的内存管理方法来防止内存泄漏。

获取直接字节缓冲区

直接字节缓冲区也可以在Java application中创建。Native代码可以使用`GetDirectBufferAddress` 方法调用来获取native byte array的内存地址:
unsigned char* buffer;
buffer = (unsigned char*) (*env)->GetDirectBufferAddress(env,
 directBuffer);

访问属性 Accessing Fields

Java有两种类型的成员变量:类成员变量和静态成员变量。每个类的实例都拥有自己的类成员变量,所有的实例共享静态成员变量。

JNI为两者都提供访问的方式。例:

public class JavaClass {
    /** Instance field */
    private String instanceField = "Instance Field";
    /** Static field */
    private static String staticField = "Static Field";
    ...
}

获取成员变量ID (Field ID)

JNI通过field id访问两种类型的成员变量。通过GetObjectClass方法获取给定类的对象获取class object,再通过class object获取field id,最终通过field id获取成员变量。根据成员变量是否为静态分别调用获取field id的方法:
// 获取class object
jclass clazz;
clazz = (*env)->GetObjectClass(env, instance);

//获取成员变量field id
jfieldID instanceFieldId;
instanceFieldId = (*env)->GetFieldID(env, clazz, "instanceField",
                                     "Ljava/lang/String;");

//获取静态成员变量field id
jfieldID staticFieldId;
staticFieldId = (*env)->GetStaticFieldID(env, clazz, "staticField",
                                         "Ljava/lang/String;");

// 获取成员变量值
jstring instanceField;
instanceField = (*env)->GetObjectField(env, clazz, instanceFieldId);

// 获取静态成员变量值
jstring staticField;
staticField = (*env)->GetStaticObjectField(env, clazz, staticFieldId);

建议:如果某个field id被频繁使用,可以通过缓存field id的方式提高应用性能。

考虑到内存溢出,两个方法都可能返回NULL,之后native代码就不应该再执行。

建议:native通过JNI方法获取一个成员变量的值需要两三个方法JNI调用,如果每个成员变量都这样获取就会造成性能影响。推荐将需要的成员变量值通过方法传递的方式传递到native,而不是通过JNI去获取。

调用方法

和成员变量一样,Java有两种方法:类成员方法和静态方法。同样JNI也提供了访问二者的方式。见代码
public class JavaClass {
    /**
     * Instance method.
     */
    private String instanceMethod() {
        return "Instance Method";
    }
    /**
     * Static method.
     */
    private static String staticMethod() {
        return "Static Method";
    }
    ...
}

获取method ID

```c // 获取class object jclass clazz; clazz = (*env)->GetObjectClass(env, instance);

jmethodID instanceMethodId; instanceMethodId = (*env)->GetMethodID(env, clazz, "instanceMethod", "()Ljava/lang/String;");

jmethodID staticMethodId; staticMethodId = (*env)->GetStaticMethodID(env, clazz, "staticMethod", "()Ljava/lang/String;");

jstring staticMethodResult; staticMethodResult = (*env)->CallStaticStringMethod(env, instance, staticMethodId);

jstring instanceMethodResult; instanceMethodResult = (*env)->CallStringMethod(env, instance, instanceMethodId);


和上面field类似,同样对method id 作cache,可以提高性能。在native和Java方法之间来回调用也会引起性能问题。



<h1 id="r08jh">Field 和 Method Descriptors</h1>
获取field id和method id都需要传入描述符(上面的例子:()Ljava/lang/String;)。这个描述符是根据下面的tab获得的:

| Java Type | Signature | |
| --- | --- | --- |
| Boolean | Z | |
| Byte | B | |
| Char | C | |
| Short | S | |
| Int | I | |
| Long | J | |
| Float | F | |
| Double | D | |
| full-qualified-class | L full-qualified-class | |
| type[] | [type | |
| method type | (arg-type) ret-type | |


<h1 id="XDlsb">Java Class File Disassemblerjavap</h1>
JDK通过命令行工具javap来获取类中成员变量和方法的descriptor



<h1 id="N4nGJ">捕获异常</h1>
JNIEnv提供了一系列方法用于处理异常。例:

```java
public class JavaClass {
    /**
 * Throwing method.
 */
    private void throwingMethod() throws NullPointerException {
        throw new NullPointerException("Null pointer");
    }
    /**
 * Access methods native method.
 */
    private native void accessMethods();
}

accessMethod的native方法如果调用了throwingMethod就需要显式处理异常。JNI提供了ExceptionOccured方法用于向JVM查询是否将会有异常。异常处理需要在方法调用完成后显式调用ExceptionClear清除异常。见代码:

jthrowable ex;
...
    (*env)->CallVoidMethod(env, instance, throwingMethodId);
ex = (*env)->ExceptionOccurred(env);
if (0 != ex) {
    (*env)->ExceptionClear(env);
    /* Exception handler. */
}

抛出异常

native 代码也可以通过JNI抛出异常。既然异常是Java类,native代码可以通过FindClass方法获取这个类。ThrowNew可以初始化并抛出一个异常,代码如下:
jclass clazz;
...
    clazz = (*env)->FindClass(env, "java/lang/NullPointerException");
if (0 != clazz) {
    (*env)->ThrowNew(env, clazz, "Exception message.");
}

因为native代码执行并不收JVM控制,抛出一个异常也不会停止native方法的执行并把方法执行交给异常处理器。在抛出异常之前,native方法需要释放所有分配的native资源,例如内存,并且处理妥当后返回。从JNIEnv接口获取的引用是local reference并且他们在native方法返回后被JVM自动释放。

本地和全局引用

引用在Java程序中扮演了非常重要的角色。JVM通过引用管理类对象的生命周期和垃圾回收。由于native 代码并不在这这个受管理的环境中,JNI提供一系列方法用于native 代码显式的管理对象的引用和生命周期。JNI支持三种引用类型:local reference、global reference和weak global reference。

本地引用 Local reference

大多数JNI函数返回local reference。Local reference不能被缓存也不能在后续的调用中被使用,因为他们的生命周期被限制在native方法中。Local reference在 native方法 返回后就被释放。例如FindClass返回一个local reference,当native方法返回后就立刻被释放。Native代码也可以通过`DeleteLocalReference` 方法显式地释放。
jclass clazz;
clazz = (*env)->FindClass(env, "java/lang/String");
...
(*env)->DeleteLocalReference(env,clazz);

全局引用 Global reference

Global reference在后续的native代码中保持有效除非他们被native代码显示地释放。

New Global Reference

直接看代码:
jclass localClazz;
jclass globalClazz;
...
localClazz = (*env)->FindClass(env, "java/lang/String");
globalClazz = (*env)->NewGlobalRef(env, localClazz);
...
(*env)->DeleteLocalRef(env, localClazz);


(*env)->DeleteGlobalRef(env, globalClazz);

Weak Global References

global reference的另一个flavor是weak global reference。和global reference一样,weak global reference在后续的native 代码调用中仍然有效 。但是和global reference不同的是,weak global reference不会影响底层对象被回收。

New Weak Gloal Reference

见代码:
jclass weakGlobalClazz;
weakGlobalClazz = (*env)->NewWeakGlobalRef(env, localClazz);


Global Reference在被显式释放前都是有效的,可以在后续的任何native方法和线程中使用。

检测Weak Global Reference是否有效

通过`IsSameObject` 方法可以检测weak global reference是否仍然指向一个有效的类实例。
if (JNI_FALSE == (*env)->IsSameObject(env, weakGlobalClazz, NULL)) {
 /* Object is still live and can be used. */
} else {
 /* Object is garbage collected and cannot be used. */
}

删除Weak Global Reference

见代码:
(*env)->DeleteWeakGlobalRef(env, weakGlobalClazz);

线程 Threading

JVM支持在多线程环境下运行native 代码。在native代码开发过程中有一些JNI技术点需要注意:
  1. Local reference 只在当前执行的线程的native方法中有效。Local Reference不能在多线程中共享。只有Global reference可以在多线程中共享。
  2. JNIEnv 接口指针在每次native方法调用时都会被传入,在同一个线程中有效 。JNIEnv不能被缓存也不能跨线程使用。

同步

同步是多线程程序设计最重要的特征。和Java同步类似,JNI Monitor允许native代码使用Java对象进行同步控制。由JVM确保获取monitor的线程执行安全,其他等待的线程只有在monitor可以被获取的时候才能执行。先看下Java的同步代码:
synchronized(obj) { 
    /* Synchronized thread-safe code block. */ 
}

在native代码中,相似的功能可以使用JNI Monitor实现,见代码:

if (JNI_OK == (*env)-> MonitorEnter(env, obj)) { 
    /* Error handling. */ 
} 

/* Synchronized thread-safe code block. */ 

if (JNI_OK == (*env)-> MonitorExit(env, obj)) { 
    /* Error handling. */ 
}

注意:调用MonitorEnter后要确保调用MonitorExit,否者会造成死锁

Native Threads

native 模块可能需要使用native线程并行执行代码。因为JVM并不能感知到native线程,所以他们之间不能直接通讯。Native 线程首先需要attach到JVM上这样才能和程序进行交互。

通过JVM接口指针提供的AttachCurrentThread 方法,native代码可以把native线程attach到 JVM上。JVM接口指针需要尽早被缓存 ,否则会无法获取(这里存疑)。先看下代码:

JavaVM* cachedJvm; 
... 
    JNIEnv* env; 
... 
    /* Attach the current thread to virtual machine. */ 
    (*cachedJvm)-> AttachCurrentThread(cachedJvm, &env, NULL); 

/* Thread can communicate with the Java application 
using the JNIEnv interface. */ 

/* Detach the current thread from virtual machine. */ 
(*cachedJvm)-> DetachCurrentThread(cachedJvm);

调用AttachCurrentThread 方法可以获取和当前线程绑定的有效 JNIEnv(即env)。如果反复地attach也不会造成异常。当native 线程完成任务的时候调用DetachCurrentThread 来detach native线程和JVM。

总结

如你所见,调用JNI方法需要至少调用两三个方法,如果方法增多,会变成一件非常麻烦的事。在下一章中,会学习到一些开源的方案用于解决这个问题。