【JNI编程】JNI中的主要设计

242 阅读15分钟

这里主要讨论JNI中的主要设计问题。本节中的大多数设计问题都与本地方法有关。

一、JNI接口函数和指针

本地代码通过调用JNI函数访问JVM特性。JNI函数通过接口指针可用。接口指针是指向指针的指针。这个指针指向一个指针数组,每个指针指向一个接口函数。每个接口函数在数组中都有一个预定义的偏移量。图2-1演示了接口指针的组织。
Interface Pointer
JNI接口的组织方式类似于C++虚拟函数表或COM接口。使用接口表而不是硬连接的函数项的优点是,JNI名称空间与本地代码分离。VM可以轻松地提供JNI函数表的多个版本。例如,VM可以支持两个JNI函数表:

  • 执行彻底的非法参数检查,适合调试;
  • 另一个执行JNI规范所需的最小检查量,因此效率更高。

JNI接口指针仅在当前线程中有效。因此,本地方法不能将接口指针从一个线程传递到另一个线程。实现JNI的VM可以在JNI接口指针指向的区域中分配和存储线程本地数据。

本地方法接收JNI接口指针作为参数。当VM从相同的Java线程多次调用本地方法时,它保证将相同的接口指针传递给本地方法。但是,可以从不同的Java线程调用本地方法,因此可以接收不同的JNI接口指针。

二、编译、加载和链接本地方法

由于JVM是多线程的,还应该编译本地库,并使用支持多线程的本机编译器进行链接。例如,对于使用Sun Studio编译器编译的C++代码,应该使用-mt标志。对于遵循GNU gcc编译器的代码,应该使用-D_REENTRANT或-D_POSIX_C_SOURCE标志。有关更多信息,请参阅本地编译器文档。

所有的本地方法被System.loadLibrary方法加载。在下面的示例中,类初始化方法加载一个特定于平台的本地库,其中定义了本地方法f:

package pkg;  

class Cls { 

     native double f(int i, String s); 

     static { 

         System.loadLibrary(“pkg_Cls”); 

     } 

} 

System.loadLibrary的参数是程序员任意选择的库名。系统遵循一种标准的、但特定于平台的方法来将库名转换为本地库名。例如,Solaris系统将名称pkg_Cls转换为libpkg_Cls.so。当系统为Win32时将相同的pkg_Cls名称转换为pkg_Cls.dll。

程序员可以使用一个库来存储任意数量的类所需的所有本地方法,只要这些类使用相同的类加载器加载即可。VM内部为每个类加载器维护一个已加载的本地库列表。VM供应商应该选择尽可能减少名称冲突的本地库名称。

如果底层操作系统不支持动态链接,则必须将所有本机方法预先链接到VM。在这种情况下,VM 在不实际加载库的情况下完成System.loadLibrary调用。

程序员还可以调用JNI函数RegisterNatives()来注册与类关联的本地方法。RegisterNatives()函数对于静态链接的函数特别有用。

三、解析本地方法名

动态链接器根据条目的名称解析条目。本地方法名称由以下组件连接而成:

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

VM检查方法名与驻留在本地库中的方法是否匹配。VM首先查找短名称;即没有参数签名的名称。然后,它查找具有参数签名的长名称。只有在本地方法被另一个本地方法重载时,程序员才需要使用长名称。但是,如果本地方法与非本地方法具有相同的名称,则这不是问题。非本机方法(Java方法)不驻留在本地库中。

在下面的示例中,不必使用长名称链接本地方法g,因为另一个方法g不是本地方法,因此不在本地库中。

class Cls1 { 

  int g(int i); 

  native int g(double d); 

} 

我们采用了一种简单的名称混淆模式,以确保所有Unicode字符都转换为有效的C函数名称。在全限定类名中,我们使用下划线(" _ “)字符代替斜杠(” / ")。由于名称或类型描述符从不以数字开头,所以我们可以使用_0,…, _9表示转义序列,如下表所示:

