设计概述
本章着重讨论 JNI 中的主要设计问题,其中的大部分问题都与本地方法有关。调用 API 的设计将在 第 5 章 “调用 API” 中讨论。
JNI 接口函数和指针
平台相关代码是通过调用 JNI 函数来访问 Java 虚拟机功能的。JNI 函数可通过接口指针来获得。接口指针是指针的指针,它指向一个指针数组,而指针数组中的每个元素又指向一个接口函数。每个接口函数都处在数组的某个预定偏移量中。图 2-1 说明了接口指针的组织结构。
图 2-1 接口指针
JNI 接口的组织类似于 C++ 虚拟函数表或 COM 接口。使用接口表而不使用硬性编入的函数表的好处是使 JNI 名字空间与平台相关代码分开。虚拟机可以很容易地提供多个版本的 JNI 函数表。例如,虚拟机可支持以下两个 JNI 函数表:
JNI 接口指针只在当前线程中有效。因此,本地方法不能将接口指针从一个线程传递到另一个线程中。实现 JNI 的虚拟机可将本地线程的数据分配和储存在 JNI 接口指针所指向的区域中。
本地方法将JNI 接口指针当作参数来接受。虚拟机在从相同的 Java 线程中对本地方法进行多次调用时,保证传递给该本地方法的接口指针是相同的。但是,一个本地方法可被不同的 Java 线程所调用,因此可以接受不同的 JNI 接口指针。
加载和链接本地方法
对本地方法的加载通过 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
。
程序员可用单个库来存放任意数量的类所需的所有本地方法,只要这些类将被相同的类加载器所加载。虚拟机在其内部为每个类加载器保护其所加载的本地库清单。提供者应该尽量选择能够避免名称冲突的本地库名。
如果底层操作系统不支持动态链接,则必须事先将所有的本地方法链接到虚拟机上。这种情况下,虚拟机实际上不需要加载库即可完成System.loadLibrary
调用。
程序员还可调用 JNI 函数 RegisterNatives()
来注册与类关联的本地方法。在与静态链接的函数一起使用时,RegisterNatives()
函数将特别有用。
解析本地方法名
动态链接程序是根据项的名称来解析各项的。本地方法名由以下几部分串接而成:
虚拟机将为本地库中的方法查找匹配的方法名。它首先查找短名(没有参数签名的名称),然后再查找带参数签名的长名称。只有当某个本地方法被另一个本地方法重载时程序员才有必要使用长名。但如果本地方法的名称与非本地方法的名称相同,则不会有问题。因为非本地方法(Java 方法)并不放在本地库中。
下例中,不必用长名来链接本地方法 g
,因为另一个方法 g
不是本地方法,因而它并不在本地库中。
class Cls1 {
int g(int i);
native int g(double d);
}
我们采取简单的名字搅乱方案,以保证所有的 Unicode 字符都能被转换为有效的 C 函数名。我们用下划线(“_”) 字符来代替全限定的类名中的斜杠(“/”)。由于名称或类型描述符从来不会以数字打头,我们用_0
、...、_9
来代替转义字符序列,如表
2-1所示:
表 2-1 Unicode 字符转换 |
|
字符“_” |
|
本地方法和接口 API 都要遵守给定平台上的库调用标准约定。例如,UNIX 系统使用 C 调用约定,而 Win32 系统使用 __stdcall。
本地方法的参数
JNI 接口指针是本地方法的第一个参数。其类型是 JNIEnv。第二个参数随本地方法是静态还是非静态而有所不同。非静态本地方法的第二个参数是对对象的引用,而静态本地方法的第二个参数是对其 Java 类的引用。
其余的参数对应于通常 Java 方法的参数。本地方法调用利用返回值将结果传回调用程序中。第 3 章 “JNI 的类型和数据结构” 将描述 Java 类型和 C 类型之间的映射。
代码示例 2-1 说明了如何用 C 函数来实现本地方法f
。对本地方法f
的声明如下:
package pkg;
class Cls {
native double f(int i, String s);
...
}
具有长 mangled 名称 Java_pkg_Cls_f_ILjava_lang_String_2
的 C 函数实现本地方法f
:
代码示例 2-1: 用 C 实现本地方法
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* 接口指针 */
jobject obj, /* “this”指针 */
jint i, /* 第一个参数 */
jstring s) /* 第二个参数 */
{
/* 取得 Java 字符串的 C 版本 */
const char *str = (*env)->GetStringUTFChars(env, s, 0);
/* 处理该字符串 */
...
/* 至此完成对 str 的处理 */
(*env)->ReleaseStringUTFChars(env, s, str);
return ...
}
注意,我们总是用接口指针 env 来操作 Java 对象。可用 C++ 将此代码写得稍微简洁一些,如代码示例 2-2 所示:
代码示例 2-2: 用 C++ 实现本地方法
extern "C" /* 指定 C 调用约定 */
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* 接口指针 */
jobject obj, /* “this”指针 */
jint i, /* 第一个参数 */
jstring s) /* 第二个参数 */
{
const char *str = env->GetStringUTFChars(s, 0);
...
env->ReleaseStringUTFChars(s, str);
return ...
}
使用 C++ 后,源代码变得更为直接,且接口指针参数消失。但是,C++ 的内在机制与 C 的完全一样。在 C++ 中,JNI 函数被定义为内联成员函数,它们将扩展为相应的 C 对应函数。
引用 Java 对象
基本类型(如整型、字符型等)在 Java 和平台相关代码之间直接进行复制。而 Java 对象由引用来传递。虚拟机必须跟踪传到平台相关代码中的对象,以使这些对象不会被垃圾收集器释放。反之,平台相关代码必须能用某种方式通知虚拟机它不再需要那些对象,同时,垃圾收集器必须能够移走被平台相关代码引用过的对象。
全局和局部引用
JNI 将平台相关代码使用的对象引用分成两类:局部引用和全局引用。局部引用在本地方法调用期间有效,并在本地方法返回后被自动释放掉。全局引用将一直有效,直到被显式释放。
对象是被作为局部引用传递给本地方法的,由 JNI 函数返回的所有 Java 对象也都是局部引用。JNI 允许程序员从局部引用创建全局引用。要求 Java 对象的 JNI 函数既可接受全局引用也可接受局部引用。本地方法将局部引用或全局引用作为结果返回。
大多数情况下,程序员应该依靠虚拟机在本地方法返回后释放所有局部引用。但是,有时程序员必须显式释放某个局部引用。例如,考虑以下的情形:
- 本地方法要访问一个大型 Java 对象,于是创建了对该 Java 对象的局部引用。然后,本地方法要在返回调用程序之前执行其它计算。对这个大型 Java 对象的局部引用将防止该对象被当作垃圾收集,即使在剩余的运算中并不再需要该对象。
- 本地方法创建了大量的局部引用,但这些局部引用并不是要同时使用。由于虚拟机需要一定的空间来跟踪每个局部引用,创建太多的局部引用将可能使系统耗尽内存。例如,本地方法要在一个大型对象数组中循环,把取回的元素作为局部引用,并在每次迭代时对一个元素进行操作。每次迭代后,程序员不再需要对该数组元素的局部引用。
JNI 允许程序员在本地方法内的任何地方对局部引用进行手工删除。为确保程序员可以手工释放局部引用,JNI 函数将不能创建额外的局部引用,除非是这些 JNI 函数要作为结果返回的引用。
局部引用仅在创建它们的线程中有效。本地方法不能将局部引用从一个线程传递到另一个线程中。
实现局部引用
为了实现局部引用,Java 虚拟机为每个从 Java 到本地方法的控制转换都创建了注册服务程序。注册服务程序将不可移动的局部引用映射为 Java 对象,并防止这些对象被当作垃圾收集。所有传给本地方法的 Java 对象(包括那些作为 JNI 函数调用结果返回的对象)将被自动添加到注册服务程序中。本地方法返回后,注册服务程序将被删除,其中的所有项都可以被当作垃圾来收集。
可用各种不同的方法来实现注册服务程序,例如,使用表、链接列表或 hash 表来实现。虽然引用计数可用来避免注册服务程序中有重复的项,但 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 或方法 ID 并不能防止虚拟机卸载生成该 ID 的类。该类被卸载之后,该方法 ID 或域 ID 亦变成无效。因此,如果平台相关代码要长时间使用某个方法 ID 或域 ID,则它必须确保:
JNI 对域 ID 和方法 ID 的内部实现并不施加任何限制。
报告编程错误
JNI 不检查诸如传递 NULL 指针或非法参数类型之类的编程错误。非法的参数类型包括诸如要用 Java 类对象时却用了普通 Java 对象这样的错误。JNI 不检查这些编程错误的理由如下:
大多数 C 库函数对编程错误不进行防范。例如,printf()
函数在接到一个无效地址时通常是引起运行错而不是返回错误代码。强迫 C 库函数检查所有可能的错误情况将有可能引起这种检查被重复进行--先是在用户代码中进行,然后又在库函数中再次进行。
程序员不得将非法指针或错误类型的参数传递给 JNI 函数。否则,可能产生意想不到的后果,包括可能使系统状态受损或使虚拟机崩溃。
Java 异常
JNI 允许本地方法抛出任何 Java 异常。本地方法也可以处理突出的 Java 异常。未被处理的 Java 异常将被传回虚拟机中。
异常和错误代码
一些 JNI 函数使用 Java 异常机制来报告错误情况。大多数情况下,JNI 函数通过返回错误代码并抛出 Java 异常来报告错误情况。错误代码通常是特殊的返回值(如 NULL),这种特殊的返回值在正常返回值范围之外。因此,程序员可以:
在以下两种情况中,程序员需要先查出异常,然后才能检查错误代码:
-
调用 Java 方法的 JNI 函数返回该 Java 方法的结果。程序员必须调用
ExceptionOccurred()
以检查在执行 Java 方法期间可能发生的异常。 - 某些用于访问 JNI 数组的函数并不返回错误代码,但可能会抛出
ArrayIndexOutOfBoundsException
或ArrayStoreException
。
在所有其它情况下,返回值如果不是错误代码值就可确保没有抛出异常。
异步异常
在多个线程的情况下,当前线程以外的其它线程可能会抛出异步异常。异步异常并不立即影响当前线程中平台相关代码的执行,直到出现下列情况:
注意,只有那些有可能抛出同步异常的 JNI 函数才检查异步异常。
本地方法应在必要的地方(例如,在一个没有其它异常检查的紧密循环中)插入 ExceptionOccurred()
检查以确保当前线程可在适当时间内对异步异常作出响应。
异常的处理
可用两种方法来处理平台相关代码中的异常:
抛出了某个异常之后,平台相关代码必须先清除异常,然后才能进行其它的 JNI 调用。当有待定异常时,只有以下这些 JNI 函数可被安全地调用:ExceptionOccurred()、ExceptionDescribe()
和ExceptionClear()
。ExceptionDescribe()
函数将打印有关待定异常的调试消息。