第二章:设计概述

本章重点讨论JNI中的主要设计问题。本节中的大多数设计问题与本地方法相关。调用API的设计在第五章:调用API中有详细介绍。

本章涵盖以下主题:

JNI接口函数和指针

本地代码通过调用JNI函数访问Java虚拟机功能。JNI函数通过一个接口指针提供。接口指针是一个指向指针的指针。该指针指向一个指针数组,每个指针指向一个接口函数。每个接口函数都位于数组内的预定义偏移量处。下图,接口指针,说明了接口指针的组织结构。

接口指针

接口指针图解

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

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

本地方法将JNI接口指针作为参数接收。虚拟机保证在从同一Java线程多次调用本地方法时向本地方法传递相同的接口指针。但是,本地方法可以从不同的Java线程调用,因此可能接收到不同的JNI接口指针。

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

由于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 {
        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部分的库和版本管理中。

解析本地方法名称

JNI将在Java中声明的native方法的名称与驻留在本地库中的本地方法的名称进行一对一映射。虚拟机使用此映射动态链接Java对native方法的调用到本地库中的相应实现。

该映射通过连接以下组件从native方法声明派生的内容来生成本地方法名称:

  1. 前缀Java_
  2. 给定声明native方法的类的二进制名称(内部形式):名称的转义结果。
  3. 下划线("_")
  4. 转义的方法名称
  5. 如果native方法声明已重载:两个下划线("__")后跟方法声明的参数描述符(JVMS 4.3.3)的转义形式。

转义保留每个字母数字ASCII字符(A-Za-z0-9)不变,并用下表中的对应转义序列替换每个UTF-16代码单元。如果要转义的名称包含代理对,则高代理代码单元和低代理代码单元将分别转义。转义的结果是一个仅由ASCII字符A-Za-z0-9和下划线组成的字符串。

UTF-16代码单元 转义序列
正斜杠(/,U+002F) _
下划线(_,U+005F) _1
分号(;,U+003B) _2
左方括号([,U+005B) _3
任何不表示字母数字ASCII(A-Za-z0-9)、正斜杠、下划线、分号或左方括号的UTF-16代码单元\uWXYZ _0wxyz其中wxyz是十六进制数字WXYZ的小写形式。(例如,U+ABCD变为_0abcd。)

转义是必要的原因有两个。首先,确保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中的这些native方法:

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_2Java_p_q_r_A_f__ILjava_lang_Object_2,因为这些native方法是重载的。

如果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源代码生成的类文件中情况并非如此。为了保留与本机方法名称的一对一映射,虚拟机按照以下方式检查生成的名称。如果从native方法声明(类名或方法名,或参数类型)中转义任何前导字符串的过程导致前导字符串中的"0","1","2"或"3"字符在结果中保持不变,要么紧跟在下划线后面,要么出现在转义字符串的开头(在完全组装的名称中将跟在下划线后面),则转义过程被称为“失败”。在这种情况下,不会执行本机库搜索,并且尝试链接native方法调用将引发UnsatisfiedLinkError。可以扩展当前简单的映射方案以涵盖这种情况,但复杂性成本将超过任何好处。

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

还可以使用RegisterNatives函数显式链接本机方法。请注意,RegisterNatives可能会更改JVM的文档行为(包括加密算法、正确性、安全性、类型安全性),通过更改要执行的本机代码来执行给定的本机Java方法。因此,使用使用RegisterNatives函数的具有本机库的应用程序时要小心。

本机方法参数

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,        /* 接口指针 */
     jobject obj,        /* "this"指针 */
     jint i,             /* 参数#1 */
     jstring s)          /* 参数#2 */
{
     /* 获取Java字符串的C副本 */
     const char *str = (*env)->GetStringUTFChars(env, s, 0);

     /* 处理字符串 */
     ...

     /* 现在我们完成了str */
     (*env)->ReleaseStringUTFChars(env, s, str);

     return ...
}

请注意,我们始终使用接口指针env来操作Java对象。使用C++,您可以编写稍微更清晰的代码版本,如下面的代码示例所示:

extern "C" /* 指定C调用约定 */

jdouble Java_p_q_r_A_f__ILjava_lang_String_2 (

     JNIEnv *env,        /* 接口指针 */
     jobject obj,        /* "this"指针 */
     jint i,             /* 参数#1 */
     jstring s)          /* 参数#2 */

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

     // ...

     env->ReleaseStringUTFChars(s, str);

     // 返回 ...
}

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