转义序列表示
_0XXXX一个Unicode字符XXXX。注意使用小写字母代表非ASCII Unicode字符,例如,_0abcd对照_0ABCD。
_1字符 “_”
_2签名中的字符“;”
_3签名中的字符“[”

本地方法和接口API都遵循给定平台上的标准库调用约定。例如,UNIX系统使用C调用约定,而Win32系统使用 __stdcall。

四、本地方法参数

JNI接口指针是本地方法的第一个参数。JNI接口指针的类型是JNIEnv。第二个参数取决于本地方法是静态的还是非静态的,非静态本地方法的第二个参数是对对象的引用。静态本地方法的第二个参数是对其Java class的引用。其余参数对应于常规Java方法参数。本地方法调用通过返回值将结果传递回调用例程。后面的章节描述了Java和C类型之间的映射。

代码示例演示了如何使用C函数实现本地方法f。本地方法f的声明如下:

package pkg;  

class Cls { 

     native double f(int i, String s); 

     ... 

} 

C函数的长名称是Java_pkg_Cls_f_ILjava_lang_String_2,它实现了本地方法f:

使用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 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++,您可以编写一个稍微干净一些的代码版本,如下代码示例所示:

使用C++实现本地方法

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对应的函数。

五、引用Java对象

在Java和本地代码之间复制整数、字符等基本类型。另一方面,任意Java对象都是通过引用传递的。VM必须跟踪所有传递给本地代码的对象,这样垃圾收集器就不会释放这些对象。反过来,本地代码必须有一种方式通知VM它不再需要对象。此外,垃圾收集器必须能够移动本地代码引用的对象。

5.1 全局和局部引用

JNI将本地代码使用的对象引用分为两类:局部引用和全局引用。局部引用在本地方法调用期间有效,并在本机方法返回后自动释放。全局引用在显式释放之前保持有效。

对象作为局部引用传递给本地方法。JNI函数返回的所有Java对象都是局部引用。JNI允许程序员从局部引用创建全局引用。JNI函数期望Java对象接受全局和局部引用。本地方法可以返回VM的局部或全局引用作为其结果。

在大多数情况下,程序员应该依赖VM在本地方法返回后释放所有局部引用。然而,有时程序员应该显式地释放局部引用。例如,考虑以下情况:

  • 本地方法访问大型Java对象,从而创建对该Java对象的局部引用。然后,本地方法在返回调用方之前执行额外的计算。对大型Java对象的局部引用将防止对象被垃圾回收,即使在剩余的计算中不再使用该对象。
  • 本地方法创建大量的局部引用,尽管并非所有引用都同时使用。由于VM需要一定的空间来跟踪局部引用,因此创建过多的局部引用可能会导致系统耗尽内存。例如,本地方法循环遍历大量对象,检索作为局部引用的元素,并在每次迭代中操作一个元素。在每次迭代之后,程序员不再需要对数组元素的局部引用。

JNI允许程序员在本地方法中的任何位置手动删除局部引用。为了确保程序员可以手动释放局部引用,JNI函数不允许创建额外的局部引用,除非它们作为结果返回引用。

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

5.2 实现局部引用

为了实现局部引用,JVM为从Java到本地方法的每次控制转换创建一个注册表。注册表将不可移动的局部引用映射到Java对象,并防止对象被垃圾回收。传递给本地方法的所有Java对象(包括作为JNI函数调用结果返回的对象)都会自动添加到注册表中。在本地方法返回后删除注册表,允许对其所有条目进行垃圾回收。

实现注册表有不同的方法,例如使用表、链表或散列表。虽然可以使用引用计数来避免注册中心中的重复项,但是JNI实现没有义务检测和折叠重复项。

注意,保守地扫描本地堆栈不能如实地实现局部引用。本地代码可以将局部引用存储到全局或堆数据结构中。

六、访问Java对象

JNI为全局和局部引用提供了一组丰富的访问器函数。这意味着无论VM内部如何表示Java对象,相同的本地方法实现都可以工作。这就是为什么各种VM实现都支持JNI的一个关键原因。

通过不透明引用使用访问器函数的开销比直接访问C数据结构的开销要高。我们相信,在大多数情况下,Java程序员使用本地方法来执行一些重要的任务,这些任务会掩盖此接口的开销。

6.1 访问原始数组

对于包含许多基本数据类型(如整数数组和字符串)的大型Java对象,这种开销是不可接受的(考虑用于执行向量和矩阵计算的本地方法)。遍历Java数组并使用函数调用检索每个元素的效率非常低。

一种解决方案引入了“固定”的概念,这样本地方法就可以要求VM固定数组的内容。然后,本地方法接收指向元素的直接指针。然而,这种方法有两种含义:

  • 垃圾收集器必须支持固定。
  • VM必须在内存中连续地布局基元数组。尽管这是大多数基本数组最自然的实现,但布尔数组可以实现为打包或解打包。因此,依赖于布尔数组的精确布局的本地代码是不可移植的。

我们采取了一种折衷办法,克服了上述两个问题。

首先,我们提供了一组函数,用于在Java数组的段和本地内存缓冲区之间复制原始数组元素。如果本地方法只需要访问大数组中的少量元素,则使用这些函数。

其次,程序员可以使用另一组函数来检索数组元素的压缩版本。请记住,这些函数可能需要JVM执行存储分配和复制。这些函数是否实际复制数组取决于VM实现,如下所示:

  • 如果垃圾收集器支持钉住,并且数组的布局与本地方法所期望的相同,则不需要复制。
  • 否则,将数组复制到一个不可移动的内存块(例如,在C堆中),并执行必要的格式转换。返回一个指向副本的指针。

最后,该接口提供函数来通知VM本地代码不再需要访问数组元素。当您调用这些函数时,系统要么解压该数组,要么将原始数组与其不可移动的副本进行协调,并释放该副本。

我们的方法提供了灵活性。对于每个给定的数组,垃圾收集器算法可以单独决定是复制还是固定。例如,垃圾收集器可以复制小对象,但锁定大对象。

JNI实现必须确保在多个线程中运行的本地方法能够同时访问相同的数组。例如,JNI可以为每个固定的数组保留一个内部计数器,这样一个线程就不会解钉另一个线程也固定的数组。注意,JNI不需要锁定原生方法独占访问的原语数组。同时从不同的线程更新Java数组会导致不确定的结果。

6.2 访问字段和方法

JNI允许本地代码访问字段并调用Java对象的方法。JNI通过它们的符号名和类型签名标识方法和字段。两个步骤从字段或方法的名称和签名中提取出定位该字段或方法。例如,要调用cls类中的方法f,本地代码首先获得一个方法ID,如下所示:

jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”); 

