JNI 详解

JNI,全称为Java Native Interface,即Java本地接口,JNI是Java调用Native 语言的一种特性。通过JNI可以使得Java与C/C++机型交互。即可以在Java代码中调用C/C++等语言的代码或者在C/C++代码中调用Java代码。由于JNI是JVM规范的一部分,因此可以将我们写的JNI的程序在任何实现了JNI规范的Java虚拟机中运行。

JNI的主要竞争优势在于:它在设计之初就确保了二进制的兼容性,JNI编写的应用程序兼容性以及其在某些具体平台上的Java虚拟机兼容性(当谈及JNI时,这里并不特比针对Davik虚拟机,JNI适用于所有JVM虚拟机)。这就是为什么C/C++编译后的代码无论在任何平台上都能执行。不过,一些早期版本并不支持二进制兼容。二进制兼容性是一种程序兼容性类型,允许一个程序在不改变其可执行文件的条件下在不同的编译环境中工作。

JNI下一共涉及到三个角色:C/C++代码、本地方法接口类、Java层中具体业务类。

JNI的命名规则

举例如下:

1
JNIExport jstring JNICALL Java_com_example_hellojni_MainActivity_stringFromJNI( JNIEnv* env,jobject thiz )

jstring 是返回值类型
Java_com_example_hellojni 是包名
MainActivity 是类名
stringFromJNI 是方法名

其中JNIExport和JNICALL是不固定保留的关键字不要修改

如何实现JNI

JNI开发流程的步骤:

  • 第1步:在Java中先声明一个native方法
  • 第2步:编译Java源文件javac得到.class文件
  • 第3步:通过javah -jni命令导出JNI的.h头文件
  • 第4步:使用Java需要交互的本地代码,实现在Java中声明的Native方法(如果Java需要与C++交互,那么就用C++实现Java的Native方法。)
  • 第5步:将本地代码编译成动态库(Windows系统下是.dll文件,如果是Linux系统下是.so文件,如果是Mac系统下是.jnilib)
  • 第6步:通过Java命令执行Java程序,最终实现Java调用本地代码。

PS:javah 是JDK自带的一个命令,-jni参数表示将class 中用到native 声明的函数生成JNI 规则的函数

JNI结构


这张JNI函数表的组成就像C++的虚函数表。虚拟机可以运行多张函数表,举例来说,一张调试函数表,另一张是调用函数表。JNI接口指针仅在当前线程中起作用。这意味着指针不能从一个线程进入另一个线程。然而,可以在不同的线程中调用本地方法。

示例如下:

1
2
3
4
5
6
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (JNIEnv *env, jobject obj, jint i, jstring s)
{
const char *str = (*env)->GetStringUTFChars(env, s, 0);
(*env)->ReleaseStringUTFChars(env, s, str);
return 10;
}

里面的方法有三个入参,我们就依次来看下:

  • *env:一个接口指针
  • obj:在本地方法中声明的对象引用
  • i和s:用于传递的参数

关于obj、i和s的类型大家可以参考下面的JNI数据类型,JNI有自己的原始数据类型和数据引用类型如下:

JNI原理

Java语言的执行环境是Java虚拟机(JVM),JVM其实是主机环境中的一个进程,每个JVM虚拟机都在本地环境中有一个JavaVM结构体,该结构体在创建Java虚拟机时被返回,在JNI环境中创建JVM的函数为JNI_CreateJavaVM。

1
JNI_CreateJavaVM(JavaVM **pvm, void **penv, void*args);

JavaVM

JavaVM是Java虚拟机在JNI层的代表,JNI全局仅仅有一个JavaVM结构中封装了一些函数指针(或叫函数表结构),JavaVM中封装的这些函数指针主要是对JVM操作接口。另外,在C和C++中的JavaVM的定义有所不同,在C中JavaVM是JNIInvokeInterface_类型指针,而在C++中有对JNIInvokeInterface_进行了一次封装,比C中少了一个参数,这也是为什么JNI代码更推荐使用C++来编写的原因。

JNIEnv