引用Java对象

原始类型,如整数、字符等,在Java和本机代码之间进行复制。另一方面,任意Java对象是通过引用传递的。虚拟机必须跟踪所有传递给本机代码的对象,以便这些对象不会被垃圾回收器释放。反过来,本机代码必须有一种方法通知虚拟机它不再需要这些对象。此外,垃圾回收器必须能够移动本机代码引用的对象。

全局引用和局部引用

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

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

在大多数情况下,程序员应依赖虚拟机在本机方法返回后自动释放所有局部引用。但是,有时程序员应显式释放局部引用。例如,考虑以下情况:

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

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

实现局部引用

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

有多种实现注册表的方法,例如使用表、链表或哈希表。尽管可以使用引用计数来避免注册表中的重复条目,但JNI实现不必检测和折叠重复条目。

请注意,无法通过保守扫描本机堆栈来忠实地实现局部引用。本机代码可能将局部引用存储到全局或堆数据结构中。

访问Java对象

JNI提供了一套丰富的访问器函数,用于全局和局部引用。这意味着相同的本机方法实现可以在虚拟机如何内部表示Java对象的情况下工作。这是JNI能够受到广泛支持的关键原因。

通过不透明引用使用访问器函数的开销比直接访问C数据结构的开销更高。我们相信,在大多数情况下,Java程序员使用本机方法执行的非平凡任务将掩盖此接口的开销。

访问原始数组

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

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

我们采用一种克服上述两个问题的折衷方案。

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

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

最后,接口提供函数,通知虚拟机本机代码不再需要访问数组元素。当调用这些函数时,系统会取消固定数组,或者将原始数组与其非可移动副本协调并释放副本。

我们的方法提供了灵活性。垃圾回收器算法可以针对每个给定数组做出单独的复制或固定决策。例如,垃圾回收器可以复制小对象,但固定较大对象。

一个JNI实现必须确保在多个线程中运行的本地方法可以同时访问同一个数组。例如,JNI可以为每个固定的数组保留一个内部计数器,以便一个线程不会取消固定另一个线程也固定的数组。请注意,JNI不需要锁定原始数组以供本地方法独占访问。从不同线程同时更新Java数组会导致不确定的结果。

访问字段和方法

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的内部实现施加任何限制。

调用调用者敏感方法

少数Java方法具有称为调用者敏感性的特殊属性。一个调用者敏感方法的行为可能会根据其直接调用者的身份而有所不同。例如,AccessibleObject::canAccess需要知道调用者以确定可访问性。

当本地代码调用这样的方法时,调用堆栈上可能没有任何Java调用者。程序员有责任了解他们的本地代码调用的Java方法是否是调用者敏感的,以及如果没有Java调用者这些方法将如何响应。如果必要,程序员可以为本地代码提供Java代码来调用,然后再调用原始Java方法。

报告编程错误

JNI不检查诸如传递NULL指针或非法参数类型等编程错误。非法参数类型包括使用普通Java对象而不是Java类对象。JNI不检查这些编程错误的原因如下:

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

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

Java异常

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

异常和错误代码

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

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

在所有其他情况下,非错误返回值保证没有抛出异常。

异步异常

一个线程可以通过调用已自Java 2 SDK发布1.2以来已被弃用的Thread.stop()方法在另一个线程中引发异步异常。强烈建议程序员不要使用Thread.stop(),因为它通常会导致不确定的应用程序状态。

此外,JVM可能在当前线程中产生异常,而不是由于JNI API调用的直接结果,而是由于各种JVM内部错误,例如:VirtualMachineError,如StackOverflowErrorOutOfMemoryError。这也被称为异步异常。

异步异常不会立即影响当前线程中本地代码的执行,直到:

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

本地方法应在必要的地方插入ExceptionOccurred()检查,例如在任何没有其他异常检查的长时间运行代码中(这可能包括紧密循环)。这确保当前线程在合理的时间内对异步异常做出响应。但是,由于它们的异步性质,在调用之前进行异常检查并不能保证在检查和调用之间不会引发异步异常。

异常处理

在本地代码中处理异常有两种方式:

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

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