本章我们重点说明以下JNI设计的问题,本章中提到的大多数设计问题都与native方法有关。至于调用相关的API的设计,我们会在后面进行介绍。
一、JNI接口函数和指针
native 代码通过调用JNI函数来访问Java VM功能。JNI函数可通过接口指针获得。接口指针是指向指针的指针。该指针指向一个指针数组,每个指针指向一个接口函数。每个接口函数都在数组内的预定义偏移处。下图说明了接口指针的组织。
接口指针
JNI接口的组织方式类似于C ++虚函数表或COM接口。使用接口表而不是硬连接函数条目的优点是JNI名称空间与native代码分离。VM可以轻松提供多个版本的JNI功能表。例如,VM可能支持两个JNI函数表:
- 一个用于平台执行彻底的非法参数检查,适合调试;
- 另一个执行JNI规范所需的最小量检查,因此更有效。
JNI接口指针仅在当前线程中有效。因此,native方法不能将接口指针从一个线程传递到另一个线程。实现JNI的VM可以在JNI接口指针指向的区域中分配和存储线程本地数据。
Native方法接收JNI接口指针作为参数。当VM从同一Java线程多次调用native方法时,保证将VM传递给native方法。但是,可以从不同的Java线程调用native方法,因此可以接收不同的JNI接口指针。
二、编译,加载和链接native方法
由于Java VM是多线程的,因此native库也应该与多线程感知的native编译器一起编译和链接。例如,该-mt
标志应该用于使用Sun Studio编译器编译的C ++代码。对于符合GNU gcc编译器的代码,应使用标志-D_REENTRANT
或-D_POSIX_C_SOURCE
。有关更多信息,请参阅native编译器文档。
使用System.loadLibrary
方法加载native方法。在以下示例中,类初始化方法加载特定于平台的native库,其中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方法,只要这些类要使用相同的类加载器加载即可。VM在内部维护每个类加载器的加载native库列表。供应商应选择本地库名称,以尽量减少名称冲突的可能性。
如果底层操作系统不支持动态链接,则必须将所有native方法与VM预先链接。在这种情况下,VM完成System.loadLibrary
调用而不实际加载库。
程序员还可以调用JNI函数RegisterNatives()
来注册与类关联的native方法。该RegisterNatives()
功能对于静态链接功能特别有用。
三、解析native方法名称
动态链接器根据其名称解析条目。native方法名称由以下组件连接:
-
前缀
Java_
- 一个错位的完全限定的类名
- 下划线(“_”)分隔符
- 方法名称
- 对于重载的native方法,两个下划线(“__”)后跟参数签名
VM检查驻留在native库中的方法的方法名称匹配。VM首先查找短名称; 也就是说,没有参数签名的名称。然后它查找长名称,这是带有参数签名的名称。只有当native方法使用另一个native方法重载时,程序员才需要使用长名称。但是,如果native方法与非native方法具有相同的名称,则这不是问题。非native方法(Java方法)不驻留在native库中。
在以下示例中,g
不必使用长名称链接方法g,因为另一种方法不是native方法,因此不在native库中。
class Cls1 { int g(int i); native int g(double d); }
我们采用了一种简单的名称修改方案,以确保所有Unicode字符都转换为有效的C函数名称。
我们使用下划线(“_”)字符代替完全限定类名中的斜杠(“/”)。由于名称或类型描述符从不以数字开头,因此我们可以使用_0
...,_9
表示转义序列。
native方法和接口API都遵循给定平台上的标准库调用约定。例如,UNIX系统使用C调用约定,而Win32系统使用__stdcall。
四、native方法参数
JNI接口指针是native方法的第一个参数。JNI接口指针的类型为JNIEnv。第二个参数根据native方法是静态方法还是非静态方法而有所不同。非静态native方法的第二个参数是对该对象的引用。静态native方法的第二个参数是对其Java类的引用。
其余参数对应于常规Java方法参数。native方法调用通过返回值将其结果传递回调用例程。下面的一篇文章,我们会介绍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" /* 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和native代码之间复制。另一方面,任意Java对象都通过引用传递。VM必须跟踪已传递给native代码的所有对象,以便垃圾收集器不会释放这些对象。反过来,native代码必须有一种方法来通知VM它不再需要这些对象。此外,垃圾收集器必须能够移动native代码引用的对象。
全局引用和本地引用
JNI将native代码使用的对象引用分为两类:本地引用和全局引用。native引用在native方法调用的持续时间内有效,并在native方法返回后自动释放。全局引用在显式释放之前仍然有效。
对象作为native引用传递给native方法。JNI函数返回的所有Java对象都是本地引用。JNI允许程序员从本地引用创建全局引用。JNI函数,期望Java对象接受全局和本地引用。native方法可以返回对VM的本地或全局引用作为其结果。
在大多数情况下,程序员应该依赖VM在native方法返回后释放所有本地引用。但是,有时程序员应该明确地释放本地引用。例如,考虑以下情况:
- native方法访问大型Java对象,从而创建对Java对象的本地引用。然后,native方法在返回调用方之前执行其他计算。对大型Java对象的本地引用将阻止对象被垃圾回收,即使该对象不再用于计算的其余部分。
- native方法会创建大量本地引用,但并非所有引用都同时使用。由于VM需要一定的空间来跟踪本地引用,因此创建太多本地引用可能会导致系统内存不足。例如,native方法循环遍历大量对象,将元素作为本地引用检索,并在每次迭代时对一个元素进行操作。在每次迭代之后,程序员不再需要对数组元素的本地引用。
JNI允许程序员在native方法中的任何点手动删除本地引用。为了确保程序员可以手动释放本地引用,不允许JNI函数创建额外的本地引用,除了它们作为结果返回的引用。
本地引用仅在创建它们的线程中有效。native代码不能将本地引用从一个线程传递到另一个线程。
实现本地引用
为了实现本地引用,Java VM为从Java到native方法的每次控制转换创建了一个注册表。注册表将不可移动的本地引用映射到Java对象,并防止对象被垃圾回收。传递给native方法的所有Java对象(包括那些作为JNI函数调用结果返回的对象)都会自动添加到注册表中。在native方法返回后删除注册表,允许其所有条目被垃圾回收。
有不同的方法来实现注册表,例如使用表,链表或哈希表。虽然引用计数可用于避免注册表中的重复条目,但JNI实现没有义务检测和折叠重复条目。
注意:通过遍历native堆栈无法实现本地引用。因为native代码可以将本地引用存储到全局或堆数据结构中。
六、访问Java对象
JNI在全局和本地引用上提供了丰富的访问器函数。这意味着无论VM如何在内部表示Java对象,相同的native方法实现都会起作用。这是JNI可以被各种VM实现支持的关键原因。
通过不透明引用使用访问器函数的开销高于直接访问C数据结构的开销。我们相信,在大多数情况下,Java程序员使用native方法来执行非常重要的任务,这些任务会掩盖此接口的开销。
访问原始数组
对于包含许多基本数据类型的大型Java对象(例如整数数组和字符串),此开销是不可接受的。迭代Java数组并使用函数调用检索每个元素是非常低效的。
为解决此问题我们引入了“固定”的概念,以便native方法可以要求VM确定数组的内容。然后,native方法接收指向元素的直接指针。然而,这种方法有两个含义:
- 垃圾收集器必须支持固定。
- VM必须在内存中连续布局原始数组。
为了克服上述两个问题,我们采取以下方案:
首先,我们提供了一组函数来复制Java数组的一段和本机内存缓冲区之间的原始数组元素。如果native方法只需要访问大型数组中的少量元素,请使用这些函数。
其次,程序员可以使用另一组函数来检索数组元素的固定版本。请记住,这些功能可能需要Java VM执行存储分配和复制。这些函数实际上是否复制数组取决于VM实现,如下所示:
- 如果垃圾收集器支持固定,并且数组的布局与native方法的预期相同,则不需要复制。
- 否则,将数组复制到不可移动的内存块(例如,在C堆中)并执行必要的格式转换。返回指向副本的指针。
最后,该接口提供了通知VM native代码不再需要访问数组元素的功能。当您调用这些函数时,系统会取消数组,或者将原始数组与其不可移动的副本进行协调并释放副本。
我们的方法提供灵活性 垃圾收集器算法可以针对每个给定阵列单独决定复制或固定。例如,垃圾收集器可以复制小对象,但可以固定较大的对象。
JNI实现必须确保在多个线程中运行的native方法可以同时访问同一个数组。例如,JNI可以为每个固定数组保留一个内部计数器,这样一个线程就不会取消固定另一个线程固定的数组。请注意,JNI不需要锁定原始数组以供native方法独占访问。同时从不同的线程更新Java数组会导致不确定的结果。
访问字段和方法
JNI允许native代码访问字段并调用Java对象的方法。JNI通过符号名称和类型签名来标识方法和字段。两步过程会从字段名称和签名中分析出定位字段或方法的成本。例如,要f
在类cls中调用该方法,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施加任何限制。
七、报告编程错误
JNI不检查编程错误,例如传入NULL指针或非法参数类型。非法参数类型包括使用普通Java对象而不是Java类对象。由于以下原因,JNI不检查这些编程错误:
- 强制JNI函数检查所有可能的错误条件会降低正常(正确)native方法的性能。
- 在许多情况下,没有足够的运行时类型信息来执行此类检查。
大多数C库函数都不能防止编程错误。printf()
例如,该函数在收到无效地址时通常会导致运行时错误,而不是返回错误代码。强制C库函数检查所有可能的错误条件可能会导致重复此类检查 - 一次在用户代码中,然后再次在库中。
程序员不得将非法指针或错误类型的参数传递给JNI函数。这样做可能会导致任意后果,包括系统状态损坏或VM崩溃。
Java异常
JNI允许native方法引发任意Java异常。native代码也可以处理未完成的Java异常。未处理的Java异常会传播回VM。
例外和错误代码
某些JNI函数使用Java异常机制来报告错误情况。在大多数情况下,JNI函数通过返回错误代码并抛出Java异常来报告错误情况。错误代码通常是一个特殊的返回值(如NULL),它超出了正常返回值的范围。因此,程序员可以:
- 快速检查上次JNI调用的返回值,以确定是否发生了错误,并且
-
调用函数,
ExceptionOccurred()
以获取包含错误条件的更详细描述的异常对象。
在两种情况下,程序员需要检查异常而无法首先检查错误代码:
-
调用Java方法的JNI函数返回Java方法的结果。程序员必须调用
ExceptionOccurred()
以检查在执行Java方法期间可能发生的异常。
-
某些JNI数组访问函数不返回错误代码,但可能会抛出一个
ArrayIndexOutOfBoundsException
或ArrayStoreException
。
在所有其他情况下,非错误返回值可确保不会抛出任何异常。
异步异常
在多线程的情况下,除当前线程之外的线程可能发布异步异常。异步异常不会立即影响当前线程中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()
八、总结
本文是译文,原文地址为:https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html。
同时也是本人整理的JNI教程的第二篇,可能部分内容语法有点不通顺,但是看完了也能基本了解JNI的设计思路。后续我们会进一步对JNI相关的知识做更进一步的整理。