JNIEnv是一个线程相关的结构体,该结构体代表了Java在本线程的执行环境。该 JNIEnv 将用于线程本地存储。因此,您无法在线程之间共享 JNIEnv

一个JVM对应一个JavaVM结构,而一个JVM中可能创建多个Java线程,每个线程对应一个JNIEnv结构,它们保存在线程本地存储TLS中。因此,不同的线程的JNIEnv是不同,也不能相互共享使用。JNIEnv结构也是一个函数表,在本地代码中通过JNIEnv的函数表来操作Java数据或者调用Java方法。也就是说,只要在本地代码中拿到了JNIEnv结构,就可以在本地代码中调用Java代码。

JNIEnv的作用

  • 调用Java 函数:JNIEnv代表了Java执行环境,能够使用JNIEnv调用Java中的代码
  • 操作Java代码:Java对象传入JNI层就是jobject对象,需要使用JNIEnv来操作这个Java对象

JNIEnv的创建

JNIEnv 创建与释放:从JavaVM获得,这里面又分为C与C++,我们就依次来看下:

  • C 中——JNIInvokeInterface:JNIInvokeInterface是C语言环境中的JavaVM结构体,调用 (*AttachCurrentThread)(JavaVM*, JNIEnv*, void) 方法,能够获得JNIEnv结构体
  • C++中 ——_JavaVM:_JavaVM是C++中JavaVM结构体,调用jint AttachCurrentThread(JNIEnv** p_env, void* thr_args) 方法,能够获取JNIEnv结构体;

JNIEnv的释放

  • C 中释放:调用JavaVM结构体JNIInvokeInterface中的(*DetachCurrentThread)(JavaVM*)方法,能够释放本线程的JNIEnv
  • C++ 中释放:调用JavaVM结构体_JavaVM中的jint DetachCurrentThread(){ return functions->DetachCurrentThread(this); } 方法,就可以释放 本线程的JNIEnv

JNIEnv与线程

JNIEnv是线程相关的,即在每一个线程中都有一个JNIEnv指针,每个JNIEnv都是线程专有的,其他线程不能使用本线程中的JNIEnv,即线程A不能调用线程B的JNIEnv。所以JNIEnv不能跨线程。

JNIEnv结构

JNIEnv是一个指针,指向一个线程相关的结构,线程相关结构指向JNI函数指针数组,这个数组中存放了大量的JNI函数指针,这些指针指向了详细的JNI函数:

与JNIEnv相关的常用函数

创建Java中的对象
  • jobject NewObject(JNIEnv *env, jclass clazz,jmethodID methodID, …):
  • jobject NewObjectA(JNIEnv *env, jclass clazz,jmethodID methodID, const jvalue *args):
  • jobject NewObjectV(JNIEnv *env, jclass clazz,jmethodID methodID, va_list args):

第一个参数jclass class 代表的你要创建哪个类的对象,第二个参数,jmethodID methodID代表你要使用那个构造方法ID来创建这个对象。只要有jclass和jmethodID,我们就可以在本地方法创建这个Java类的对象。

创建Java类中的String对象

jstring NewString(JNIEnv *env, const jchar *unicodeChars, jsize len):

通过Unicode字符的数组来创建一个新的String对象。
env是JNI接口指针;unicodeChars是指向Unicode字符串的指针;len是Unicode字符串的长度。返回值是Java字符串对象,如果无法构造该字符串,则为null。

那有没有一个直接直接new一个utf-8的字符串的方法呢?答案是有的,就是jstring NewStringUTF(JNIEnv *env, const char *bytes)这个方法就是直接new一个编码为utf-8的字符串。

创建类型为基本类型PrimitiveType的数组

ArrayType NewArray(JNIEnv *env, jsize length)

指定一个长度然后返回相应的Java基本类型的数组。用于构造一个新的数组对象,类型是原始类型。基本的原始类型如下:

方法 返回值
NewArray Routines Array Type
NewBooleanArray() jbooleanArray
NewByteArray() jbyteArray
NewCharArray() jcharArray
NewShortArray() jshortArray
NewIntArray() jintArray
NewLongArray() jlongArray
NewFloatArray() jfloatArray
NewDoubleArray() jdoubleArray
创建类型为elementClass的数组