然后,本地代码可以重复使用方法ID,如下所示:

jdouble result = env->CallDoubleMethod(obj, mid, 10, str); 

字段或方法ID不阻止VM卸载派生该ID的类。卸载类后,方法或字段ID无效。因此,本地代码必须确保:

  • 保持对底层类的实时引用,或
  • 重新计算方法或字段ID

如果它打算在较长时间内使用方法或字段ID。

JNI没有对内部如何实现字段和方法ID施加任何限制。

七、编程错误报告

JNI不检查编程错误,例如传入NULL指针或非法参数类型。非法参数类型包括使用普通Java对象而不是Java class对象。由于以下原因,JNI不检查这些编程错误:

  • 强制JNI函数检查所有可能的错误条件会降低正常(正确)本地方法的性能。
  • 在许多情况下,没有足够的运行时类型信息来执行此类检查。

大多数C库函数都不能防止编程错误。例如,printf()函数在收到无效地址时通常会导致运行时错误,而不是返回错误代码。强制C库函数检查所有可能的错误条件可能会导致重复此类检查–一次在用户代码中,然后再次在库中。

程序员不得将非法指针或错误类型的参数传递给JNI函数。这样做可能会导致任意后果,包括系统状态损坏或VM崩溃。

八、Java异常

JNI允许本地方法引发任意Java异常。本地代码还可以处理未处理的Java异常。未处理的Java异常将传播回VM。

8.1 异常和错误代码

某些JNI函数使用Java异常机制来报告错误情况。在大多数情况下,JNI函数通过返回错误代码并抛出Java异常来报告错误情况。错误代码通常是一个特殊的返回值(如NULL),它超出了正常返回值的范围。因此,程序员可以:

  • 快速检查上一个JNI调用的返回值,以确定是否发生了错误,和
  • 调用一个函数ExceptionOccurred()来获取包含错误条件的更详细描述的异常对象。

在两种情况下,程序员需要检查异常而无法首先检查错误代码:

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

在所有其他情况下,非错误返回值可确保不会抛出任何异常。

8.2 异步异常

在多线程的情况下,当前线程以外的线程可能会发布异步异常。异步异常不会立即影响当前线程中本地代码的执行,直到:

  • 本地代码调用可能引发同步异常的JNI函数之一,或
  • 本地代码使用ExceptionOccurred()显式检查同步和异步异常。

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

本地方法应在必要的位置插入ExceptionOccurred()检查(例如在没有其他异常检查的紧密循环中),以确保当前线程在合理的时间内响应异步异常。

8.3 异常处理

有两种方法可以处理本地代码中的异常:

  • 本地方法可以选择立即返回,从而导致在启动本地方法调用的Java代码中抛出异常。
  • 本地代码可以通过调用ExceptionClear()来清除异常,然后执行自己的异常处理代码。

引发异常后,本地代码必须首先清除异常,然后再进行其他JNI调用。当存在挂起的异常时,可以安全调用的JNI函数是:

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