为了强调前几章介绍到的技术,本章重点讨论JNI程序员经常会犯的一些错误。这里提出的每一个错误在真正的项目中都出现过。
10.1 错误检查
最常犯的错误就是在编写原生代码的时候忘记了检查JNI函数是否发生错误/异常。不像Java编程语言那样有垃圾回收机制自动回收不再使用的对象,原生语言不提供这种机制。JNI也不依赖任何的原生异常机制(比如C++异常处理)。因此,程序员每一次调用可能产生异常的JNI函数之后,都要显式的做一次错误检查或异常检查,虽然并不是所有的JNI函数都会抛出异常,但是大部分都会。异常检查虽然很琐碎,但是是鲁棒健壮的程序必不可少的部分。
10.2 给JNI函数传递无效参数
JNI函数不会尝试检测无效参数并从这个错误中恢复。比如,对一个需要非NULL引用作为参数的JNI函数,如果你传递了NULL或(jobject)0xFFFFFFFF,那么这个行为的结果是未知的,在实际情况中,要么造成无效的输出,要么导致虚拟机崩溃。Java 2 SDK 1.2提供了命令行参数-Xcheck:jni,这个参数会让虚拟机检测并报告很多(虽然不是全部)的JNI函数中的无效参数。检测参数的有效性会导致非常明显的系统过载,因此默认会禁止使用。
在C于C++标准库中,函数不检查其参数的有效性也是通常的做法,调用这些库函数的代码负责保证调用时参数有效。因此,使用JNI的时候,务必保证参数有效。
10.3 混淆了jclass与jobject
第一次使用JNI的时候,容易把对象引用(jobject类型的值)与类引用(jclass类型的值)混为一谈。
对象引用是java.lang.Object类与其子类的对象或包含其对象的数组的引用。而类引用则是java.lang.Class类对象的引用,是一种类类型。
使用jclass类型作为参数的JNI函数,如GetFieldID()函数,是一种类型操作。因为这类函数从类中获得其成员变量的ID。相反,使用jobject类型作为参数的JNI函数,如GetIntField()函数,是一种对象操作。因为这类函数从实例化的类(也就是类的对象)中获取成员变量的值。
因此,jobject与jclass类型是有很大区别的。
10.4 截断jboolean参数
jboolean是一个8比特无符号C类型,可以存储的值为0到255。0表示JNI_FALSE,从1到255表示JNI_TRUE。但是,对于32比特或16比特类型的变量,如果它的值超过了255,并且低8为均为0的话,就会产生问题。
假如,有一个已定义的print()函数接受一个jboolean类型的condition变量作为参数:
void print(jboolean condition)
{
/ 如果condition不是jboolean类型,那么编译器会产生代码截断它,只保留其低八位 /
if (condition) {
printf(“truen”);
} else {
printf(“falsen”);
}
}
如果在传递参数的时候类型为jboolean,那么程序就能正常运行。如果传递的是jint或jlong等类型,就可能出现问题。比如:
int n = 256; / 256的二进制是0x100,它的低8为全是0 /
print(n);
我们调用print()的时候,传递的是非0值(即原本向表达JNI_TRUE),可是由于256是jint类型,会截断,只保留其低八位,而256的低八位全为0,所以实际会被认为传递的参数值是JNI_FALSE。可以采用如下的方法解决这个问题:
n = 256;
print (n ? JNI_TRUE : JNI_FALSE);
10.5 Java应用和原生代码之间的界限
通常,开发一个支持原生代码的Java应用时,都会有一个问题:哪部分代码可以放在原生代码中实现?这个问题根据应用程序的特点而定,但在设计应用的时候,Java代码与原生代码之间的界限可以参考以下的设计原则:
1.尽可能的保持界限简单明了。如果执行流是在Java和原生代码不断的跳来跳去,那么调试和管理就比较麻烦,而且容易出BUG。这种执行流也会阻碍虚拟机对原生代码的性能优化,比如对Java虚拟机来说,使用Java语言定义的方法更容易使用inline来处理,而对于C和C++定义的代码用inline来处理就比较麻烦。
2.在原生代码部分的代码尽可能的小。原生代码既不是类型安全的,也不是可移植的。在原生代码中进行错误检查也非常的枯燥麻烦。所以,好的软件工程会使这部分代码最小化。
3.使原生代码与Java应用解耦。在实际情况中,这意味着所有的原生函数都被封装到一个包或一个类中,与应用的其他部分解耦。这样一来,包含原生函数的包和类就成为了应用程序部分的移植层。
JNI提供了访问虚拟机功能的函数,比如加载类、创建对象、访问类成员、调用Java方法、线程同步等等。所以,有时候很容易在原生代码中大量使用这些功能与Java虚拟机进行复杂的交互,然而,这些任务可以在Java中轻易的完成。下面来举一个例子:
比如,用Java创建线程:
new JobThread().start();
相同功能的原生代码为:
/ 假定下面的变量是提前缓存好了的: Class_JobThread: JobThread类
MID_Thread_init: 构造函数ID MID_Thread_start: Thread.start()方法的ID
/
aThreadObject =(env)->NewObject(env, Class_JobThread, MID_Thread_init);
if (aThreadObject == NULL) {
… / out of memory /
}
(env)->CallVoidMethod(env, aThreadObject, MID_Thread_start);
if ((env)->ExceptionOccurred(env)) {
… / thread did not start /
}
上述的原生代码与Java代码实现相同的功能,原生代码明显比较复杂,况且,原生代码中我们还省略了错误和异常处理的代码。
与其在原生代码中编写复杂的代码块来管理Java虚拟机,倒不如在Java中创建一个辅助函数来做相同的任务,使用JNI回调Java的方法来回调这个辅助函数。
10.6 混淆ID与引用
JNI使用引用来绑定对象。类、数组、字符串都是特殊的对象,所以都可以用引用来指向它们。JNI使用ID来绑定类成员方法和类成员变量,ID并不是引用。
引用是Java虚拟机中的资源,可以被原生代码显式的管理,比如DeleteLocalRef()可以释放引用资源,NewGlobalRef()创建引用资源。相反,类成员变量ID和类方法ID只在定义它们的类被虚拟机卸载之前有效,并且只被虚拟机管理。在虚拟机卸载一个类之前,原生代码不能显式的创建ID和删除ID。
原生代码可能对同一个对象创建多个引用,比如一个局部引用和一个全局引用都指向同一个对象。相反,唯一的ID在类的继承过程中保持不变。比如类A定义了f()方法,类B继承类A的f()方法,那么下面两条语句的结果返回相同的ID:
jmethodID MID_A_f = (env)->GetMethodID(env, A, “f”, “()V”);
jmethodID MID_B_f = (env)->GetMethodID(env, B, “f”, “()V”);
所以,同一个方法或变量,在一个继承层次中始终只拥有一个ID。
10.7 缓存成员变量和成员方法的ID
原生代码把成员变量和成员方法的名称和类型签名作为字符串传递给虚拟机,从而获取到Java类的成员方法和成员变量的ID。然而,使用变量和方法的名称与类型签名在类中遍历查找是比较慢的,所以通常,我们第一次查找到之后,就把这些ID缓存起来,以提高后续使用的效率。如果忘记了缓存,那对性能会有影响。
在某些案例中,缓存ID不仅仅在性能方面获益。缓存了的ID能够保证原生代码访问了正确的成员变量和方法。而下面的例子就演示了缓存ID失败可能导致的细小的BUG:
class C {
private int i;
native void f();
}
假定原生函数f()要获取类C的一个对象的成员变量i的值。如果不缓存ID,那么实现分为三步:获取对象的类引用,从类引用中遍历查找i的ID,使用对象引用和变量ID获取i的值:
// 不缓存变量ID的实现方式
JNIEXPORT void JNICALL Java_C_f(JNIEnv env, jobject this) {
jclass cls = (env)->GetObjectClass(env, this);
… / 检查错误和异常 /
jfieldID fid = (env)->GetFieldID(env, cls, “i”, “I”);
… /检查错误和异常/
ival = (env)->GetIntField(env, this, fid);
… / ival的值就是this.i的值 /
}
在上面的案例中,获取成员变量i的值没有任何问题。然而,当我们定义另一个类D,它继承自类C。因为C.i是私有变量,不会被继承,所以不存在变量D.i;而C.f()是protected,能够被继承。我们在类D中同样定义一个私有变量i:
// 未缓存ID会导致BUG
class D extends C {
private int i;//i是D独有的,不是继承的
D() {
f(); // f()是继承自C
}
}
此时,如果创建类D的对象,那么类D的构造函数会调用C.f(),此时C.f()的参数是类D的对象的引用,因此它会首先获取类D的引用,再获取D.i的ID,最后ival的值为D.i的值,而不是我们期望的C.i的值。因为f()函数最初是被定义在类C中的,我们期望获取类C的变量值,而不是其他类的变量的值。
解决方案就是,在你确定获取到类C的引用(不是类D的引用)时就计算并缓存这个类中变量为i的ID。后续访问这个ID,都会获得C.i而不是D.i。下面是正确的版本:
// 在静态初始化上下文中缓存变量ID
class C {
private int i;
native void f();
private static native void initIDs();
static {
initIDs(); // Call an initializing native method
}
}
相应的原生代码修改为:
static jfieldID FID_C_i;
JNIEXPORT void JNICALL Java_C_initIDs(JNIEnv env, jclass cls) {
/ Get IDs to all fields/methods of C that
native methods will need. /
FID_C_i = (env)->GetFieldID(env, cls, “i”, “I”);
}
JNIEXPORT void JNICALL Java_C_f(JNIEnv env, jobject this) {
ival = (env)->GetIntField(env, this, FID_C_i);
… / 通过缓存了的ID访问变量,始终得到C.i的值 /
}
变量C.i的ID在原生代码中的静态上下文中首先计算并缓存起来,因此,后续调用f()函数,直接使用这个静态全局变量,而与对象引用和类引用无关了。
上面的例子同样适用于缓存成员方法ID。
10.8 在Unicode编码字符串末尾使用结束符
使用JNI函数GetStringChars()和GetStringCritical()获得的Unicode编码字符串是不以NULL结尾的。想要获得16比特Unicode编码的字符串,可以使用JNI函数GetStringLength()。
10.9 违反访问控制规则
在Java编程预言中使用访问标号(public, private)可以控制类、成员变量和成员方法的访问控制,但是JNI却不会有访问控制。在Java中访问可能会引起IllegalAccessException异常的操作,在JNI中则可能是正常的。这种“宽容”可能是有意为之,不管怎么说,在JNI中可以随意的访问和修改堆中的数据。
在原生代码中的这种无约束的访问可能会造成意想不到的后果。比如,JIT(Just In Time)编译器访问了final成员变量之后,原生代码却修改了它,造成前后不一致。同样的,原生代码也不应该修改类不变量(比如java.lang.String类与java.lang.Integer类的对象的值,这些值是不允许改变的)。
10.10 无视国际化编码
Java平台的字符串编码为Unicode,而原生代码中的字符串很可能是本地编码的。可以使用第8章提到的国际化编码中实现的工具函数JNU_NewStringNative()和JNU_GetStringNativeChars()在jstring对象与宿主系统支持的本地编码的字符串之间进行转换。特别注意消息字符串与文件名,因为它们通常是国际化编码的。
10.11 一直占用虚拟机资源
在原生代码中一个通常会忘记的事情就是释放虚拟机资源。这种忘记大多是在原生代码中有多个返回点的时候,比如出错返回,某个条件返回等等,在返回之前没有释放资源。
因此,程序员务必注意,在编写JNI程序的时候,在原生函数的每一个返回点之前务必要释放资源,比如删除局部、全局或弱全局引用。
10.12 过度创建局部引用
创建过多的局部引用会造成内存资源浪费。而且,一个不再使用的局部引用不仅仅是它所指向的对象一直占用内存资源,局部引用本身也会占用资源。特别注意要长时间运行的原生函数、工具函数和在循环中创建的局部引用。最好使用Java 2 SDK 1.2提供的Push/PopLocalFrame()函数对来更有效的管理局部引用,详细的内容请参考第5章。
在Java 2 SDK 1.2中可以使用命令行参数-verbose:jni选项让虚拟机来检查和报告过度创建局部引用的原生部分。比如,输入的命令为:
% java -verbose:jni Foo
那么,如果过度创建局部引用的话,输出可能为:
***ALERT: JNI local ref creation exceeded capacity
(creating: 17, limit: 16).
at Baz.g (Native method)
at Bar.f (Compiled method)
at Foo.main (Compiled method)
因此,可以看到原生函数Baz.g()没有进行合适的管理局部引用。
10.13 使用无效的局部引用
局部引用只会在一次原生函数调用过程中生效。一旦该函数返回,在函数内创建的局部引用就无效了(因为会被虚拟机自动释放回收)。因此,不应该缓存局部引用(包括用全局变量或静态变量),期望下一次调用时继续使用该局部引用。而且,局部引用也仅仅在创建它的那个线程中有效,不应该在多个线程间传递局部引用。当必须要这么做时,可以将局部引用转换为全局引用(使用NewGlobalRef()函数)。
10.14 在多个线程间使用同一个JNIEnv指针
JNIEnv指针也仅在单个线程内有效,不同线程中JNIEnv指针的值不一样。所以,不应该缓存JNIEnv指针。第8章介绍了如何获取当前线程的JNIEnv指针。
10.15 不兼容的线程模型
在前面章节中也提到过,如果在原生代码中有依赖线程的部分,那么原生代码依赖的线程模型要与虚拟机中支持的线程模型相同或兼容。比如在Linux上编写的原生代码依赖POSIX线程模型(pthread线程库),如果Java虚拟机不支持这种线程模型的话,那这个原生代码生成的原生库就不能在该虚拟机中运行。