jobjectArray NewObjectArray(JNIEnv *env, jsize length, jclass elementClass, jobject initialElement);

造一个新的数据组,类型是elementClass,所有类型都被初始化为initialElement。

获取数组中某个位置的元素

jobject GetObjectArrayElement(JNIEnv *env, jobjectArray array, jsize index);

返回Object数组的一个元素

获取数组的长度

jsize GetArrayLength(JNIEnv *env, jarray array);

获取array数组的长度.

GetArrayElements

NativeType *GetArrayElements(JNIEnv *env, ArrayType array, jboolean *isCopy);

返回指向数组元素的指针,如果操作失败则返回NULL。

这是用来返回原生数据类型数组体的家族函数。函数返回的指针在调用对应的ReleaseArrayElements()函数之前都是有效的(就是指针指向的区域没有被释放,是可以使用的)。因为这个函数返回的数组可能是Java数组的一份拷贝,所以直到调用ReleaseArrayElements()方法,对返回的数组所做的修改才会反映到原始数组中。

GetArrayElements()函数的返回值是和JVM相关的,如果JVM的GC支持pin操作,那么返回值就是指向原始数组的指针;否则返回的就是原始数组的一份拷贝的首地址。

如果isCopy不为NULL,如果进行了复制,则*isCopy设置为JNI_TRUE; 如果没有复制,则设置为JNI_FALSE。

下表描述了特定的原始数组元素访问器。您应该进行以下替换:

  • 将GetArrayElements替换为下表中的一个实际原始元素访问器例程名称。
  • 将ArrayType替换为相应的数组类型。
  • 将NativeType替换为该例程的相应本地类型。

无论如何在JVM中表示布尔数组,GetBooleanArrayElements()始终返回指向jbooleans的指针,每个字节表示一个元素(解包表示)。其他类型的所有数组都保证在内存中是连续的。

GetArrayElements例程 数组类型 本地类型
GetBooleanArrayElements() jbooleanArray jboolean
GetByteArrayElements() jbyteArray jbyte
GetCharArrayElements() jcharArray jchar
GetShortArrayElements() jshortArray jshort
GetIntArrayElements() jintArray jint
GetLongArrayElements() jlongArray jlong
GetFloatArrayElements() jfloatArray jfloat
GetDoubleArrayElements() jdoubleArray jdouble
ReleaseArrayElements

void ReleaseArrayElements(JNIEnv *env, ArrayType array, NativeType *elems, jint mode);

一系列函数,通知VM本地代码不再需要访问elems。elems参数是使用相应的GetArrayElements()函数从数组派生的指针。如有必要,此函数会将对elems所做的所有更改复制回原始数组

mode参数提供有关如何释放数组缓冲区的信息。如果elems不是数组中元素的副本,则mode无效。否则,模式会产生以下影响,如下表所示:

模式 行为
0 复制回内容并释放elems缓冲区
JNI_COMMIT 复制回内容,但不释放elems缓冲区
JNI_ABORT 释放缓冲区而不复制回可能的更改

在大多数情况下,程序员将“0”传递给mode参数,以确保固定和复制数组的一致行为。其他选项使程序员可以更好地控制内存管理,并且应该非常谨慎地使用。

关于更多JNI的常用方法,文档可以参考 JNI Functions

JNI的引用

在JNI规范中定义了三种引用:局部引用(Local Reference)、全局引用(Global Reference)、弱全局引用(Weak Global Reference)。

局部引用(Local Reference)

局部引用,也成本地引用,通常是在函数中创建并使用。会阻止GC回收所有引用对象。

