第八章 附加的JNI特性
我们已经讨论过用于编写本地代码的和在本地程序中嵌入一个Java虚拟机实现的JNI特性。这一章我们将介绍剩余的JNI特性。
8.1 JNI和线程
Java虚拟机支持在同一地址空间中同时执行的多个控制线程。这种并发性引入了一定程度的复杂性,这个在单线程环境中是没有的。多线程可以同时访问同一个对象,同一个文件描述符(简称相同的共享资源)。
为了充分利用本节,你应该熟悉多线程编程的概念。你应该知道如何编写使用多线程以及如何同步访问共享资源的Java应用程序。关于Java编程语言中多线程编程的好的参考书籍是由Doug Lea(Addison-Wesley,1997)编写的并发编程:设计原则与模式。
8.1.1 约束
当编写运行在多线程环境中的本地方法是,你必须记住一些约束,通过理解和使用这些约束编程,无论多少线程同时执行给定的本地方法,你的本地方法都会安全的执行。例如:
- 一个JNIEnv指针仅在其相关联的线程中有效。你不能将这个指针从一个线程中传递给另一个线程,或者在多线程中缓存和使用它。Java虚拟机在同一个线程的连续调用中传递给本地方法相同的JNIEnv指针,但是从不同线程中调用本地方法时传递的是不同的JNIEnv指针。应当避免缓存一个线程的JNIEnv指针并在另一个线程中使用指针的常见错误。
- 本地引用仅在创建它的线程中有效。你不能将本地引用冲一个线程中传递到另一个线程。每当有多个线程可能使用相同引用的可能性时,应始终将本地引用转换为全局引用。
8.1.2 监控进入和退出
监视器是Java平台上原始的同步机制。每个对象都能动态的与监视器关联。JNI允许你使用这些监视器来进行同步,从而实现与Java编程语言中的同步块相当的功能:
1 2 3 |
synchronized (obj) { ... // synchronized block } |
Java虚拟机保证线程在执行块中的任何语句之前获取与对象obj相关联的监视器。这样可以确保在任何给定的时间内最多只有一个线程持有监视器并在同步块内运行。当等待另一个线程退出监视器时,该线程会阻塞。
本地代码可以使用JNI函数在JNI引用上执行等效的同步。你可以使用MonitorEnter方法来进入监视器和使用MonitorExit方法来退出监视器。
1 2 3 4 5 6 7 |
if ((*env)->MonitorEnter(env, obj) != JNI_OK) { ... /* error handling */ } ... /* synchronized block */ if ((*env)->MonitorExit(env, obj) != JNI_OK) { ... /* error handling */ }; |
执行上面的代码,一个线程在执行同步块内的任何代码前,必须首先进入与obj相关联的监视器。MonitorEnter操作将一个jobject作为参数并且在另一个线程已经进入了和jobject相关联的监视器的情况下阻塞。当当前线程没有亦红优监视器时调用MonitorExit会导致错误并导致引发IllegalMonitorStateException异常。上面的代码包含一堆匹配的MonitorEnter和MonitorExit调用,但是我们仍然需要检查可能的错误。如果底层线程实现无法分配执行监视器操作所需的资源,则操作监视器可能会失败。
MonitorEnter和MonitorExit工作在jclass、jstring和jarray类型上,这些都是特殊的jobject引用。
请记住将MonitorEnter调用与适当数量的MonitorExit调用相匹配,特别是在处理错误和异常的代码中:
1 2 3 4 5 6 7 8 9 |
if ((*env)->MonitorEnter(env, obj) != JNI_OK) ...; ... if ((*env)->ExceptionOccurred(env)) { ... /* exception handling */ /* remember to call MonitorExit here */ if ((*env)->MonitorExit(env, obj) != JNI_OK) ...; } ... /* Normal execution path. if ((*env)->MonitorExit(env, obj) != JNI_OK) ...; |
调用MonitorExit失败的话可能会导致死锁。通过比较上面你的C代码片段和本节开始时编写的代码片段,你可以了解到使用Java编程语言进行编程比JNI更简便。因此最好使用Java编程语言表示同步结构。例如,如果静态本地方法需要进入与其定义相关联的监视器,那么你应该定义一个静态同步本地方法,而不是在本地代码中执行JNI级的监视器同步。
8.1.3 监视等待和通知
Java API包含几个用于线程同步的方法。它们是Object.wait、Object.notify和Object.notifyAll。JNI没有提供类似的方法和这些方法直接对应,因为监视等待和通知操作不如监视进入和退出的性能关键。本地代码可能会使用JNI方法调用机制来调用Java API中的相应方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/* precomputed method IDs */ static jmethodID MID_Object_wait; static jmethodID MID_Object_notify; static jmethodID MID_Object_notifyAll; void JNU_MonitorWait(JNIEnv *env, jobject object, jlong timeout) { (*env)->CallVoidMethod(env, object, MID_Object_wait, timeout); } void JNU_MonitorNotify(JNIEnv *env, jobject object) { (*env)->CallVoidMethod(env, object, MID_Object_notify); } void JNU_MonitorNotifyAll(JNIEnv *env, jobject object) { (*env)->CallVoidMethod(env, object, MID_Object_notifyAll); } |
我们假设Object.wait、Object.notify和Object.notifyAll的方法ID已经在其他地方计算过,并且被缓存在全局变量中。就像Java编程语言一样,只有在持有与Object参数相关量的监视器时,才能调用上述的监视器相关的功能。
8.1.4 在任意上下文中获取一个JNIEnv指针
之前我们就已经解释过,一个JNIEnv指针仅在与其相关联的线程中有效。对于本地方法,这通常不是问题,因为他们从虚拟机接受JNIEnv指针作为第一个参数。然而有时候可能不需要直接从虚拟机调用的本地代码来获取属于当前线程的JNIEnv接口指针。例如,属于“callback”的一端本地代码可能会被操作系统调用,在这种情况下,JNIEnv指针可能不能用作参数。你可以通过调用AttachCurrentThread方法来获取当前线程的JNIEnv指针:
1 2 3 4 5 6 |
JavaVM *jvm; /* already set */ f() { JNIEnv *env; (*jvm)->AttachCurrentThread(jvm, (void **)&env, NULL); ... /* use env */ } |
当当前线程已经附加到虚拟机上时,AttachCurrentThread返回属于当前线程的JNIEnv接口指针。
有许多中方式来获取JavaVM指针:通过在创建虚拟机的时候记录它、通过使用JNI_GetCreatedJavaVMs来查询已经创建了的虚拟机、通过在常规方法内调用GetJavaVM、或通过定义JNI_OnLoad处理程序来记录虚拟机,与JNIEnv指针不同,JavaVM指针在多个进行之间保持有效,因此可以缓存在全局变量中。
Java 2 SDK 1.2版本提供了一个新的调用接口方法GetEnv,因此你可以使用它来检查当前线程是否已经附加到一个虚拟机上,并且如果已经附加到虚拟机上了,它会返回属于当前线程的JNIEnv指针。
8.1.5 线程模型匹配
假设本地代码在多个线程中执行并访问同一个全局引用。本地代码应该按使用JNI函数MonitorEnter和MonitorExit还是使用主机环境中的本地线程同步原语(例如Solaris上的mutex_lock)?同样如果本地代码需要创建一个新的线程,那么应该创建一个java.lang.Thread对象并通过JNI调用Thread.start还是应该在主机环境中使用本地线程创建原语(例如Solaris上的thr_create)?
答案是,如果Java虚拟机实现支持与本地代码使用的线程模型匹配的线程模型,则所有这些方法都可以工作。线程模型指示着系统如何实现基本的线程操作,例如系统调度、上下文切换、同步和阻塞。在本地线程中模型中,操作系统管理所有这些必须的线程操作。另一方面,在一个用户线程模型中,应用程序代码实现线程操作。例如,Solaris上附带的JDK和Java 2 SDK版本中的“Green thread”模型使用ANSI C函数setjmp和longjmp来实现上下文切换。
许多现代操作系统(例如Solaris和Win32)支持本地线程模型。不幸的是,一些操作系统仍然缺少本地线程支持。相反,这些操作系统上可能有一个或多个用户线程包。
如果你严格使用Java编程语言编写应用程序,则无需担心虚拟机实现的底层线程模型。Java平台能够移植到任何支持所需线程原语的主机环境中。大多数本地和用户线程包提供了用于实现Java虚拟机必须的线程原语。
另一方面,JNI程序员必须注意线程模型。如果Java虚拟机实现和本地代码具有不同的线程和同步概念,那么使用本地代码的应用程序可能就无法正常运行。例如,本地方法可能会在自己的线程模型中执行同步操作时会阻塞,但是运行不同线程模型的Java虚拟机可能不会意识到执行本地方法的线程被阻塞。应用程序死锁是因为其他线程无法被调用。如果本地代码和Java虚拟机实现使用相同的线程模型,则它们的线程模型匹配。如果Java虚拟机实现支持本地线程,那么本地代码就能够在主机环境中自由的调用线程相关的原语。如果Java虚拟机实现是基于一个用户线程包的话,那么本地代码也应该链接到相同的用户线程包或者不要依赖多线程操作。后者可能比你想象的更难实现:大多数C库调用(如I/O何内存分配功能)执行底层线程同步。除非本地代码进行纯粹的计算并且不适用库调用,否则可能会间接使用线程原语。
大多数虚拟机实现为JNI本地代码,仅支持特定的线程模型。支持原生线程的虚拟机实现是最灵活的,因此本地线程,当可用时,应当是主机环境的首选。依赖于特性用户线程程序包的虚拟机实现可能严重受限于他们可以操作的本机代码的类型。
一些虚拟机实现可能支持许多不同的线程模型。更灵活的虚拟机实现类型甚至可以允许你为虚拟机内部使用提供自定义的线程模型实现,从而确保虚拟机实现可以和你的本地代码一同工作。在开始需要本地代码的项目前,你应该查看虚拟机实现附带的文档,以了解线程模型的限制。
8.2 编写国际化的代码
当编写在多个国家地区运行良好的代码时需要特别注意。JNI使程序员可以完全访问Java平台的国际化特性。我们使用字符装换作为例子,因为在许多语言环境中,文件名和信息可能包含许多非ASCII字符。
Java虚拟机使用Unicode格式来表示字符串。虽然一些本地平台(例如Windows NT)同样支持Unicode,但是还是使用特定于当地语言环境的编码来代表字符串。
不要使用GetStringUTFChars和GetStringUTFRegion函数在jstrings和特定于语言环境的字符串之间进行转换,除非UTF-8碰巧是平台的本地编码格式。当表示名称和描述符(例如GetMethodID的参数)时,UTF-8字符串非常有用,它将传递给JNI函数,但是却不适用于特定语言环境的字符串,如文件名。
8.2.1 从本地字符串中创建jstrings
使用String(byte[] bytes)构造器将一个本地字符串转换成jstring。接下来的辅助函数从一个本地语言环境编码的C字符串中创建一个jstring
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
jstring JNU_NewStringNative(JNIEnv *env, const char *str) { jstring result; jbyteArray bytes = 0; int len; if ((*env)->EnsureLocalCapacity(env, 2) < 0) { return NULL; /* out of memory error */ } len = strlen(str); bytes = (*env)->NewByteArray(env, len); if (bytes != NULL) { (*env)->SetByteArrayRegion(env, bytes, 0, len, (jbyte *)str); result = (*env)->NewObject(env, Class_java_lang_String, MID_String_init, bytes); (*env)->DeleteLocalRef(env, bytes); return result; } /* else fall through */ return NULL; } |
这个方法创建创建一个byte数组、复制本地C字符串到byte数组中并且最后调用String(byte[] bytes)的构造函数创建最后的jstring对象。Class_java_lang_String是一个指向java.lang.String的全局引用,MID_String_init是string构造函数的方法ID。因为这是要一个辅助函数,我们需要确保删除掉临时创建来保存字符的本地应用byte数组。
如果你需要在JDK版本1.1中使用此函数,需要删除对EnsureLocalCapacity的调用。
8.2.2 将jstrings转换成本地字符串
使用String.getBytes方法将一个jstring字符串转换成恰当的本地编码。下面的辅助函数将一个jstring字符串转换成本地环境特定的本地字符串:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
char *JNU_GetStringNativeChars(JNIEnv *env, jstring jstr) { jbyteArray bytes = 0; jthrowable exc; char *result = 0; if ((*env)->EnsureLocalCapacity(env, 2) < 0) { return 0; /* out of memory error */ } bytes = (*env)->CallObjectMethod(env, jstr, MID_String_getBytes); exc = (*env)->ExceptionOccurred(env); if (!exc) { jint len = (*env)->GetArrayLength(env, bytes); result = (char *)malloc(len + 1); if (result == 0) { JNU_ThrowByName(env, "java/lang/OutOfMemoryError", 0); (*env)->DeleteLocalRef(env, bytes); return 0; } (*env)->GetByteArrayRegion(env, bytes, 0, len, (jbyte *)result); result[len] = 0; /* NULL-terminate */ } else { (*env)->DeleteLocalRef(env, exc); } (*env)->DeleteLocalRef(env, bytes); return result; } |
这个方法将java.lang.String引用传递给String.getBytes方法,然后将byte数组的元素复制到一个新分配的C数组中。MID_String_getBytes是预先计算好的String.getbytes的方法ID。因为这是一个辅助函数,我们需要确保byte数组的本地引用被删除并且处理异常对象。需要记住的是,删除引用异常对象的JNI引用不会清除挂起的异常。
再次强调,如果你需要在JDK版本1.1中使用此函数,需要删除对EnsureLocalCapacity的调用。
8.3 注册本地方法
在一个应用程序执行一个本地方法前,它需要执行两个步骤来加载包含本地代码实现的本地库,然后再链接到本地方法实现。
- System.loadLibrary定位和加载命名的本地库。例如,在Win32系统中System.loadLibrary(“foo”)会是foo.dll被加载。
- 虚拟机会在加载的本地库中定位到本地方法实现。例如,一个Foo.g方法调用需要定位和链接到可能存在于foo.dll库中的本地方法Java_Foo_g。
这一节会介绍另一个方法来完成第二个步骤。JNI程序员能够使用一个类的引用、方法名字和方法描述符来注册方法指针的方法手动的连接本地库,而不是依赖虚拟机在已加载的本地库中查找本地方法:
1 2 3 4 5 6 |
JNINativeMethod nm; nm.name = "g"; /* method descriptor assigned to signature field */ nm.signature = "()V"; nm.fnPtr = g_impl; (*env)->RegisterNatives(env, cls, &nm, 1); |
上面的代码注册一个本地方法g_impl作为Foo.g方法的本地实现:
1 |
void JNICALL g_impl(JNIEnv *env, jobject self); |
本地方法g_impl不需要遵循JNI的命名规则,因为只涉及函数指针调用,也不需要从库中导出(因此不需要使用JNIEXPORT声明方法)。但是,本地g_impl方法仍然需要遵循JNICALL调用规则。
RegisterNatives方法有很多用途:
- 注册大量本地方法实现时更加方便和有效,而不是让虚拟机懒散的连接这些方法的入口点。
- 你可以在一个方法中多次调用RegisterNatives方法,允许本地方法实现在运行时被更新。
- 当一个本地应用程序需要嵌入一个虚拟机实现,并且需要链接到定义在该本地应用程序中的地方法时,RegisterNatives将非常有用。虚拟机不能够自动查找这个本地方法引用,因为它只能够在本地库中查找而不是应用程序中。
8.4 加载和卸载处理程序
加载和卸载处理程序允许本地库导出两个方法:其中一个当System.loadLibrary加载本地库是会被调用,另一个当虚拟机卸载本地库是会被调用。这个特性是在Java 2 SDK 1.2版本中加入的。
8.4.1 JNI_OnLoad处理程序
当System.loadlibrary加载一个本地库时,虚拟机会在本地库中查找下述的导出的程序入口:
1 |
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved); |
在一个JNI_OnLoad实现内,你可以调用任何的JNI方法。一个JNI_OnLoad处理程序的典型用法是缓冲JavaVM指针、类引用或者方法和字段ID,如下面展示的例子一样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
JavaVM *cached_jvm; jclass Class_C; jmethodID MID_C_g; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) { JNIEnv *env; jclass cls; cached_jvm = jvm; /* cache the JavaVM pointer */ if ((*jvm)->GetEnv(jvm, (void **)&env, JNI_VERSION_1_2)) { return JNI_ERR; /* JNI version not supported */ } cls = (*env)->FindClass(env, "C"); if (cls == NULL) { return JNI_ERR; } /* Use weak global ref to allow C class to be unloaded */ Class_C = (*env)->NewWeakGlobalRef(env, cls); if (Class_C == NULL) { return JNI_ERR; } /* Compute and cache the method ID */ MID_C_g = (*env)->GetMethodID(env, cls, "g", "()V"); if (MID_C_g == NULL) { return JNI_ERR; } return JNI_VERSION_1_2; } |
JNI_OnLoad方法首先缓存JavaVM指针在全局变量cached_jvm中。然后通过调用GetEnvironment获取JNIEnv指针。最后加载C类,或者类引用并计算出C.g的方法ID。JNI_OnLoad方法返回JNI_ERR(12.4节)作为错误指示,否则返回本地库需要的JNIEnv的版本号JNI_VERSION_1_2。
我们会在下一节中解释为什么我们在一个弱全局引用中缓存C类而不是在一个全局引用中缓存C类。
给定了一个缓存的JavaVM接口指针,那么实现一个允许本地代码获取当前线程的JNIEnv接口指针的辅助函数是非常简便的。
1 2 3 4 5 |
JNIEnv *JNU_GetEnv() { JNIEnv *env; (*cached_jvm)->GetEnv(cached_jvm, (void **)&env, JNI_VERSION_1_2); return env; } |
8.4.2 JNI_OnUnload处理程序
直观上,当虚拟机卸载一个本地库的时候,它会调用JNI_OnUnload处理程序。然而这还不够精确。虚拟机什么时候能够确认他可以卸载一个本地库?哪一个线程执行JNI_OnUnload处理程序?
卸载本地库的规则如下:
- 虚拟机将每一个本地库与发出System.loadLibrary调用的类C的类加载器L相关联
- 在虚拟机确认L加载器再也没有存活的对象时,它将调用JNI_OnUnload处理程序并卸载本地库。因为类加载器引用了它所定义的所有类,所以这意味着C也可以被卸载。
- JNI_OnUnload处理程序在终结器中运行,可以有java.lang.runFinalization同步调用,也可以由虚拟机异步调用。
下面是一个JNI_OnUnload处理程序的定义,它清楚了上一节JNI_OnLoad处理程序申请的资源
1 2 3 4 5 6 7 8 9 |
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *jvm, void *reserved) { JNIEnv *env; if ((*jvm)->GetEnv(jvm, (void **)&env, JNI_VERSION_1_2)) { return; } (*env)->DeleteWeakGlobalRef(env, Class_C); return; } |
JNI_OnUnload方法删除在JNI_OnLoad处理程序中创建的C类的全局弱引用。我们不需要删除MID_C_g的方法ID,因为虚拟机在卸载其定义的C类时会自动回收表示C类的方法ID所占用的资源。
现在我们准备解释为什么我们在一个弱全局引用中缓存C类,而不是在全局引用中缓存C类。一个全局引用会一直保持C是存活的,这样同时也会保证C类加载器存活。给定的本地库是和C类的加载器L相关联的,所以本地库将不会被卸载而且JNI_OnUnload将不会被调用。
JNI_UnLoad处理程序在终结器中运行。相反JNI_OnLoad处理程序在启动System.loadLibrary调用的线程中运行。因为JNI_OnUnload运行在一个未知的线程上下文中,为了避免可能的死锁,你在JNI_OnUnload中应该避免复杂的同步和加锁操作。JNI_OnUnload通常执行简单的任务,例如释放本地库申请的资源。当加载库的类加载器并且由该类加载器定义的类不再存活时,JNI_OnUnload处理程序就会运行。JNI_OnUnload处理程序将不得以任何方法使用这些类。在上面的JNI_OnUnload定义中,你不得执行任何假定Class_C仍引用有效类的操作。示例中的DeleteWeakGlobalRef调用为弱全局引用本身释放内存,但是不以任何方式操作类C。
总之,当调用JNI_OnUnload处理程序的时候,你应该非常消息,避免复杂的锁定操作引起死锁。记住,当JNI_OnUnload处理程序被调用时,类已经被卸载了。
8.5 反射支持
反射通常是指运行时操作语言级别的结构。例如,反射机制能够让你在运行时去发现任意类对象的名字以及定义在类中的一些列的字段和方法。Java编程语言通过java.lang.reflect包以及java.lang.Object和java.lang.Class类中的一些方法来支持反射机制。尽管你能够经常调用相应的Java API来进行反射操作,JNI提供以下方法,使本地代码频繁的反射操作更加高效和方便:
- GetSuperClass返回一个给定类引用的超类。
- IsAssignableFrom用于检查在另一个类的实例产生预期效果时,一个类的实例是否可以使用。
- GetObjectClass返回给定对象引用的类
- IsInstanceOf检查一个jobject引用是否是一个给定类的实例。
- FromReflectedField和ToReflectedField允许本地代码在字段ID和jva.lang.reflect.Field对象之间转换。这个是在Java 2 SDK 1.2版本上新添加的。
- FromReflectedMethod和ToReflectedMethod允许本地代码在方法ID、java.lang.reflect.Method对象和java.lang.reflect.Constructor对象之间转换。这个是在Java 2 SDK 1.2版本上新添加的。
8.6 使用C++进行JNI编程
JNI为C++程序员提供了一个稍微简单的接口,jni.h文件中包含一组定义,以便C++程序员编写,例如:
1 |
jclass cls = env->FindClass("java/lang/String"); |
而在C语言中:
1 |
jclass cls = (*env)->FindClass(env, "java/lang/String"); |
env上间接寻址的额外级别以及FindClass的env参数对程序员是隐藏的。C++编译器将C++成员函数内联到相应的C对象上,结果代码是完全一样的。在C和C++中使用JNI没有内在的性能差异。另外,jni.h文件还定义了一组虚拟的C++类来强制不同的jobject子类型之间的子类型关系:
1 2 3 4 5 6 7 8 9 |
// JNI reference types defined in C++ class _jobject {}; class _jclass : public _jobject {}; class _jstring : public _jobject {}; ... typedef _jobject* jobject; typedef _jclass* jclass; typedef _jstring* jstring; ... |
C++编译器能够在编译时检查是否通过,例如将一个jobject传递到GetMethodID中:
1 2 3 |
// ERROR: pass jobject as a jclass: jobject obj = env->NewObject(...); jmethodID mid = env->GetMethodID(obj, "foo", "()V"); |
因为GetMethodID期望一个jclass引用,C++编译器会给出错误的信息。在JNI的C类型定义中,jclass和jobject是等同的:
1 |
typedef jobject jclass; |
因此,C编译器无法检测到您错误地传递了jobject而不是jclass。
&ems; C++中添加的类型层次有时需要额外的投射。在C中,你可以从一个字符串数组中获取一个字符串,并将结果赋给一个jstring:
1 |
jstring jstr = (*env)->GetObjectArrayElement(env, arr, i); |
但是在C++中,你需要插入一个显示的转换:
1 |
jstring jstr = (jstring)env->GetObjectArrayElement(arr, i); |