本章篇幅较长,需要多天完成。下面部分是05/27/15完成的。
本章主要讨论JNI的其他特性。
8.1 JNI和线程
Java虚拟机支持在同一个地址空间中控制多个线程并发执行。多线程比单线程有多大的复杂度,比如多个线程可能会同时访问一个对象,需要进行多线程的同步与互斥。在继续下面的讨论之前,你必须要懂Java多线程编程,懂得同步与互斥。
8.1.1 多线程的约束
编写多线程原生代码时时刻要牢记下面的一些约束。只有理解了这些约束并灵活运用,你才能写出线程安全的原生函数,比如:
1.JNIEnv指针只在当前线程有效。不能把指针缓存到静态变量或全局变量,也不能把指针传递到另一个线程的函数中。Java虚拟机对同一个线程的多次不同的调用传递一样的JNIEnv指针,但对不同的线程中的函数调用传递不同的JNIEnv指针。
2.局部引用也只在当前线程有效。不能把局部引用传递到其他的线程中,如果需要传递局部引用指向的对象,必须记得将局部引用转换为全局引用。
8.1.2 使用Java同步监视器
Java多线程同步监视器是Java多线程编程中基本的同步机制。每个对象都能动态的与一个监视器相关联。JNI提供了一些函数允许原生代码使用这种同步机制。
在Java中使用以下语句块来进行同步:
synchronized (obj) {
… // 临界区代码
}
Java中上述语句块保证一个线程在进入临界区代码之前必须要获取与obj对象相关的监视器。这就保证了在同一时间只有一个线程可以获取监视器并执行临界区代码,其他的线程必须等这个线程释放了监视器后才能获取,在这之前,其他线程必须在上述语句块外等待。
在JNI中,MonitorEnter()函数用于获取监视器,MonitorExit()函数用于释放监视器。使用这两个JNI函数可以在原生代码中实现与上述语句块相同功能的同步块:
if ((env)->MonitorEnter(env, obj) != JNI_OK) {
… / 错误处理代码 /
}
… / 临界区代码 /
if ((env)->MonitorExit(env, obj) != JNI_OK) {
… / 错误处理代码 /
}
当底层的原生线程出错时(如不能分配足够的内存),MonitorEnter()函数与MonitorExit()函数就会出错并抛出IllegalMonitorStateException异常。
注意,MonitorEnter()函数在临界区代码之前调用,而在每一个可能导致线程退出的地方都要调用MonitorExit()函数,否则就会导致死锁——当前线程退出之前没有释放监视器,其他线程均处于等待状态,临界区代码就无法被执行了。比如:
if ((env)->MonitorEnter(env, obj) != JNI_OK) …;
… //临界区代码
if ((env)->ExceptionOccurred(env)) {
… / 异常处理代码 /
/ 记得在这个出口调用MonitorExit() /
if ((env)->MonitorExit(env, obj) != JNI_OK) …;
}
… / 正常执行路径
if ((env)->MonitorExit(env, obj) != JNI_OK) …;
使用同步监视器进行多线程同步是非常方便的,因此建议使用这种方式进行多线程同步,而不是系统支持的方式(如信号量、互斥锁)。注意,如果是静态原生函数需要获取监视器,那么必须定义一个静态的原生同步块供其使用,而不是上述的普通的原生同步块。
## 8.1.3 监视器等待与通知
除了8.1.2节中提到的MonitorExit()与MonitorEnter()对应的同步机制外,Java API还提供了另外的同步:object.wait, object.notify, object.notifyAll。这三种同步方法没有直接对应的JNI函数,因此我们需要使用JNI调用函数的机制来调用这些Java API:
/ 下面三个jmethodID是提前计算好了的 /
static jmethodID MID_Object_wait; //object.wait
static jmethodID MID_Object_notify; //object.notify
static jmethodID MID_Object_notifyAll; //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在其他地方已经被计算好了,并且缓存在了全局变量中,这里直接使用。当我们获取到了监视器的时候,可以调用上面的三个工具函数进行等待和通知。
## 8.1.4 在任意上下文中获取JNIEnv指针
JNIEnv指针只在当前线程有效,原生代码通常可以从Java虚拟机中获得当前线程的JNIEnv作为其函数的第一个参数。但有时原生函数并不是直接由虚拟机调用,此时就无法获得当前线程的JNIEnv指针了,比如在原生代码中,有一个函数是用于被操作系统回调用的(当出现了某种情况,操作系统会调用这个原生函数),因此这个函数就无法获得当前线程的JNIEnv指针了。
可以通过Java虚拟机的调用接口的AttachCurrentThread()函数获取当前线程的JNIEnv指针:
JavaVM jvm; / 已经在别处计算好了,创建了Java虚拟机 /
void f()
{
JNIEnv env;
//调用AttachCurrentThread()函数获取JNIEnv指针
(jvm)->AttachCurrentThread(jvm, (void *)&env, NULL);
… / 使用JNIEnv指针的代码 /
}
当当前线程被绑定到了虚拟机上时,AttachCurrentThread()就返回当前线程的JNIEnv指针。
有很多种方法获取JavaVM指针:当Java虚拟机被创建(JNI_CreateJavaVM())的时候就缓存其指针;当虚拟机已经创建后,使用JNI_GetCreatedJavaVMs()方法获取其指针;在原生函数中调用JNI的GetJavaVM()函数;创建一个JNI_OnLoad句柄保存。不像JNIEnv指针,JavaVM指针在多个线程间均有效,因此可以将其缓存到全局变量中。
JDK 1.2提供了新的方法获取JNIEnv指针:GetEnv()。因此可以使用这个函数来检查当前线程是否已经绑定到了虚拟机,如果已经绑定了,就返回当前线程的JNIEnv指针。如果当前线程已经绑定了虚拟机,那么GetEnv()与AttachCurrentThread()方法是等效的。
## 8.1.5 匹配线程模型
假定一个运行在多线程上得原生代码要访问一个全局资源,必然会引入多线程同步互斥。那么这个原生代码是调用JNI函数MonitorEnter()与MonitorExit()呢还是使用操作系统提供的原生线程同步互斥(如mutex_lock()与mutex_unlock())呢?同样的,当原生代码需要创建线程的时候,是应该创建一个java.lang.Thread对象然后使用JNI回调Thread.start()方法呢,还是应该使用操作系统提供的本地线程API(如pthread_create())呢?
答案就是:如果Java虚拟机支持原生代码中使用的线程模型的话,那上述的方法就可以。
线程模型是指操作系统如何在系统调用中实现重要的线程操作,比如线程调度、上下文切换、同步和阻塞等等。在本地线程模型(操作系统提供的线程模型)中操作系统管理所有的线程操作。在用户线程模型中,应用程序代码就必须实现线程操作。比如,Linux版本的JDK和Java 2 SDK中的”Green Thread”线程模型使用ANSI C的函数setjmp()和longjmp()实现上下文切换。
如果使用Java语言编写多线程应用,那么就不用关系Java虚拟机底层的线程模型。Java平台可以在任何提供了某种线程模型的操作系统上工作。大部分的本地和第三方线程包提供了实现Java虚拟机线程模型的基本操作。
然后,编写JNI程序就必须额外注意线程模型了。当Java虚拟机使用的线程模型与原生代码中使用的线程模型和同步机制不匹配时,原生函数就可能无法正常工作。比如,当原生代码在它使用的线程模型中被阻塞了,但是使用另一种线程模型的Java虚拟机却不知道它阻塞了。这样一来,其他线程就无法被调度,应用程序就会陷入死锁。
Java虚拟机如果使用本地线程模型,那么编写JNI就非常方便灵活。如果虚拟机使用第三方的线程模型,那么JNI程序要么不使用线程模型,要么链接第三方线程模型库。
你的Java虚拟机到底使用什么线程模型,最好参考一下它的说明文件。
下面的部分是05/28/15完成的
# 8.2 编写国际化代码
当编写的原生应用要在不同地域使用时,就需要特别注意了。JNI提供了很多的方法获取Java平台的国际化特性。下面以字符串转换为例介绍如何写出国际化的代码,因为在一些地区文件名或文字信息中含有非ASCII字符。
Java虚拟机使用Unicode编码字符串。尽管某些操作系统(比如Windows NT)也支持Unicode,但是大部分系统都只支持本地区的字符串编码。
如果系统使用的不是UTF-8编码的字符串,那么就不能使用GetStringUTFChars()与GetStringRegion()函数从jstring变为本地字符串。
## 8.2.1 用本地字符串创建jstring
使用String类的构造函数String(byte[] bytes)把本地字符串转换为jstring。下面的工具函数把本地的C字符串转换为了jstring对象:
//第二个参数是C字符串
jstring JNU_NewStringNative(JNIEnv env, const char str)
{
jstring result;
jbyteArray bytes = 0;
int len;
//申请创建2个局部引用
if ((env)->EnsureLocalCapacity(env, 2) < 0) {
return NULL; / 内存用完了 /
}
len = strlen(str);
//创建byte[]数组,存储C字符串
bytes = (env)->NewByteArray(env, len);
if (bytes != NULL) {
//存储C字符串到byte[]数组
(env)->SetByteArrayRegion(env, bytes, 0, len, (jbyte )str);
//使用String(byte[] bytes)构造函数创建jstring对象
result = (env)->NewObject(env, Class_java_lang_String, MID_String_init, bytes);
//在工具函数中记得删除不再使用的局部引用
(env)->DeleteLocalRef(env, bytes);
return result;
}
return NULL;
}
在上述的工具函数中,Class_java_lang_String是java.lang.Stirng类的全局引用,MID_String_init是String(byte[] bytes)构造函数的方法ID,也是全局变量。
## 8.2.2 把jstring转换为本地字符串
使用String.getBytes()方法将jstring对象的值转换为本地字符串,下面的工具函数完成这个功能:
char JNU_GetStringNativeChars(JNIEnv env, jstring jstr)
{
jbyteArray bytes = 0;
jthrowable exc;
char result = 0;
//申请创建2个局部引用
if ((env)->EnsureLocalCapacity(env, 2) < 0) {
return 0; / 内存使用完了 /
}
//调用String.getBytes()方法,MID_String_getBytes是其方法ID,全局变量。
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;
}
//把bytes的内容拷贝到分配的内存中
(env)->GetByteArrayRegion(env, bytes, 0, len, (jbyte )result);
result[len] = ‘ ’; //字符串以’ ’结尾
} else { //抛出了异常
(env)->DeleteLocalRef(env, exc);//删除不再需要的引用
}
(env)->DeleteLocalRef(env, bytes); //删除不再需要的引用
return result;
}
8.3 注册原生函数
在应用程序执行原生函数之前,它进行下面两步完成加载包含原生函数实现的原生库并且链接这个库中的原生函数实现代码:
1.System.loadLibrary()函数查找并加载指定名字的原生库:
System.loadLibrary(“example”); //加载libexample.so
2.Java虚拟机在加载的一个或多个原生库中定位并链接原生函数的实现。比如调用Example.f()函数就必须定位并且链接原生函数Java_Example_f(),这个函数可能在已经加载的libexample.so库中。
这一节将介绍另一种方法完成上述的第二步。除了依赖Java虚拟机在加载的库中定位并链接原生函数,JNI程序员可以在原生代码中使用类引用、函数名和函数签名来注册一个函数指针,今儿手动链接这个原生函数。如:
//下面注册void JNICALL g_impl(JNIEnv env, jobject obj);
JNINativeMethod nm;
nm.name = “g”;
/ 给定方法签名 /
nm.signature = “()V”;
nm.fnPtr = g_impl;
//注册这个函数指针
(env)->RegisterNatives(env, cls, &nm, 1);
原生函数g_impl()不需要遵循JNI的命名规范(因为只有函数指针被调用,名字用不到),也不用从原生库中导出符号(因此不需要JNIEXPORT前缀)。但它必须要遵守JNI的调用规范,因此必须要有JNICALL前缀。
RegisterNatives()有如下的作用:
1.有时快速注册大量的原生方法非常方便有效,不再依靠Java虚拟机较慢的定位链接函数。
2.可以在原生函数中多次调用RegisterNatives()函数,这样可以在运行时更新函数。
3.当原生应用嵌入了Java虚拟机并且想要链接一个在这个原生应用中定义的原生函数时,RegisterNatives()函数就特别有用。因为Java虚拟机只会在加载的库中定位链接函数,并不能在原生应用中定位链接。
# 8.4 加载和卸载句柄
加载和卸载句柄在Java 2 SDK 1.2被支持。它允许原生库导出两个函数:一个函数在System.loadLibrary()加载原生库的时候运行,另一个函数在虚拟机卸载原生库的时候运行。
## 8.4.1 JNI_OnLoad()句柄
当System.loadLibrary()函数加载原生库的时候,虚拟机就在该原生库中搜寻下面的导出入口:
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM jvm, void reserved);
在JNI_OnLoad()函数中可以调用任意的JNI函数。JNI_OnLoad()函数最常使用的功能是把JavaVM指针、类引用、方法ID和成员ID缓存到全局变量中:
//三个全局变量,用于缓存
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; / 缓存JavaVM指针到全局变量 /
if ((jvm)->GetEnv(jvm, (void **)&env, JNI_VERSION_1_2)) {
return JNI_ERR; / 不支持这个JNI版本 /
}
cls = (env)->FindClass(env, “C”);
if (cls == NULL) {
return JNI_ERR;
}
//使用弱全局引用缓存类C的引用,可以让类C在需要时被卸载
Class_C = (env)->NewWeakGlobalRef(env, cls);
if (Class_C == NULL) {
return JNI_ERR;
}
//计算并缓存void C.g()方法的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()函数要么返回JNI_ERR给原生库表示出错,要么返回JNI_VERSION_1_2给原生库表示成功。
下一小节我们会解释为什么要把类C的引用缓存在弱全局引用中,而不是全局引用中。
使用缓存的JavaVM指针,可以使用下面的工具函数获得当前线程的JNIEnv指针:
JNIEnv JNU_GetEnv()
{
JNIEnv env;
(*cached_jvm)->GetEnv(cached_jvm, (void **)&env, JNI_VERSION_1_2); return env;
}
下面的部分于05/29/15完成。
8.4.2 JNI_OnUnload()句柄
在虚拟机卸载原生库的时候会调用这个句柄。然而,这句话并不准确。虚拟机什么时候决定卸载一个函数库呢?哪个线程执行这个句柄呢?
卸载原生库的规则如下:
1.类C调用了System.loadLibrary()加载一个或多个原生库,Java虚拟机则根据类C的类加载器L绑定类C加载的原生库。
2.当Java虚拟机认为类C的类加载器L不再被使用时(no longer a live object),就是调用JNI_OnUnload()函数并卸载与类加载器L绑定的一个或多个原生库。
3.JNI_OnUnload()函数运行在finalizer中,要么是java.lang.System.runFinalization异步调用,要么被虚拟机异步调用。
下面的JNI_OnUnload()的例子清除掉8.4.1中JNI_OnLoad()函数申请的各种资源:
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM jvm, void reserved)
{
JNIEnv env;
if((jvm)->GetEnv(jam, (void *)&env, JNI_VERSION_1_2)) {
return;
}
(env)->DeleteWeakGlobalRef(env, Class_c);
return;
}
在JNI_OnUnload()函数中不必清除掉MID_C_g这个方法ID,因为在虚拟机卸载类C的时候就会自动回收资源。
下面解释一下为什么要把类C的引用放入若全局引用而不是全局引用。在讲解引用的时候提到过,全局引用会一直保持其指向的对象,使得垃圾回收机制无法自动回收,除非手动的调用DeleteGLobalRef()函数。而弱全局引用指向的对象在必要时允许垃圾回收机制回收。因此,如果使用全局引用指向类C,那么类C会一直保持活跃,那么类C的类加载器也会一直保持活跃,因此与类C的类加载器绑定的原生库就不会被卸载,那么JNI_OnUnload()函数永远不会被执行。
JNI_OnUnload()函数运行在finalizer中,与之相反,JNI_OnLoad()函数运行在执行System.loadLibrary()方法的线程中。因为JNI_OnUnload()运行在一个未知的上下文环境中,为了避免死锁,在这个函数中应该避免使用复杂的同步机制和互斥锁的操作,JNI_OnUnload()函数的典型应用就是释放掉JNI_OnLoad()函数中申请的各种资源。
JNI_OnUnload()函数是在类的类加载器不再使用时被调用,因此在这个函数中不应该使用这些类。比如在上面的JNI_OnUnload()例子中,不能假定Class_c指向的对象还存在。我们只是调用DeleteWeakGlobalRef()函数释放了Class_c弱全局引用,只是对引用本身进行操作,而没有对引用指向的对象做操作。
8.5 反射支持
反射指的是在运行时管理语言级的创建。比如说,反射允许代码在运行时发现任意类的对象的名称和这个类定义的成员变量和成员方法。Java语言的java.lang.reflect包和java.lang.Object与java.lang.Class类的某些方法提供了反射支持。虽然可以在原生代码中调用相应的方法执行反射操作,但是JNI提供了一些函数能更有效方便的完成这些功能。
1.GetSuperClass()函数返回给定类引用的父类。
2.IsAssignableFrom()函数检查两个类之间是否支持相互转换,即类A能不能转换为类B。
3.GetObjectClass()函数返回jobject对象的类引用。
4.IsInstanceOf()判断一个jobject对象是否是某个类的实例。
5.FromReflectedField() 和 ToReflectedField()函数允许原生代码在类成员变量ID和java.lang.reflect.Field对象之间进行转换。这两个函数在Java 2 SDK 1.2中提供。
6.FromReflectedMethod()和ToReflectedMethod()允许原生代码在类成员方法ID和java.lang.reflect.Method对象之间进行转换。这两个函数在Java 2 SDK 1.2中提供。
8.6 JNI对C++的支持
JNI提供了与C类似的C++接口。jni.h头文件中定义了一系列的函数可供C++程序员调用。
对同一个JNI函数,C++与C的区别如下:
C++:jclass cls = env->FindClass(“java/lang/String”);
C: jclass cls = (*env)->FindClass(env, “java/lang/String”);
C++编译器会把C中的JNI函数看做JNI类的成员方法进行inline调用,JNI函数的返回码都一样。JNI函数在C和C++中的性能没有任何区别。
另外,JNI在jni.h中定义了一系列的虚设的类用于在jobject的不同子类之间形成子类关系:
// 定义C++版本的JNI引用类型
class _jobject {};
class _jclass : public _jobject {};
class _jstring : public _jobject {};
…
typedef _jobject jobject;
typedef _jclass jclass;
typedef _jstring* jstring;
…
有了上面的子类定义,C++编译器会在编译时识别出一些错误:当参数需要jclass时,如果传递jobject会出错:
// ERROR: 把jobject当做jclass
jobject obj = env->NewObject(…);
jmethodID mid = env->GetMethodID(obj, “foo”, “()V”);
GetMethodID()函数需要一个jclass类型,而不是jobject类型。
然而,在C版本的JNI定义中,jclass与jobject类型是相同的:
typedef jobject jclass;
因此C编译器不会检测到上面的错误。
C++的这种类型等级定义在调用JNI函数的一些情况下需要做必要的强制转换。比如,在C的版本中,可以使用如下的语句从字符串数组中得到字符串并赋值给jstring类型变量:
jstring jstr = (*env)->GetObjectArrayElement(env, arr, i);
然而在C++中,则需要在赋值前进行强制转换:
jstring jstr = (jstring)env->GetObjectArrayElement(arr, i);
本章的主要内容是介绍JNI提供的其他特性:线程模型,国际化字符串,手动注册原生函数,加载和卸载句柄,反射支持和C++支持。