上一篇:01-导言
本章重点讨论 JNI 中的主要设计问题。本节中的大多数设计问题都与本地方法有关。调用 API 的设计将在第 5 章:调用 API 中介绍。
2.1 JNI 接口函数和指针
本地代码通过调用 JNI 函数来访问 Java 虚拟机功能。JNI 函数可通过接口指针使用。接口指针是指向指针的指针。该指针指向一个指针数组,数组中每个指针指向一个接口函数。每个接口函数都位于数组内预定义的偏移量处。下图 "接口指针 "说明了接口指针的组织结构。
JNI 接口的组织方式类似于 C++ 虚拟函数表或 COM 接口。使用接口表而不是硬连线函数项的好处是,JNI 名称空间与本地代码分离。虚拟机可以轻松提供多个版本的 JNI 函数表。例如,虚拟机可以支持两个 JNI 函数表:
①. 一种会对非法参数进行彻底检查,适合调试;
②. 而另一种方法只执行 JNI 规范要求的最少检查量,因此效率更高。
JNI 接口指针只在当前线程中有效。因此,本地方法不得将接口指针从一个线程传递到另一个线程。实现 JNI 的虚拟机可以在 JNI 接口指针指向的区域中分配和存储线程本地数据。
本地方法接收 JNI 接口指针作为参数。当虚拟机从同一个 Java 线程多次调用本地方法时,会保证将同一个接口指针传递给本地方法。不过,本地方法可以从不同的 Java 线程调用,因此可能会收到不同的 JNI 接口指针。
2.2 编译、加载和链接本地方法
由于 Java 虚拟机是多线程的,因此本地库也应使用多线程感知本地编译器进行编译和链接。例如,使用 Sun Studio 编译器编译的 C++ 代码应使用 -mt 标志。对于使用 GNU gcc 编译器编译的代码,应使用标志 -D_REENTRANT 或 -D_POSIX_C_SOURCE 。
本地方法用 System.loadLibrary 方法加载。在下面的示例中,类初始化方法加载了一个特定平台的本地库,其中定义了本地方法 f :
package p.q.r;class A {native double f(int i, String s);static {// // /*** defined in "libcore/ojluni/src/main/java/java/lang/System.java"* public static void loadLibrary(String libname);* 加载由libname参数指定的本机库。* libname参数不能包含任何特定于平台的前缀、文件扩展名或路径。* 如果一个名为libname的本地库与虚拟机静态链接,则调用该库导出的JNI_OnLoad()函数。* 否则,libname参数将从系统库位置加载,并以依赖于实现的方式映射到本机库映像。*/System.loadLibrary("p_q_r_A");}
}
System.loadLibrary 的参数是程序员任意选择的库名。系统会采用一种标准的、但与特定平台有关的方法,将库名称转换为本地库名称。例如,Linux 系统会将库名 p_q_r_A 转换为 libp_q_r_A.so ,而 Windows 系统会将库名 p_q_r_A 转换为 p_q_r_A.dll 。
程序员可以使用一个库来存储任意数量的类所需的所有本地方法,只要这些类是用同一个类加载器加载的。虚拟机内部会为每个类加载器维护一个已加载的本地库列表。供应商在选择本地库名称时,应尽量减少名称冲突的机会。
对动态链接和静态链接库的支持,以及它们各自的生命周期管理 "加载 "和 "卸载 "功能钩子,在库和版本管理的调用 API 部分有详细介绍。
2.2.1 解析本地方法名称
JNI 定义了从 Java 中声明的 native 方法名称到本地库中本地方法名称的 1:1 映射。虚拟机使用该映射将 native 方法的 Java 调用动态链接到本地库中的相应实现。
该映射通过连接从 native 方法声明中派生出来的以下组件来生成本地方法名称:
①. Java_前缀;
②. 给出了声明 native 方法的类的二进制内部名称:转义该名称的结果。
③. ("_") 下划线
④. 转义方法名
⑤. 如果 native 方法声明是重载的:两个下划线("__"),后跟方法声明的转义参数描述符(JVMS 4.3.3)。
转义时,每个字母数字 ASCII 字符 ( A-Za-z0-9 ) 都保持不变,并用相应的转义序列替换下表中的每个 UTF-16 代码单元。如果要转义的名称包含一对代用字符,则高代用代码单元和低代用代码单元将分别转义。转义的结果是一个仅由 ASCII 字符 A-Za-z0-9 和下划线组成的字符串。
出于两个原因,转义是必要的。首先,为了确保 Java 源代码中可能包含 Unicode 字符的类名和方法名能转换成 C 源代码中有效的函数名。其次,确保 native 方法的参数描述符(使用"; "和"["字符对参数类型进行编码)可以在 C 函数名中编码。
当 Java 程序调用一个 native 方法时,虚拟机会搜索本地库,首先查找本地方法名称的简短版本,即不包含转义参数签名的名称。如果找不到短名称的本地方法,虚拟机就会查找长版本的本地方法名称,即包含转义参数签名的名称。
先查找简短的名称,可以更方便地在本地库中声明实现。例如,给出 Java 中的 native 方法:
package p.q.r;
class A {native double f(int i, String s);
}
相应的 C 函数可以命名为 Java_p_q_r_A_f ,而不是 Java_p_q_r_A_f__ILjava_lang_String_2 。
只有当一个类中的两个或多个 native 方法具有相同的名称时,才有必要在本地库中声明具有长名称的实现。例如,Java 中有以下1 个方法:
package p.q.r;
class A {native double f(int i, String s);native double f(int i, Object s);
}
相应的 C 语言函数必须命名为 Java_p_q_r_A_f__ILjava_lang_String_2 和 Java_p_q_r_A_f__ILjava_lang_Object_2 ,因为这两个方法是重载的。
如果 Java 中的 native 方法只被非 native 方法重载,则无需在本地库中使用长名称。在下面的示例中, native 方法 g 不必使用长名称链接,因为另一个方法 g 不是 native 方法,因此不存在于本地库中。
package p.q.r;
class B {int g(int i);native int g(double d);
}
请注意,转义序列可以安全地以 _0 、 _1 等开头,因为 Java 源代码中的类名和方法名从不以数字开头。但是,在非 Java 源代码生成的类文件中,情况并非如此。为了保持与本地方法名称的 1:1 映射,虚拟机会对生成的名称进行如下检查。如果从方法声明(类或方法名称或参数类型)中转义任何前导字符串的过程导致前导字符串中的 " 0 "、" 1 "、" 2 "或 " 3 "字符在结果中紧跟下划线后或在转义字符串的开头(在完全组装后的名称中,这些字符将紧跟下划线)出现,且未发生变化,则称转义过程 "失败"。在这种情况下,将不执行本地库搜索,并且在尝试链接 native 方法调用时将抛出 UnsatisfiedLinkError 。可以扩展目前的简单映射方案以涵盖这种情况,但复杂性成本将超过任何好处。
本地方法和接口 API 都遵循特定平台上的标准库调用约定。例如,UNIX 系统使用 C 调用约定,而 Win32 系统使用 __stdcall。
本机方法也可以使用 RegisterNatives 功能进行显式链接。需要注意的是, RegisterNatives 函数可以通过更改给定本地 Java 方法要执行的本地代码来改变 JVM 的记录行为(包括加密算法、正确性、安全性、类型安全性)。因此,请谨慎使用使用 RegisterNatives 函数的本地库应用程序。
2.2.2 本地方法参数
JNI 接口指针是本地方法的第一个参数。JNI 接口指针的类型是 JNIEnv。第二个参数根据本地方法是静态还是非静态而有所不同。非静态本地方法的第二个参数是对象的引用。静态本地方法的第二个参数是对其 Java 类的引用。
其余参数与常规 Java 方法参数相对应。本地方法调用通过返回值将结果传回调用例程。第 3 章:JNI 类型和数据结构,介绍了 Java 和 C 语言类型之间的映射。
下面的代码示例说明了如何使用 C 语言函数来实现本地方法 f 。本地方法 f 的声明如下:
package p.q.r;class A {native double f(int i, String s);// ...
}
长名称为 Java_p_q_r_A_f_ILjava_lang_String_2 的 C 语言函数实现了本地方法 f :
jdouble Java_p_q_r_A_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_p_q_r_A_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++ 中,JNI 函数被定义为内联成员函数,可扩展为 C 语言的对应函数。
2.3 引用 Java 对象
原始类型(如整数、字符等)可在 Java 和本地代码之间复制。而任意 Java 对象则通过引用传递。虚拟机必须跟踪所有已传递给本地代码的对象,以免垃圾回收器释放这些对象。反过来,本地代码也必须有办法通知虚拟机它不再需要这些对象。此外,垃圾回收器必须能够移动本地代码引用的对象。
2.3.1 全局和本地引用
JNI 将本地代码使用的对象引用分为两类:本地引用和全局引用。本地引用在本地方法调用期间有效,并在本地方法返回后自动释放。全局引用在显式释放之前一直有效。
对象以本地引用的形式传递给本地方法。JNI 函数返回的所有 Java 对象都是局部引用。JNI 允许程序员从局部引用创建全局引用。期望返回 Java 对象的 JNI 函数既接受全局引用,也接受局部引用。本地方法可将本地引用或全局引用作为结果返回给 VM。
在大多数情况下,程序员应依靠虚拟机在本地方法返回后释放所有本地引用。不过,有时程序员应该显式释放本地引用。例如,请考虑以下情况:
①. 本地方法访问大型 Java 对象,从而创建 Java 对象的本地引用。然后,本地方法在返回给调用者之前会执行额外的计算。即使在剩余的计算中不再使用该对象,该大型 Java 对象的本地引用也会阻止该对象被垃圾回收。
②. 本地方法会创建大量的本地引用,但并非所有引用都会同时使用。由于虚拟机需要一定的空间来跟踪本地引用,创建过多的本地引用可能会导致系统内存不足。例如,一个本地方法在一个大型对象数组中循环,以本地引用的形式检索元素,每次迭代对一个元素进行操作。每次迭代后,程序员就不再需要数组元素的本地引用了。
JNI 允许程序员在本地方法的任意位置手动删除本地引用。为确保程序员能手动释放本地引用,JNI 函数不允许创建额外的本地引用,但作为结果返回的引用除外。
本地引用只在创建引用的线程中有效。本地代码不得将本地引用从一个线程传递到另一个线程。
2.3.2 本地引用的实现
为了实现本地引用,Java 虚拟机会为每次从 Java 到本地方法的控制转换创建一个注册表。注册表将不可移动的本地引用映射到 Java 对象,并防止对象被垃圾回收。传递给本地方法的所有 Java 对象(包括作为 JNI 函数调用结果返回的对象)都会自动添加到注册表中。本地方法返回后,注册表将被删除,从而允许对其所有条目进行垃圾回收。
实现注册表的方法有很多种,例如:使用表、链表或哈希表。虽然可以使用引用计数来避免注册表中的重复条目,但 JNI 实现没有义务检测和删除重复条目。
请注意,本地引用不能通过保守地扫描本地堆栈来忠实地实现。本地代码可能会将本地引用存储到全局或堆数据结构中。
2.4 访问 Java 对象
JNI 为全局和局部引用提供了丰富的访问函数集。这意味着,无论虚拟机如何在内部表示 Java 对象,都能使用相同的本地方法实现。这也是 JNI 可以被各种虚拟机实现所支持的重要原因。
通过不透明引用使用访问函数的开销要高于直接访问 C 数据结构的开销。我们相信,在大多数情况下,Java 程序员会使用本地方法来执行一些非繁琐的任务,而这些任务会掩盖该接口的开销。
2.4.1 访问原始数组
对于包含许多原始数据类型(如整数数组和字符串)的大型 Java 对象来说,这种开销是不可接受的。(考虑一下用于执行向量和矩阵计算的本地方法)。通过函数调用遍历 Java 数组并检索每个元素的效率非常低。
一种解决方案引入了 "锁定 "的概念,这样本地方法就可以要求虚拟机锁定数组的内容。然后,本地方法会收到指向元素的直接指针。不过,这种方法有两个影响:
①. 垃圾回收器必须支持“锁住”功能。
②. 虚拟机必须在内存中连续布局基元数组。虽然这对大多数基元数组来说是最自然的实现方式,但布尔数组可以以打包或未打包的方式实现。因此,依赖于布尔数组精确布局的本地代码将无法移植。
我们采取了一种折中的方法来克服上述两个问题。
首先,我们提供了一组函数,用于在 Java 数组段和本地内存缓冲区之间复制原始数组元素。如果本地方法只需访问大型数组中的少量元素,则使用这些函数。
其次,程序员可以使用另一组函数来检索数组元素的固定版本。请记住,这些函数可能需要 Java 虚拟机执行存储分配和复制。这些函数实际上是否复制数组取决于 VM 的实现,具体如下:
①. 如果垃圾回收器支持"锁定",且数组布局与本地方法预期的相同,则无需复制。
②. 否则,数组将被复制到一个不可移动的内存块(例如,在 C 堆中),并执行必要的格式转换。系统将返回指向拷贝的指针。
最后,接口提供了一些函数,用于通知虚拟机本地代码不再需要访问数组元素。调用这些函数时,系统要么取消数组的锁定,要么将原始数组与其不可移动的副本进行核对,并释放副本。
我们的方法具有灵活性。垃圾回收器算法可以针对每个给定的数组分别做出复制或“锁定”的决定。例如,垃圾回收器可以复制小对象,但“锁定”较大的对象。
JNI 实现必须确保在多个线程中运行的本地方法可以同时访问同一个数组。例如,JNI 可以为每个被钉住的数组保留一个内部计数器,这样一个线程就不会解除被另一个线程钉住的数组。请注意,JNI 不需要为本地方法的独占访问锁定原始数组。不同线程同时更新 Java 数组会导致非确定性结果。
2.4.2 访问字段和方法
JNI 允许本地代码访问 Java 对象的字段和调用 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 不会阻止虚拟机卸销毁 ID 所派生的类。类销毁后,方法或字段 ID 将失效,并且不能传递给任何使用该 ID 的函数。因此,本地代码如果打算长期使用某个方法或字段 ID,必须确保:
①. 保持对底层类的实时引用,或
②. 重新计算方法或字段 ID;
JNI 对内部如何实现字段和方法 ID 没有施加任何限制。
2.4.2.1 调用对调用者敏感的方法
少数 Java 方法具有一种特殊属性,称为调用者敏感性。对调用者敏感的方法可以根据直接调用者的身份做出不同的行为。例如,AccessibleObject::canAccess 需要知道调用者才能确定是否可访问。
当本地代码调用此类方法时,调用栈上可能没有任何 Java 调用者。程序员有责任了解从本地代码调用的 Java 方法是否对调用者敏感,以及如果没有 Java 调用者,这些方法将如何响应。如果有必要,程序员可以提供 Java 代码供本地代码调用,然后本地代码再调用原始 Java 方法。
2.5 报告编程错误
JNI 不会检查编程错误,如:传递 NULL 指针或非法参数类型。非法参数类型包括:使用普通 Java 对象而非 Java 类对象等。JNI 不检查这些编程错误的原因如下:
①. 强制 JNI 函数检查所有可能的错误条件会降低正常(正确)本地方法的性能。
②. 在许多情况下,没有足够的运行时类型信息来执行这种检查。
多数 C 库函数都不会防范编程错误。例如, printf() 函数在接收到无效地址时通常会导致运行时错误,而不是返回错误代码。强制 C 库函数检查所有可能的错误条件很可能会导致重复检查:在用户代码中检查一次,然后在库中再检查一次。
程序员不得向 JNI 函数传递非法指针或错误类型的参数。否则可能导致任意后果,包括系统状态损坏或虚拟机崩溃。
也就是说:传递这种空指针异常(nullptr)的检查,由用户负责;
2.6 Java异常
JNI 允许本地方法引发任意 Java 异常。本地代码也可以处理未处理的 Java 异常。未处理的 Java 异常会传播回虚拟机。
2.6.1 异常和错误代码
某些 JNI 函数使用 Java 异常机制来报告错误条件。在大多数情况下,JNI 函数通过返回错误代码和抛出 Java 异常来报告错误条件。错误代码通常是超出正常返回值范围的特殊返回值(如 NULL)。因此,程序员可以:
①. 快速检查最后一次 JNI 调用的返回值,以确定是否发生错误,以及
②. 调用 ExceptionOccurred() 函数来获取异常对象,该对象包含对错误条件的更详细描述。
在两种情况下,程序员需要检查异常,而无法首先检查错误代码:
①. 调用 Java 方法的 JNI 函数会返回 Java 方法的结果。程序员必须调用 ExceptionOccurred() 来检查 Java 方法执行过程中可能出现的异常。
②. 某些 JNI 数组访问函数不会返回错误代码,但可能会抛出 ArrayIndexOutOfBoundsException 或 ArrayStoreException 。
在所有其他情况下,非错误返回值保证没有抛出异常。
2.6.2 异步异常
一个线程可以通过调用 Thread.stop() 方法在另一个线程中引发异步异常,该方法自 Java 2 SDK 1.2 版起已被弃用。强烈建议程序员不要使用 Thread.stop() ,因为它通常会导致应用程序状态不确定。
此外,JVM 可能会在当前线程中产生异常,但这些异常并不是 JNI API 调用的直接结果,而是因为 JVM 内部的各种错误,例如: 像 StackOverflowError 或 OutOfMemoryError 的VirtualMachineError 。这些异常也被称为异步异常。
异步异常不会立即影响当前线程中本地代码的执行,直到出现以下情况:
①. 本地代码调用了一个可能引发同步异常的 JNI 函数,或
②. 本地代码使用 ExceptionOccurred() 来明确检查同步和异步异常。
请注意,只有那些可能引发同步异常的 JNI 函数才会检查异步异常。
本地方法应在必要的地方插入 ExceptionOccurred() 检查,例如:在没有其他异常检查的任何长时间运行代码中(可能包括紧密循环)。这样可以确保当前线程在合理的时间内响应异步异常。不过,由于异步异常的特性,在调用前进行异常检查并不能保证在检查和调用之间不会引发异步异常。
2.6.3 异常处理
在本地代码中有两种处理异常的方法:
①. 本机方法可以选择立即返回,从而在启动本机方法调用的 Java 代码中抛出异常。
②. 本地代码可以通过调用 ExceptionClear() 清除异常,然后执行自己的异常处理代码。
出现异常后,本地代码必须先清除异常,然后才能调用其他 JNI 函数。当出现待处理异常时,可以安全调用的 JNI 函数有:
ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
Release<Type>ArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()
DetachCurrentThread()
下一篇: 03-JNI 类型和数据结构