一、JAVA JNI介绍
JNI全称为Java Native Interface. 它可以简单理解为是本地方法的接口,即允许在Java虚拟机里面的Java代码可以和如C,C++等其他底层语言进行交互(即可互相调用)。
一般情况下,当你无法用纯Java来实现需求的时候,就需要使用JNI来用底层语言编写的本地方法来满足这些该需求。
例如以下的几种场景可能需要用到JNI:
-
Java库无法提供基于平台系统相关特性的功能,如平台特有的功能或接口等。
-
已经用其他语言写好的库,需要用Java调用,而不想重现编码,而想直接复用它们。
-
希望实现一部分时间和性能要求都很高的逻辑,需要用底层语言来编写,如视频图片处理,游戏逻辑等。
使用JNI,你可以使用本地方法来:
-
创造,交互,更新Java对象(包括array和string)
-
调用Java方法。
-
catch或抛出异常。
-
载入Java Class,获取class信息。
-
执行运行时类型检查(runtime type checking)
当你使用JNI的 Invocation API 可以允许任何本地应用内嵌Java虚拟机,这允许程序员非常简单的可以使已经写好的程序变成 Java-enabled , 而不需要链接到虚拟机源码。
当然你还可以使用JNI来使得Java方法可以直接调用本地方法。本地方法也可以调用Java方法。
JNI的目标
JNI的目标就是提供一套统一的,考虑充分的标准接口来为大家提供好处:
-
所有Java虚拟机都支持大量的本地代码。
-
工具开发者不需要维护多种不同的本地方法接口。
-
应用开发者可以只写一个版本的本地代码,并且可以在不同的Java虚拟机上正常运行。
标准的统一的本地方法接口应该满足一下几点要求:
-
二进制的兼容性。主要目的是提供本地方法库访问在所有平台上实现的Java虚拟机的二进制兼容性。
-
高效性。为了支持性能要求高的代码,本地方法接口必须执行很少的开销。所有已知的技术在确保Java虚拟机的独立性(以及二进制的兼容性)一定会带来一定的开销。所有必须以某种方法再效率和虚拟机独立性之间寻求平衡点。
-
功能性。接口必须暴露足够的Java虚拟机内部,来允许本地方法来访问它们以完成有用的任务。
JNI接口函数和指针
本地代码访问Java虚拟机的特性是靠调用JNI函数来实现的。JNI函数可以通过一个接口指针(interface pointer)来访问到。一个接口指针是指向指针的指针,它指向的指针(pre-thread JNI data stucture)又指向一个指针的列表,其中每个指针都指向一个接口方法(interface function)。
JNI interface pointer -> Pointer -> Array of pointers to JNI functions -> interface function
JNI Interface的组织形式类似于c++ 虚方法table或一个COM接口。使用interface table,而不使用硬连接到函数实体的好处是,隔离了JNI名称空间和本地代码。一个虚拟机可以简单的提供多个版本的JNI函数表。例如一个虚拟机可以提供两个函数表:
-
一个执行严格非法参数检查,并且便于测试。
-
另外一个执行JNI标准要求的最小的检查,使得更加高效。
JNI interface pointer只在当前线程有效,本地方法绝对不能将JNI interface pointer传递给其他线程。Java虚拟机对于JNI的实现可能使用了Thread-Local数据,所以不能跨线程。
本地方法以参数的形式接收到JNI interface pointer。Java虚拟机确保了同一个线程多次调用本地方法的时候,这些本地方法都接受到同一个JNI interface pointer对象。但在多线程环境下,一个本地方法可能被多个线程调用,那么传入的JNI interface pointers就不一定是同一个对象了。
编译,载入和链接本地方法
因为Java虚拟机是多线程的,因此本地库应该使用支持多线程的编译器来编译和链接。例如使用SUN studio编译器时应该使用 -mt flag来编译C++代码。使用GNU gcc编译器的时候应该使用 D_REENTRANT 或 ``D_POSIX_C_SOURCE` flag来编译。更多信息请查阅编译器文档。
本地方法使用 System.loadLibrary 方法来载入到Java虚拟机。例如下面的例子,在类加载的时候载入一个本地库,其中拥有一个本地方法:
package pkg;
class Cls {
static {
System.loadLibrary("pkg_Cls");
}
public native double nativeFunction(int i, String s);
}
System.loadLibaray 方法的参数为任意本地库的名字。系统允许使用标准,但符合系统标准,来将库的名字转换成本地库文件的名字。例如在Solaris系统上,会将 pkg_Cls 名字转换成 pkg_Cls.so , 而在windows系统上,会将其转换为 pkg_Cls.dll 。
开发者可以使用一个本地库来放置所有的本地方法,只要是同一个class loader载入的类都可以访问到这些本地的方法。Java虚拟机内部为每个class Loader都维护一组本地库。开发商应该谨慎选择库的名称来避免名字冲突。
如果当前操作系统不支持动态链接(dynamic linking), 所有本地方法必须预链接(prelinked)到虚拟机。这种情况下,虚拟机在执行 System.loadLibrary 的时候不会真正的载入本地库。
开发者也可以使用JNI函数 RegisterNatives() 来注册将一个本地方法注册到指定的类上面去。这个函数在静态链接函数(statically linked functions)的时候会特别有用。
本地方法的命名规范
动态链接定位函数靠的是它们的函数名。 一个本地方法的函数名分为如下几个部分:
-
Java_ 的前缀
-
用_为分隔符的类名全称,例如 com_package_SomeClass
-
一个_分隔符
-
方法名
-
对于重载方法(overload),后面还有跟两个下划线,以及参数签名。(因为C的函数签名只有函数名,而Java的方法签名除了方法名,还有参数,避免冲突所以重载方法需要加上后缀避免冲突)
Java虚拟机会在本地库里面寻找函数名,虚拟机会首先查找短名称,即不包括参数的方法签名(仅方法名)。然后才会去找长名称,即包括参数的方法签名(方法名+参数列表)。开发者应该只在存在两个本地方法被重载的时候才使用长名称。如果一个是本地方法,而另一个重载方式是Java方法,则不会有问题,因为Java方法不在本地库中。
对于长名字,即重载的情况解释比较难理解,这里我来举一个例子来看,例如有几个本地方法和Java方法存在重载情况:(这个例子不是官方文档里面的)
public native String echo(String arg0);
public native String echo(String arg0, String arg1);
// Java方法和Native方法的重载不会互相影响
public String echo(String arg0, String arg1, String arg2) {
return null;
}
这个例子里面一共有三个重载方法,其中两个本地方法,一个Java方法。
首先Java方法和本地方法之间的重载不会影响JNI方法的签名写法,但两个本地方法之间重载,因为方法名相同,会造成函数名冲突,所以必须使用增加了参数后缀的长方法名。例如:
/*
* Class: test_lds_com_androidndktest_NdkTestJava
* Method: echo
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_test_lds_com_androidndktest_NdkTestJava_echo__Ljava_lang_String_2
(JNIEnv *, jobject, jstring);
/*
* Class: test_lds_com_androidndktest_NdkTestJava
* Method: echo
* Signature: (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_test_lds_com_androidndktest_NdkTestJava_echo__Ljava_lang_String_2Ljava_lang_String_2
(JNIEnv *, jobject, jstring, jstring);
对于一些特殊字符,使用转义字符来代替,例如作为分隔符的下划线如果在方法名中,则会被替换成 _1
转义字符 | 含义 |
---|---|
_0XXXX | Unicode字符 |
_1 | 下划线 _ |
_2 | 分号 ; |
_3 | 中括号 [ |
对于本地方法和interface api都遵循当前系统平台的标准函数调用约定(calling convention)。例如 UNIX 系统上使用C语言的调用约定,而在window系统上,使用个 __stdcall 约定。
本地方法参数
所有本地方法的第一个参数都是JNI接口指针(JNI interface pointer),它的类型是 JNIEnv 。第二个参数根据本地方法是否为静态的而不一样。如果不是静态的本地方法,第二个参数则是一个对象的引用,如果是静态的本地方法,第二个参数则是一个Java类的引用。
剩下的参数就是一一对应Java方法的参数列表。本地方法也可以通过返回值来返回其结果。
例如Java的本地方法定义如下:
package Pkg;
class Cls {
native double f(int i, String s);
}
则C函数的长名字则为 Java_pkg_Cls_f__ILjava_Lang_String2 , 它的实现则为:
jdouble Java_Pkg_Cls_f__ILJava_Lang_String2(
JNIEnv *env, /* JNI Interface Pointer */
jobject obj, /* "this" 指针 */
jint i, /* java方法 arg0 */
jstring s) /* java方法 arg1 */
{
// 转换一个Java String对象到C风格的字符串数组
const char *str = (*env)->GetStrinUTFChars(env, s, 0);
// 释放str
(*env)->ReleaseStrinUTFChars(env, s, str);
return 0;
}
使用C++来实现,则可以写稍后简洁一点的代码:
extern "C"
jdouble Java_Pkg_Cls_f__ILJava_Lang_String2 (
JNIEnv *env, /* JNI Interface Pointer */
jobject obj, /* "this" 指针 */
jint i, /* java方法 arg0 */
jstring s) /* java方法 arg1 */
{
const char *str = env->GetStringUTFChars(s, 0);
env->ReleaseStringUTFChars(s, str);
return 0;
}
引用传递Java对象
基本类型,例如int,char等这种个,是在Java方法和本地方法之间互相拷贝,即值传递。而对于Java对象,则不太一样,它们是引用传递。
Java虚拟机必须时刻跟踪传递给本地代码的所有Java对象,这样这些对象才不会被垃圾收集器释放掉。因此本地方法有一种途径来告诉虚拟机有些对象不再被使用了,可以被回收了。此外,本地方法还应该能让垃圾回收器移动对象的引用。(the garbage collector must be able to move an object referred to by the native code)
全局和本地引用
JNI将本地代码使用对象引用分为两种:
-
本地引用:本地引用的生命周期只在本地方法被调用的有效,退出方法的时候自动释放掉。
-
全局引用:全局引用的生命周期会一直持续到明确的释放它的时候。
这里可以理解,在Java层新建对象分配内存,有GC来收集,而由本地方法新建的对象,则无法由GC来管理,因此必须自己来管理它们。
而对于本地引用,有点类似Java栈里面的局部变量,进入方法时栈帧里分配内存,退出方法栈帧的时候自动释放内存,因此这部分的内存其实是不需要GC来管理。
而对于全部引用,应该遵循谁分配谁释放的约定,由本地方法分配内存,并由本地方法释放内存。
Java对象是作为局部引用来传递给本地方法的。所有JNI函数返回的Java对象也是局部引用。JNI允许开发者基于局部引用来创建全局引用。JNI方法期望Java对象接受全局或局部引用。一个本地方法可能返回给虚拟机一个局部引用或全局引用的结果。
一般情况下,开发者应该主要依靠虚拟机在本地方法退出的时候自动释放局部引用。但也有几种情况下开发者应该明确的释放局部引用。例如:
-
一个本地方法访问一个超大的Java对象,因此会为这个Java对象创建一个局部引用。本地方法还需要在返回之前做一些其他计算,而这种因为有一个局部引用指向这个Java对象,因此垃圾回收器不能回收掉这个大的对象。而这个对象已经没有用了,因此需要本地方法明确的去释放它,来使得这个java对象可以被释放掉。
-
一个本地方法创建了大量的局部引用,但不是同时都需要它们。因此虚拟机需要大量空间来持续跟踪这些局部引用,所以创建大量的局部引用可能会造成系统内部不足。例如一个本地放在在for循环一个大的数组,数组里的元素以局部引用的形式引用,在遍历的时候一次操作一个元素,而在遍历结束以后,开发者已经不再需要这个数组里面的元素的局部引用了。
JNI允许开发者在任何时间使用本地方法来删除局部引用。为了确保开发者可以手动释放这些布局引用,JNI函数不允许创建额外的局部引用,除了作为结果返回的引用。
局部引用只在它们被创建的线程中有效,本地代码绝对将一个局部引用从一个线程传给另一个线程。
局部引用的实现
为了实现局部变量,Java虚拟机创建了一个注册表来记录从Java到本地方法传递。一个注册表映射了一个不可移动的布局变量到一个Java对象,并防止这些Java对象被垃圾回收器回收。所有被传递给本地方法的Java对象(包括JNI函数返回的那些对象)都会被自动加入注册表。当本地方法退出的时候自动将它们从注册表中删除,来使其可以被垃圾回收器收回。
对于注册表,有不同的方式来实现,例如使用table, 链表,hash table等。尽管计算引用计数可能对于避免重复讲同一个对象添加注册表,但JNI实现没有一路去检测和避免重复对象。
注意,局部引用不能通过保守扫描native stack来完全实现,因为本地代码可能将局部引用存储到全局或堆数据结构里面。
访问Java对象
JNI 提供丰富的访问函数来访问全局和局部引用。这意味着无论Java虚拟机在内部如何描述一个Java对象,本地方法的实现都会其作用,也就是为什么JNI可以被大量不同的虚拟机支持的重要原因。
通过模糊的引用来使用访问器函数肯定会直接使用C数据结构体的开销要大。但我们相信,在大多数情况下,Java开发者使用本地方法是用来处理不容易的任务,因此可能忽略接口造成的开销。
访问基本类型数组
对于包括很多基本数据类型的大Java对象,例如integer array或strings,其造成的额外开销是不可接受的。遍历一个Java数组并处理其中每个元素是非常低效的。
解决这个问题的办法是引入一个叫做 pinning 的概念,使得本地方法可以向Java虚拟机要求将数组的内容pin down出来。本地方法可以直接接收到这些元素的直接指针(direct pointer),为了实现这个目的,因此有两个影响:
-
垃圾收集器必须支持 pinning
-
虚拟机必须用连续的内存空间来放置基本类型的数组。
因此我们可以采取妥协的方法来解决上面的两个问题:
首先,我们提供一组函数用来在Java数组和本地方法buffer之间复制基本类型数组的元素。如果本地方法只需要访问一个大数组中的一小部分元素则可以使用这些函数。
然后,开发者可以使用另外一组函数来获取 pinned-down 版本的数组元素,记住这些函数可能需要JAVA虚拟机来分配和复制数组。至于是否需要复制数组则取决于虚拟机的实现:
-
如果垃圾收集器支持 pinning ,则不需要复制数组。
-
另一种情况下数组则会被复制到不可移动的内存空间(例如C heap),返回一份复制后的数组指针。
最后,接口提供函数来通知虚拟机,本地代码不再需要这些数组里的元素了。当你调用了这些函数,系统要么 unpins 数组,或者释放复制的数组,并引用到之前的数组(即逆操作)。
我们的目标是提供灵活性。垃圾回收器的算法可以根据不同的情况决定是 pinning 还是 copying 数组。因此,它可以在小对象数组的时候选择copy,而在大对象的时候选择pin。
JNI的实现必须确保在不同线程的原生方法可以同时访问到同一个数组。例如JNI必须在内部保留一份计数器来计算每一个 pinned 的数组,从而使得一个线程不会 unpin 一个被另一个线程 pinned 的数组。记住JNI不需要使用本地方法来锁住基本类型数组,同时从不同的线程来修改数组可能造成不确定的结果。
访问域和方法
JNI允许本地方法来访问Java对象的成员域和成员方法。JNI以符号名和类型签名来ID成员域和成员方法。例如定位到一个Java方法:
例如Java类为:
class Cls {
public double f(String input);
}
则原生代码可先定位到该Java方法(根据其名字和签名):
jmethodID method_id = env->GetMethodID(cls, "f", "(ILjava/lang/String;)D");
然后原生代码即可使用method id来调用该方法:
jdouble result = env->CallDoubleMethod(obj, method_id, 10, str);
除了CallDoubleMethod 之外还是有一系列的同类函数来调用不同类型返回值的Java方法。
report程序错误
JNI并不会检查程序错误,例如空指针或者不合法的参数类型。JNI不检查这些程序错误的原因是:
-
强制JNI函数去检查所有可能的错误可能会导致正常运行的本地方法性能下降。
-
在很多情况下,没有足够的运行时类型信息来执行这些检查。
很多C语言的库并不会防范程序错误,例如printf函数,它通常在接收到一个非法的地址时,不会返回一个错误码,而是直接抛出运行时异常。强制要求C库的函数去检查所有可能的异常情况可能会导致这种检查代码的重复--一份在用户的代码里,一份在库的代码里。
开发者绝对不能传入非法指针或错误参数给JNI函数。这样做可能会导致不确定的结果,例如系统状态或虚拟机崩溃。
Java异常
JNI允许本地方法去抛出普通的Java异常。本地代码也可以处理Java方法抛出的异常。如果Java异常没有被捕捉则会重新传回给虚拟机。
异常和错误码
某些JNI函数会使用Java异常机制来报告异常情况。 在大多数情况下,JNI函数通过返回一个错误码并且抛出一个Java异常来报告异常情况。这个错误码通过是超出正常返回的值(例如NULL)。因此,开发者可以:
-
快速的检查最后JNI调用的返回值来确定错误的发生情况。
-
通过调用 ExceptionOccurred() 来获取关于错误更多的异常详情。
有两种情况下,开发者需要检查异常详情,而不是快速的检查返回的错误码:
-
JNI函数调用一个Java方法来返回一个结果。则开发者必须调用 ExceptionOccurred() 来检查是否在Java方法执行的过程中出现异常情况。
-
有些JNI数组访问函数没有返回错误码,但可能会抛出 ArrayIndexOutOfBoundsException 或 ArrayStoreException
其他的所有情况下,当没有返回一个错误码的时候,则确保了不会有被异常抛出。
异步异常
在多线程环境下,线程可能抛出一个异步的异常。一个异步的异常并不会马上影响到当前线程中正在执行的本地代码,直到:
-
本地方法调用一个JNI函数可能抛出一个同步的异常。
-
本地方法主动调用 ExceptionOccurred 来明确的检查同步和异常异常。
本机方法应该将 ExceptionOccurred() 检查插入到必要的位置(例如,在没有其他异常检查的循环中),以确保当前线程在合理的时间内响应异步异常。
异常捕捉
有两种方法在本地代码中捕捉异常:
-
本地方法可以选择立即返回,促使异常抛会给Java方法,让其自己处理该异常。
-
本地方法可以调用 ExceptionClear 来清除异常,然后执行自己的异常处理代码。
在一个异常被抛出时,本地代码首先必须在其他JNI调用前清除异常,当有一个待处理的异常时,JNI函数可以安全的调用如下方法:
ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
Release<Type>ArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()