JNI之二:设计概览

本文重点是JNI的主要设计问题。这里大部分的设计问题都跟native方法有关。调用API的设计将在JNI之五:API调用中具体叙述。

JNI函数和指针

native代码通过调用JNI函数访问JVM功能。JNI函数可以通过接口指针使用,接口指针是指向指针的指针。它指向一个指针数组,其中每个指针都指向一个接口函数。每个接口函数在这个数组内部都有预定的偏移量。

下图展示了一个接口指针的结构:

接口指针

JNI接口的组织方式和C++虚函数表或COM接口相似,使用接口表比hard-wired函数实体的优势在于,JNI的命名空间从native代码中分离。虚拟机可以轻松地提供多个版本的JNI函数表。例如,虚拟机可以支持两个JNI函数表:

  • 一个进行严密的参数检查,适合debuging。
  • 另一个做JNI规范所要求的最少检查,因此更高效。

JNI接口指针仅在当前线程有效。因此native方法不应该将接口指针从一个线程传递到另一个线程。虚拟机实现的JNI可以在JNI接口指针指向的区域分配并存储thread-local数据。

native方法接收JNI接口指针作为参数。虚拟机可以保证当从Java线程向native方法发起多个调用时,向native方法传入相同的接口指针。然而native方法可以被不同的Java线程调用,因此可能受到不同的JNI接口指针。

编译、加载及链接native方法

由于JVM是多线程的,native库也应该被多线程感知的native编译器编译。

线程感知:在任意时刻,最多只有一个线程在对象上被激活,这个对象能感知到周围的线程并通过将所有线程放进一个队列里来保护自己。在任何时刻,当只有一个线程可以在这个对象上激活时,此对象将始终保持其状态。这样,就不会有任何的同步问题。

线程安全:在任意时刻,多个线程可以活跃在同一个对象上。对象知道如何处理它们,它被资源共享给正确同步访问它的线程,并且在多个线程环境中保持状态数据(它不会进入一个不确定的中间状态)。在多线程环境下使用这个对象是安全的。

使用一个既不是线程感知又不是线程安全对象的情况下,可能得到错误的结果或者神秘的异常。

例如,-mtflag应该用于通过Sun Studio编译的C++代码。对于通过GNU gcc编译器编译的代码,应该使用-D_REENTRANT或者-D_POSIX_C_SOURCE flag。获取更多信息请查阅native编译器文档。

Native方法通过System.loadLibrary方法加载。下面的例子中,类初始化方法加载了一个内部定义了f方法的平台相关的native库:

package pkg;

class Cls {
    native double f(int i, String s);
    static {
        System.loadLibrary("pkg_Cls");
    }
}

传入System.loadLibrary里的参数是程序员为这个库定义的名字。系统遵循一个标准的、但平台相关的方法,将库名称转变为native库名称。例如,Solaris系统将pkg_Cls转换为libpkg_Cls.so,而Win32系统将pkg_Cls转换为pkg_Cls.dll

开发者可以将任意数量的类所需要的所有native方法放进一个单独的库里,只要这些类被同一个ClassLoader所加载。VM内部维护了每个ClassLoader所加载的native库的集合。厂商应该为native库选择命名冲突最小的库名称。

动态和静态链接库以及他们各自的生命周期管理“load”和“unload”函数将在JNI之五:API调用的<库及版本管理>节中详细讲解。

解析native方法名称

动态链接器根据其名字解析条目。native方法名由以下组件连接而成:

  • Java_前缀
  • 一个类的全限定名
  • 一个下划线(“_”)分隔符
  • 一个方法的全限定名
  • 对于重载的native方法,参数权限定签名后跟两个下划线(“__”)

JVM检测native库中匹配的方法名。首先查询短名称(short name),即没有参数签名的名称。然后才是长名称(long name)的查询,即带着参数签名的名称。只有当一个native方法被另一个native方法重载时,才需要使用长名称。native方法和非native方法重名是没有问题的。非native方法没有包含在native库里。

下面的例子中,native方法g不需要使用长名称进行链接,因为另一个g方法不是native方法,因此它不在native库中。

class Cls1 {
    int g(int i);
    native int g(double d);
}

Sun采用了一个简单的名称识别方案来确保所有的Unicode字符都能翻译成有效的C函数名称。在类的权限定名中,使用下划线(“_”)替代斜杠(“/”)。由于名称和类型描述符从不以数字开头,因此我们可使用_0,...,_9作为为转义序列,如下表所示:

