Author: geneblue
Blog: https://geneblue.github.io/
本篇主要关注JNI设计中的主要问题。这里讲述的大多数问题都与本地方法有关。Invocation API的介绍在Chapter 5中。
JNI Interface Functions and Pointers
本地代码通过JNI函数可以获取JVM的一些特性。JNI函数可以通过一个接口指针获取。该接口指针是一个双重指针(指向指针的指针)。指针指向了一个指针数组,指针数组中的每一个成员都指向一个接口函数。每一个接口函数都在数组中预定义了一个偏移地址。下图可以表明接口指针的组织关系。
JNI接口是以类似于C++虚函数表或者COM接口的形式组织的。比起电路函数入口的方式,接口表的有点在于JNI的命名空间可以与本地代码分离开来。VM也可以较容易地提供多个版本的JNI函数表。例如,VM可以同时支持两种JNI函数表:
- 一种可以执行严格的非法参数检查并且适合调试
- 另一种执行JNI规范文档中最低层次的参数检查,从而提高效率
JNI接口指针在执行线程中保持不变。因此,本地方法不可以从一个线程传递接口指针到另一个线程。VM在实现JNI时应该分配并存储被JNI接口指针指向的线程数据。
本地方法以参数形式获取JNI接口指针。当从同一个java线程中多次调用本地方法时,VM可以确保传递相同的接口指针到本地方法。但是,一个本地方法可以被多个不同的java线程调用,因此也就能获得不同的JNI接口指针。
Loading and Linking Native Methods
本地方法由System.loadLibrary方法加载。以下代码示例中,在类初始化过程中加载了一个本地库并定义了库中实现的方法f。
package pkg;
class Cls {
native double f(int i, String s);
static {
System.loadLibrary(“pkg_Cls”);
}
}
System.loadLibrary方法的参数是由开发者定义的库名。VM会遵循平台约定将库名转换为本地库名。如Solaris系统会将pkg_Cls库名转换为libpkg_Cls.so本地库名,Win32系统会将pkg_Cls转换成pkg_Cls.dll本地库名。
只要所有的java类由同一个classLoader对象加载,开发者就可以使用一个单独的库来存储所有的需要的本地方法。VM内部会为每一个classLoader维护一个本地库列表。VM构建者应该规范本地库的命名以减少名字冲突的几率。
如果一个OS不支持动态链接,那么所有的本地方法都必须与VM重新链接。在这种情况下,VM会完成System.loadLibrary方法的调用但并不会真的加载本地库。
开发人员也可以调用JNI的RegisterNatives()函数来注册于一个类相关联的本地方法。RegisterNatives()函数在静态链接中相当有用。
Resolving Native Method Names
动态连接器根据本地方法名来决定入口地址。一个本地方法名由以下几个部分连结而成:
- 前缀 Java_
- 以下划线"_"分割开来的类名
- 带有下划线"_"的方法名
- 对于被加载的本地方法,两个下划线紧随参数签名
VM会检查一个方法名是否匹配本地库中的方法名。VM首先检查短名,也就是没有参数签名的名字。随后检查带有参数签名的长名。当一个本地方法被另一个本地方法调用时,开发人员人员需要使用长名。但是,本地方法如果与非本地方法(java方法)有一样的名字也是也可接受的,因为非本地方法并不存在于本地库中。
在接下来的示例中,本地方法g没有使用长名,因为另一个方法g是非本地方法不属于本地库。
class Cls1 {
int g(int i);
native int g(double d);
}
我们采用的简单命名方式可以确保所有的Unicode字符翻译为标准的C函数名。在完整的类名中,我们使用下划线"_"代替了斜线"/"。因为名字和类型描述符从不以数字开始,我们就是用_0,……,_9来表示转义字符。如表2-1所述:
表2-1:Unicode转义字符
转义字符 | 注释 |
_0XXXX | 一个Unicode字符XXXX。注意小写字母被用于表示非ASCII的Unicode字符,如:_0abcd与_0ABCD是相反的。 |
_1 | 字符"_" |
_2 | 字符";" |
_3 | 字符"[" |
所有的本地方法和接口API都遵循平台的标准库调用规范。如,UNIX系统使用C调用规范,然而Win32系统使用_stdcall。
Native Method Arguments
JNI接口指针是第一个进入本地方法的参数。JNI接口指针是JNIEnv类型的。第二个参数依据本地方法是静态还是非静态而不同。非静态本地方法的第二个参数一般是一个对象的引用。静态方法的第二个参数是所属java类的引用。
余下的参数符合java方法的参数规律。本地方法调用使用返回值来传递结果给调用程序。Chapter 3中将描述java与C的类型关系。
代码示例2-1表明了使用 C 函数来实现本地方法f。本地方法f以如下形式声明:
package pkg;
class Cls {
native double f(int i, String s);
...
}
C函数以长命名方式Java_pkg_Cls_f_Iljava_lang_String_2的形式来实现本地方法f:
代码示例2-1以C代码形式实现了本地方法
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* interface pointer */
jobject obj, /* "this" pointer */
jint i, /* argument #1 */
jstring s) /* argument #2 */
{
/* Obtain a Ccopy of the Java string */
const char *str = (*env)->GetStringUTFChars(env, s, 0);
/* process the string */
...
/* Now we are done with str */
(*env)->ReleaseStringUTFChars(env, s, str);
return ...
}
注意,我们总是使用接口指针env来操纵java对象。使用C++,可以实现结构更加清晰的一个版本,如代码示例2-2所示:
extern "C" /* specify the C calling convention */
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* interface pointer */
jobject obj, /* "this" pointer */
jint i, /* argument #1 */
jstring s) /* argument #2 */
{
const char *str = env->GetStringUTFChars(s, 0);
...
env->ReleaseStringUTFChars(s, str);
return ...
}
使用C++,可以做到从源码中消除其他等级的间接表达和接口指针参数。但是,其本质仍与C相同。在C++中,JNI函数以C为蓝本的内联函数的形式定义。
Referencing Java Objects
原始的数据类型如intergers,characters等在java和本地代码中都会被拷贝。在任意一方的所有java对象都是通过引用来传递的。VM必须能够追踪所有被传递到本地代码中的java对象,因此这些java对象并不是由垃圾收集器清理释放的。于是,本地代码必须有一种方法通知VM不在需要某些java对象。此外,垃圾收集器也必须能够清理有本地代码所引用的java对象。
Global and Local References
JNI将引用对象划分成两类:局部和全局引用。局部引用在本地方法调用期间是可信的,并且在本地方法返回时自动被清理。全局引用在本地代码中一直存在,直至有了明确的清理要求。
对象以局部引用的形式传递到本地方法中。所有本JNI函数返回的java对象都是局部引用形式。JNI允许开发是将局部引用转变为全局引用。JNI函数允许java对象是局部引用和全局引用。本地方法可以以局部引用或全局引用的形式对VM返回结果。
在大多数情况下,开发人员应该依赖VM来清理所有的局部引用。但是,有几个特殊情况需要开发人员明确地清理局部引用。如下面情形:
- 本地方法在获取一个很大的java对象后会创建该对象的局部引用。如果该本地方法在返回结果给调用者前要执行额外的计算任务。被创建的局部引用就会阻值对象被垃圾收集器清理,即使对象在计算过程中从未被使用。
- 当本地方法创建了大量的局部引用后。只要VM需要使用内存空间来追踪局部引用,创建太多的局部引用会引起返回值内存溢出。例如,本地方法循环遍历较大的对象数组,以引用方式检索数组成员并且以每个迭代器来操纵每个成员。之后,开发人员就不再需要局部引用来获取数组成员。
JNI允许开发人员在本地方法的任意位置主动地清理局部引用。为了确保开发人员可以清理局部引用,JNI函数不会再创建额外的局部引用,处理作为返回值的引用。
局部引用只会存在本地方法所在的线程中。本地代码不可以将一个引用从一个线程传递到另一个线程。
Implementing Local References
为了实现局部引用,JVM为每一个从java到native方法的过渡控制建立以注册表。每一个注册表都映射了局部映射到java对象并且一直保持到该对象被垃圾收集器回收。所有传递到native方法的java对象(包括那些作为JNI函数调用返回值的java对象)都自动地添加到注册表中。当native方法返回值后,注册表就会被删除并且所有的入口点都会被垃圾收集器回收。
有多种方式可以实现注册表,如使用table,linked list或者hash table。尽管为避免重复的入口点会在注册表中使用引用计数,但JNI的实现中并不总是检查并清理重复的入口点。
注意:局部引用不会忠实地通过扫描本地栈结构来实现。native代码也可能会将局部引用存储到全局或堆结构中。
Accessing Java Objects
JNI在全局和局部引用中提供了一组丰富的读值函数(accessor functions)。这意味着同样的native方法实现在任何VM(不管VM内部是如何表达java对象的)中都是奏效的。这也是JNI可以被大量的VM支持的重要原因。
在非透明引用中使用读值函数的额外开销比直接使用C数据结构要高。我们相信,在大多数情况下,java开发者使用native方法来执行重要的任务比接口的额外开销要更实用。
Accessing Primitive Arrays
对于包含大量基本数据类型(如integer arrays和strings)的java对象这样的开销就不太适宜。考虑到native方法多用于执行vector和大量的计算。使用函数调用来获取java array,遍历array中的元素就会变得非常低效率。
有个方案提供了一种“阻塞”的概念让native方法可以要求VM来约束array中的内容。然后native方法就会获得一个指向array元素的指针。但是这种方法存在两个隐含的缺陷:
- 垃圾收集器必须支持阻塞机制
- VM必须将基本类型array连续地分配在内存中。尽管对于大多数基本类型array都是连续分配的,但boolean类型array是以封包或拆包的形式实现。因此,native代码对boolean类型array的处理就会复杂。
我们采取了折中的方式来克服以上问题。
首先,我们提供了一组函数来拷贝java array段和native方法buffer中的每一个基本array元素。如果一个native方法需要在一个较大的array中访问一小部分的元素就可以使用这些函数。
其次,开发人员可以使用另一组函数来检索阻塞的array元素。记住这些函数可能会要求JVM执行内容分配和内容拷贝操作。不管这些函数拷贝数组的实现是否依赖于VM的实现,都会有以下两点:
- 如果垃圾收集器支持阻塞,那么array的分配就如native方法期待的那样,也就不再需要内存拷贝。
- 另一方面,array被拷贝到不可移的内存块中(如C的堆)并且需要执行格式转换。指向该拷贝的指针会被返回。
最后,接口提供的函数会通知VM native代码不再需要访问array元素。当调用这些函数时,系统并不会解除对array的阻塞,也不会协调原array与不可变拷贝和释放拷贝。
我们的方案提供了一种变通的方法。垃圾收集算法可以对每个给定的array做拷贝操作还是阻塞操作提供独立的决策。例如,垃圾收集器可以拷贝较小的对象,但会阻塞较大的对象。
JNI的实现必须要确保运行在多线程下的native方法能够同时获取相同的array。例如,JNI应该为每一个被阻塞的array都维持一个内部计数器,这样就可以做到一个线程能够解除对一个array的阻塞,但同时该array被另一个thread阻塞。注意:JNI不需要锁定只被一个native方法访问的基本类型array。同时从不同的线程更新java array对象会导致不确定的结果。
Accessing Fields and Methods
JNI允许native代码访问field字段和调用java对象的方法。JNI通过名称和类型来识别方法和字段。一个两步式的进程会通过名称和类型来定位字段和方法,然后计算出开销。例如,为了调用cls类中的方法f,native代码首先以如下方式获取方法ID:
jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”);
然后native代码会以如下形式反复地使用方法ID,不会再去查找方法:
jdouble result = env->CallDoubleMethod(obj, mid, 10, str);
字段和方法ID不会阻止VM卸载ID已经被找到的类。当类被卸载掉后,方法或字段ID就会失效。因此,native代码必须确保:
- 对装载过的类维持活动状态的引用,或
- 重新计算方法或字段ID
如果在一段持续的时间里使用方法或字段ID。
JNI对字段和方法ID在内部是怎样实现的没有设置任何限制。
Reporting Programming Errors
JNI并不会检查编程错误如传递空指针或非法的参数类型。非法参数类型包括使用正常的java对象而不是java类对象。JNI不检查这些编程错误的原因在于:
- 强制JNI函数检查所有的可能错误会降低正常native方法的效率。
- 在许多情况下,并没有足够的运行时类型信息来执行这样的检查。
大多数的C函数库并未防止编程错误。例如,函数printf()在接收到一个非法地址时,通常会引起运行时(runtime)错误,而不会返回一个错误信息。强制C库函数对所有可能的错误情况都进行检查可能会导致:在用户代码中检查一遍后,还要在库代码中再检查一遍。
开发人员一定不可以给JNI函数传递非法指针或错误的参数类型。因为这样会导致不可预测的后果,包括中断系统状态或让VM崩溃。
Java Exceptions
JNI允许native代码引起任意的java异常。native代码可能也会处理显著的java异常。剩下未处理的异常会被传播会VM。
Exceptions and Error Codes
要明确JNI函数使用java异常来报告错误情况的机理。在大多数情况下,JNI函数会通过返回错误代码和抛出java异常来报告错误情况。错误代码通常是一些特殊的返回值(如NULL),这些返回值一般不在正常返回值之列。因此,开发人员应该做到:
- 快速检查最后一个JNI函数调用后的返回值并判断是否有错误发生,并且
- 调用函数ExceptionOccurred()来获取异常对象,一般异常对象会包含更多的错误描述信息。
有两个地方需要开发人员检查异常,这些异常并不会在第一次检查时返回错误代码:
- 调用java方法的JNI函数返回java方法的结果。开发人员必须调用ExceptionOccured()函数来检查java方法在执行期间可能发生的异常。
- 一些获取array的JNI函数并不会返回错误代码,但是可能会抛出ArrayIndexOutOfBoundsException或ArrayStoreException异常。
其它所有情况,一个non-error返回值可以保证没有异常被抛出。
Asynchronous Exceptions
在多线程情况下,除了正在运行的线程,其余的线程可能会抛出一个异步异常。一个异步异常并不会在运行线程中立即影响native代码的执行,直到:
- native代码调用可能引起同步异常的JNI函数,或
- native代码使用ExceptionOccurred()函数明确地检查同步和异步异常。
注意:只有那些可能会潜在引起同步异常的JNI函数会检查异步异常。
native方法应该在必要的地方(如在一个没有其它异常检查的循环中)插入ExceptionOccurred()函数来确保当前线程在合理的时间内响应异步异常。
Exception Handling
有两种方式在native代码中处理异常:
- native方法可以选择立即返回,导致异常被抛出在java代码中,这些java代码一般启动native方法调用。
- native代码通过调用ExceptionClear()函数来清除异常,然后执行它自己的异常处理代码。
在异常发生后,native代码必须首先在引起其它JNI调用前清除异常。当存在一个挂起的异常,以下JNI函数是可以安全调用的:
ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
Release<Type>ArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()