最常见的引用类型,基本上通过JNI返回来的引用都是局部引用,例如使用NewObject,就会返回创建出来的实例的局部引用,局部引用值在该native函数有效,所有在该函数中产生的局部引用,都会在函数返回的时候自动释放(freed),也可以使用DeleteLocalRef函数手动释放该应用。之所以使用DeleteLocalRef函数:实际上局部引用存在,就会防止其指向对象被垃圾回收期回收,尤其是当一个局部变量引用指向一个很庞大的对象,或是在一个循环中生成一个局部引用,最好的做法就是在使用完该对象后,或在该循环尾部把这个引用是释放掉,以确保在垃圾回收器被触发的时候被回收。在局部引用的有效期中,可以传递到别的本地函数中,要强调的是它的有效期仍然只是在第一次的Java本地函数调用中,所以千万不能用C++全部变量保存它或是把它定义为C++静态局部变量。

全局引用(Global Reference)

全局引用可以跨方法、跨线程使用,直到被开发者显式释放。类似局部引用,一个全局引用在被释放前保证引用对象不被GC回收。能创建全部引用的函数只有NewGlobalRef,而释放它需要使用ReleaseGlobalRef函数

弱全局引用(Weak Global Reference)

与全局引用类似,创建跟删除都需要由编程人员来进行,不一样的是,弱引用将不会阻止垃圾回收期回收这个引用所指向的对象,所以在使用时需要多加小心,它所引用的对象可能是不存在的或者已经被回收。

通过使用NewWeakGlobalRef、ReleaseWeakGlobalRef来产生和解除引用。

引用比较

在给定两个引用,不管是什么引用,我们只需要调用IsSameObject函数来判断他们是否是指向相同的对象。代码如下:

1
(*env)->IsSameObject(env, obj1, obj2)

如果obj1和obj2指向相同的对象,则返回JNI_TRUE(或者1),否则返回JNI_FALSE(或者0),

PS:有一个特殊的引用需要注意:NULL,JNI中的NULL引用指向JVM中的null对象,如果obj是一个全局或者局部引用,使用(*env)->IsSameObject(env, obj, NULL)或者obj == NULL用来判断obj是否指向一个null对象即可。但是需要注意的是,IsSameObject用于弱全局引用与NULL比较时,返回值的意义是不同于局部引用和全局引用的。代码如下:

1
2
3
4
jobject local_obj_ref = (*env)->NewObject(env, xxx_cls,xxx_mid);
jobject g_obj_ref = (*env)->NewWeakGlobalRef(env, local_ref);
// ... 业务逻辑处理
jboolean isEqual = (*env)->IsSameObject(env, g_obj_ref, NULL);

UTF-8 和 UTF-16 字符串

Java 编程语言使用的是 UTF-16。为方便起见,JNI 还提供了使用修改后的 UTF-8 的方法。修改后的编码对 C 代码非常有用,因为它将 \u0000 编码为 0xc0 0x80,而不是 0x00。这样做的好处是,您可以依靠以零终止的 C 样式字符串,非常适合与标准 libc 字符串函数配合使用。但缺点是,您无法将任意 UTF-8 数据传递给 JNI 并期望它能够正常工作

如果可能,使用 UTF-16 字符串执行操作通常会更快。Android 目前不需要 GetStringChars 的副本,而 GetStringUTFChars 需要分配和转换为 UTF-8。请注意,UTF-16 字符串不是以零终止的,并且允许使用 \u0000,因此您需要保留字符串长度和 jchar 指针。

不要忘记 Release 您 Get 的字符串。字符串函数会返回 jchar* 或 jbyte*,它们是指向原始数据而非局部引用的 C 样式指针。这些指针在调用 Release 之前保证有效,这意味着在原生方法返回时不会释放这些指针。

传递给 NewStringUTF 的数据必须采用修改后的 UTF-8 格式。一种常见的错误就是从文件或网络数据流中读取字符数据,并在未过滤的情况下将其传递给 NewStringUTF。除非您确定数据是有效的 MUTF-8(或 7 位 ASCII,这是一个兼容子集),否则您需要剔除无效字符或将它们转换为适当的修改后的 UTF-8 格式。如果不这样做,UTF-16 转换可能会产生意外的结果。CheckJNI 默认状态下为模拟器启用,它会扫描字符串并且在收到无效输入时会中止虚拟机。

参考资料

https://www.jianshu.com/p/87ce6f565d37
JNI Functions
JNI的常用方法的中文API
JNI 提示
Android NDK 从入门到精通(汇总篇)