转义序列 描述
_0xxxx 一个Unicode字符xxxx,表示除了ASCII字母数字([A-Za-z0-9])以外的字符。注意!使用小写。如使用_0abcd 而不是_0ABCD
_1 字符”_”
_2 字符”;”,出现于签名中
_3 字符”[“,出现于签名中

native方法和接口APIs在给定的平台上都遵守库调用(library-calling)约定。例如,UNIX系统使用C调用约定,Win32系统使用__stdcall。

Native方法参数

JNI接口指针是native方法的第一个参数,类型为JNIEnv。第二个参数取决于native方法是静态还是非静态。非静态native方法,第二个参数是对象的引用。静态native方法,则第二个参数是class的引用。

其余的参数与常规的Java方法参数相对应,native方法将调用结果通过返回值返回。JNI类型和数据结构中将详细描述Java和C的类型映射关系。

下面的代码展示了如何使用C函数实现native方法f,native方法f声明如下:

package pkg;

class Cls {
    native double f(int i, String s);
    // ...
}

C语言函数通过长名称Java_pkg_Cls_f_ILjava_lang_String_2实现native方法f

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 C-copy 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++,可以写出略微干净版本的代码,如下面的代码样本:

extern "C" /* 指定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 */

{
     const char *str = env->GetStringUTFChars(s, 0);

     // ...

     env->ReleaseStringUTFChars(s, str);

     // return ...
}

使用C++,额外的间接级别和接口指针参数从源码中消失了,但是底层的机制跟C是完全相同的。在C++中,JNI函数被定义为扩展到C对应成员的内联成员函数。

引用Java对象

原始数据类型,如整数、字符等等,在Java和native代码之间复制。另一方面,通过引用传递任意的Java对象。虚拟机必须持续跟踪所有传入native代码的对象,这样,垃圾收集器就不会释放这些对象。反过来,native代码必须有方式告知虚拟机不在需要这些对象。此外,垃圾收集器必须可以移动被native代码引用的对象。

全局引用和局部引用

JNI将native代码使用的对象引用分为两类:localglobal引用,局部变量在native方法调用期间有效,且在native方法return之后自动释放。全局引用将一直保持,直到被明确释放。

对象作为局部引用被传入native方法,JNI函数返回的所有方法都是局部引用。JNI允许程序员从局部引用中创建全局引用。需要Java对象的JNI函数局部引用和全局引用都接受。native方法可能将局部引用或全局引用作为它的结果,返回给虚拟机。

大多是情况下,当native方法返回后,程序员应该依赖虚拟机释放所有的局部引用。然而,有些情况下程序员应该明确的释放局部引用。考虑如下场景:

  • 一个native方法访问巨型Java对象,从而创建了这个Java对象的局部引用,这个native方法返回前,它在处理其他的计算。此时巨型Java对象的局部引用将阻止垃圾收集器回收此对象。即使这个对象在剩余的计算中未使用。
  • native方法创建了大量的局部引用,虽然并不同时使用它们。这样一来,虚拟机需要一定数量的空间来保持这些局部引用,创建过多的局部引用可能会导致内存溢出。例如,一个native方法遍历一个巨型对象数组,取出元素作为局部引用,并在每次迭代时操作元素对象。每次迭代后,程序员将不再需要数组元素的局部引用。

JNI允许程序员在native方法中的任意时间点手动删除局部引用,确保程序员能手动释放局部引用,JNI函数不允许创建额外的局部引用,除非它们返回的结果是引用。

局部引用仅在创建它们的线程中有效,native代码不能将局部引用从一个线程传递到另一个线程。

局部引用的实现

实现本地引用,JVM为每个从Java到native方法的控制权转移创建了注册表。注册表将不可移动的局部引用映射到Java对象,防止对象被垃圾收集器回收。所有传入native方法的Java对象(包括那些被作为JNI函数调用结果返回的)都被自动添加到注册表。注册表在native方法返回后删除,其上的所有实体将能够被垃圾收集器回收。

有多种不同的途径实现注册表,如使用表、链表、哈希表。虽然引用计数法可被用来避免重复实体出现在注册表中,但JNI的实现没有义务检测和折叠重复实体。

注意,局部引用不能通过保守的扫描本地栈来忠实地实现。native代码可能将局部引用存到全局或堆数据结构。

访问Java对象

JNI在全局引用和局部引用上提供了丰富的访问函数。这意味着无论VM内部如何实现Java对象,相同的native方法都管用。这是JNI能被各种各样VM实现所支持的重要原因。

通过不透明引用使用访问函数的开销比直接访问C数据结构的开销要高。我们相信,大多数情况下,Java程序员使用native方法处理不平凡的任务,使得该接口的开销变得微不足道。

访问原始数组

对于包含许多私有数据类型的大型Java对象的开销是不能接受的,比如整型数组和字符串(想想native方法被用来处理矢量和矩阵计算)。迭代Java数组并取它的每个元素是非常低效的。

Sun引入了一种叫做“固定(pining)”的解决方案,native方法能要求虚拟机将数组的内容固定下来。接着native方法将收到元素的指针。这种方式有两个意义:

  • 垃圾收集器必须支持“pining”
  • 虚拟机必须在内存中连续布局数组,对大多数自由数组的来说是很自然的实现,boolean可被实现为包装的或非包装的。因此依赖与布尔数组的确切布局的native代码将不可移植。

Sun在上述两个问题件做了妥协。

首先提供了一组函数,用于Java数组的一段和native内存缓冲之间复制数组元素,如果一个native方法只需访问大型数组中的少量元素,请使用这些函数。

其次,程序员能使用其他集合函数集获取固定版本的数组元素。这些函数可能需要JVM进行存储分配和拷贝。这些函数实际上是否拷贝,取决于虚拟机的实现,具体如下:

  • 如果经垃圾收集器支持固定(pining),同时数组布局也像native方法期望的那样,则不需要复制。
  • 除此以外,数组被拷贝到一个不可移动的内存块(如,在C堆中)并执行必要的格式转换。返回指向这个副本的指针。

最后,接口提供通知虚拟机native代码不再需要访问数组元素的函数。当你调用这些函数,系统要么“unpins”(取消固定)数组,要么协调原始数组与不可移动的副本(协调一致),并移除副本。

这种做法提供了灵活性,垃圾收集算法能对给定的数组做出复制或固定的单独决策。例如,垃圾收集器可能复制小的对象但固定大对象。

JNI实现必须确保native方法在多线程下能并发访问同一个数组。例如,JNI可为每个固定的数组保持一个内部计数器,以便一个线程不会取消同样被另一个线程固定的数组。注意,JNI不需要锁定原始数组即可通过native方法进行独占访问。从不同的线程中并发的更新一个Java数组,将导致不同的结果。

访问域和方法

JNI允许native代码访问Java对象的字段并且能够调用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无法阻止虚拟机卸载从这个ID派生的类。类卸载之后,方法或字段id将无效。因此native代码必须确保:

  • 保持一个底层类活着的引用,或者
  • 重新计算字段或方法的id

如果打算长时间使用方法或字段id,JNI不会对方法或字段id的内部实现增加任何限制。

报告程序错误

JNI不会检查诸如空指针或非法的参数类型等程序错误,非法参数类型就比如Java对象而不是Java class对象。JNI不检查程这些序错误,原因如下,

  • 强制JNI函数检查所有的程序错误条件,将会降低普通native代码的性能(包括正确的函数)。
  • 大多数情况下,运行时环境的类型信息不足以进行所有的错误检测。

大多数C程序库都不能防止程序错误。例如printf()函数,当它收到一个无效的地址是,通常导致运行时错误,而不是返回错误码。强制让C库对所有可能出错的条件进行检查,可能会导致检查重复——用户代码里检测一次,在库中又检测一次。

程序员不能将非法的指针或错误的类型传入JNI函数,这将导致意想不到的后果,破坏系统状态或引起虚拟机奔溃。

Java异常

JNI允许native方法生成任意的Java异常,native代码同样也能捕获外部的Java异常,剩下未捕获的Java异常被传回虚拟机。

异常和错误码

某些JNI函数使用Java异常机制报告错误情况。大多数情况下,JNI函数通过返回错误码以及抛出Java异常的方式报告错误情况。错误码通常是一个在常规返回值范围外的特别返回值(如 NULL)。这样,程序员能够:

  • 快速检查最后一次JNI调用的返回值,以确定是否发生了错误;
  • 同时调用一个函数ExceptionOccurred(),来获取包含了错误详细信息的exception对象。

有两种情况程序员需要检查异常,而不是先检查error code:

  • 调用一个Java方法的JNI函数返回Java方法的结果。程序员必须调用ExceptionOccurred() 方法来检查Java方法执行期间可能产生的exceptions。
  • 一些JNI数组访问函数没返回错误码,但是可能抛出ArrayIndexOutOfBoundsException异常或ArrayStoreException异常。

其它情况下,非错误的返回值保证了不会发生任何异常。

异步异常

在另一个线程中调用Thread.stop()方法,可能产生异步异常,这个方法从JDK 1.2开始被废弃掉了。程序员们强烈反对使用Thread.stop(),因为它将导致程序出现不确定的状态。

此外,JVM可能在当前线程中产生异常,不是JNI API调用的结果,而是JVM内部各种各样的错误,例如VirtualMachineErrorStackOverflowErrorOutOfMemoryError。这些也称为异步异常。

异步异常不直接影响当前线程中native代码的执行结果,直到:

  • native代码调用一个可能出现异步异常的JNI函数;
  • native代码使用ExceptionOccurred()方法明确地检测同步异常和异步异常。

注意,只有那些可能产生同步异常的JNI函数才会检查异步异常。

native方法应该在需要检查的地方插入ExceptionOccurred()方法。例如,在长时间运行的没有异常检测的代码里(可能包含紧密循环)。这样确保当前线程在合理的时间范围内响应异步异常。然而,由于它们的异步性质,在调用之前进行异常检测是没法保证在检测和调用之间产生异步异常的。

异常处理

有两种途径捕获native代码中的异常:

  • native方法可以选择直接返回,导致在native方法中调用的Java代码抛出异常。
  • native代码能通过调用ExceptionClear()清除异常,然后执行自己的异常捕获代码。

异常发生后,native代码在其它JNI调用前必须先清除异常。当存在延迟异常时,JNI函数调用以下函数是安全的:

ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
Release<Type>ArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()
DetachCurrentThread()