Jimmy Chen

A Programmer

(译文) JNI编程指南与规范 第十章 陷阱与缺陷

第十章 陷阱与缺陷

为了突显前面几章中介绍的重要技术,本章涵盖了JNI程序员长犯的一些错误。这里描述的每个错误都发生在现实世界的项目中。

10.1 错误检查

编写本地方法最常见的错误是忘记检查是否发生了错误情况。与Java编程语言不同,本地语言不提供标准的异常机制。JNI不依赖于任何特定的本地异常机制(例如C++异常)。因此,在执行每一个可能会引起异常的调用后,程序员都需要执行显式检查。并不是所有的JNI函数都会引起异常,但是大多数都可以检查。异常检测是单调的,但是为了确保使用本地方法的应用程序的健壮性,确实有必须的。

  错误检查的繁琐工作极大地强调了将本地代码限制在需要使用JNI的应用程序的那些定义良好的子集(第10.5节)的必要性。

10.2 传递错误的参数给JNI函数

JNI函数不会尝试检测或从无效参数中恢复。 如果将NULL或(jobject)0xFFFFFFFF传递给期望得到一个引用的JNI函数,则结果的行为是未定义的。实际上,这可能导致错误的结果或虚拟机崩溃。Java 2 SDK版本1.2为你提供了命令行选项-Xcheck:jni。此选项指示虚拟机检测并报告许多(尽管不是全部)本机代码将非法参数传递给JNI函数的情况。检查参数的有效性会导致大量开销,因此默认情况下不会启用。

  不检查参数的有效性是C和C++库中的常见做法。使用库的代码有责任确保传递给库函数的所有参数都是有效的。但是,如果你习惯了Java编程语言,则可能需要适应JNI编程中缺乏安全性检查的特定方面。

10.3 混淆jclass和jobject

第一次使用JNI时,实例引用(jobject类型的值)和类引用(jclass类型的值)之间的区别可能会引起混淆。

  实例引用对应于数组、java.lang.Object实例或其一个子类。 类引用对应于表示类类型的java.lang.Class实例。

  像GetFieldID这样的接受jclass的操作是一个类操作,因为它从类中获取字段描述符。相反,使用jobject的GetIntField是一个实例操作,因为它从一个实例中获取一个字段的值。jobject与实例操作以及jclass与类操作的关联在所有的JNI函数中是一致的,所以很容易记住类操作与实例操作是不同的。

10.4 阶段jboolean参数

jboolean是一个8位无符号C类型,可以存储0到255之间的值。值0对应于常量JNI_FALSE,1到255之间的值对应于JNI_TRUE。但是,32位或16位值大于255的而低8位为0的数会造成问题。

  假设你已经定义了一个函数print,它带有一个类型为jboolean的参数条件:

  以前的定义没有错。然而,一下合法的调用会产生一些意想不到的结果:

  我们传递一个非零值(256)来打印,期望它代表真实。但是因为除了低位8以外的其他所有位都被截断了,所以参数的计算结果为0.程序打印出“假”,与预期相反。

  将强制类型(如int)强制转换为jboolean时,一个好的经验法则是始终评估整型类型的条件,从而避免强制过程中的无意错误。您可以重写print方法如下:

10.5 Java应用程序与本地代码之间的界限

设计本地代码所支持的Java应用程序时,常见的问题是“本机代码中应该包含哪些和多少内容?”本机代码与以Java编程语言编写的其他应用程序之间的界限是特定于应用程序的, 但有一些普遍适用的原则:

  • 保持边界简单。 在Java虚拟机和本机代码之间来回复杂的控制流可能很难调试和维护。这种控制流程也阻碍了高性能虚拟机实现的优化。例如,虚拟机实现内联在Java编程语言中定义的方法要比在C和C ++中定义的内联本机方法容易得多。
  • 尽量减少本地代码的代码。这样做有令人信服的理由。本机代码既不可移植,也不是类型安全的。在本地代码中检查错误是很乏味的(§10.1)。将这些部分保持在最低限度将是很好的软件工程。
  • 保持本地代码隔离。实际上,这可能意味着所有本地方法都在同一个包中,或者在同一个类中,与应用程序的其余部分分离。包或者包含本地方法的类本质上成为应用程序的“移植层”。

  JNI提供对虚拟机功能的访问,如类加载,对象创建,字段访问,方法调用,线程同步等等。有时候用本地代码来进行与Java虚拟机功能的复杂交互很有诱惑力,事实上,在Java编程语言中完成相同的任务更简单。下面的例子说明了为什么“在本机代码中进行Java编程”是不好的做法。考虑一个简单的语句来创建一个用Java编程语言编写的新线程:

同样的代码可以用本地代码重写:

  尽管我们已经省略了错误检查所需的代码行,但是本机代码比用Java编程语言编写的代码要复杂得多。

  而不是编写一个复杂的操作Java虚拟机的本地代码片段,通常最好是用Java编程语言定义一个辅助方法,并让本机代码向辅助方法发出回调。

10.6 混淆ID和引用

JNI将对象作为引用。类,字符串和数组是特殊类型的引用。JNI将方法和字段作为ID。一个ID不是一个参考。不要将类引用称为“类ID”,也不要将方法ID称为“方法引用”。

  引用是可以由本地代码显式管理的虚拟机资源。例如,JNI函数DeleteLocalRef允许本地代码删除本地引用。相比之下,字段和方法ID由虚拟机管理并保持有效直到其定义的类被卸载。在虚拟机卸载定义的类之前,本机代码不能显式删除字段或方法ID。

  本机代码可能会创建多个引用同一个对象的引用。例如,全局引用和本地引用可能指的是同一个对象。 相反,为字段或方法的相同定义仅导出唯一的字段或方法ID。如果A类定义方法f,B类从A继承f,则以下代码中的两个GetMethodID调用总是返回相同的结果:

10.7 缓存字段和方法ID

本地代码通过将字段或方法的名称和类型描述符指定为字符串(4.1节,4.2节)从虚拟机获取字段或方法ID。使用名称和类型字符串的字段和方法查找速度很慢。缓存这些ID通常是有利的。未能缓存字段和方法ID是本机代码中的常见性能问题。

  在某些情况下,缓存ID不仅仅是一个性能增益。缓存的ID可能是必要的,以确保本地代码访问正确的字段或方法。以下示例说明了缓存字段ID失败可能导致一个微妙的错误:

  假设本地方法f需要在C的一个实例中获得字段i的值。一个不缓存一个ID的简单实现通过三个步骤完成:1)获得对象的类; 2)从类参考中查找i的字段ID; 和3)根据对象引用和字段ID访问字段值:

  代码能够工作正常,直到我们将另一个类D定义为C的子类,并在D中也声明一个称为“i”的私有字段

  当D的构造函数调用C.f时,本地方法接收D的一个实例作为此参数,cls指向D类,而fid代表D.i. 在本地方法结束时,ival包含D.i的值,而不是C.i. 这可能不是你所期望的当实施本地方法C.f.

解决方法是在确定对C具有类引用时,计算并缓存字段ID,而不是D.随后从此缓存ID进行的访问将始终引用正确字段C.i. 这里是正确的版本:

修改后的本地代码是:

  字段ID被计算并缓存在C的静态初始化器中。 这保证了C.i的字段ID将被缓存,因此本地方法实现Java_C_f将读取C.i的值,而不依赖于该对象的实际类。

  一些方法调用也可能需要缓存。如果我们稍微改变上面的例子,以便类C和D每个都有自己的私有方法g的定义,则f需要缓存C.g的方法ID,以避免不小心调用D.g。进行正确的虚拟方法调用不需要缓存。虚拟方法根据定义动态绑定到调用该方法的实例。因此,您可以安全地使用JNU_CallMethodByName实用程序函数(第6.2.3节)来调用虚拟方法。前面的例子告诉我们,为什么我们不定义类似的JNU_GetFieldByName工具函数。

10.8 终止Unicode字符串

从GetStringChars或GetStringCritical获取的Unicode字符串不是以NULL结尾的。调用GetStringLength查找字符串中的16位Unicode字符数。某些操作系统(如Windows NT)期望两个尾随的零字节值来终止Unicode字符串。你不能将GetStringChars的结果传递给需要Unicode字符串的Windows NT API。你必须复制字符串的另一个副本,并插入两个尾随的零字节值。

10.9 违反访问控制规则

JNI不执行类,字段和方法的访问控制限制,这些限制可以通过使用诸如private和final之类的修饰符在Java编程语言中进行表达。可以编写本地代码来访问或修改对象的字段,即使在Java编程语言级别这样做会导致IllegalAccessException。由于本机代码可以访问和修改堆中的任何内存位置,所以JNI的宽容性是一个有意识的设计决定。

  绕过源语言级访问检查的本地代码可能会对程序执行产生不良影响。例如,如果本地方法在即时(JIT)编译器内联访问该字段后修改最终字段,则可能会产生不一致。同样,本地方法不应该修改java.lang.String或java.lang.Integer实例中的不可变对象,如字段。这样做可能会导致Java平台实现中不变量的破坏。

10.10 无视国际化

Java虚拟机中的字符串由Unicode字符组成,而本地字符串通常以特定于语言环境的编码形式存在。使用辅助函数(如JNU_NewStringNative(8.2.1节)和JNU_GetStringNativeChars(8.2.2节))在Unicode字符串和基于主机环境的特定语言环境的本机字符串之间进行转换。特别注意消息字符串和文件名,他们通常是国际化的。如果本地方法获取文件名作为jstring,那么文件名必须在传递给C库例程之前转换为本地字符串。

  以下本机方法MyFile.open打开一个文件并返回文件描述符作为其结果:

  我们使用JNU_GetStringNativeChars函数来转换jstring参数,因为开放系统调用期望文件名是特定于语言环境的编码。

10.11 保留虚拟机资源

本地方法中常见的错误是忘记释放虚拟机资源。程序员需要特比注意那些只有发生错误了才会执行的代码路径。下面的代码段(6.2.2节中的一个例子稍作修改)会遗漏一个ReleaseStringChars调用:

  忘记调用ReleaseStringChars函数可能会导致无限期地固定jstring对象,导致内存碎片,或者C副本被无限期地保留,造成内存泄漏。

  无论GetStringChars是否已经创建了字符串的副本,都必须有相应的ReleaseStringChars调用。 以下代码无法正确释放虚拟机资源:

  即使isCopy为JNI_FALSE,仍然需要调用ReleaseStringChars,以便虚拟机将解除绑定jstring元素。

10.12 过量的本地引用创建

过多的本地引用创建会导致程序保留不必要地内存。不必要的本地引用会浪费引用对象和引用本身的内存。

  要特别注意长时间运行的本地方法,循环中创建的本地引用以及辅助函数。利用Java 2 SDK 1.2版中新的Push/PopLocalFrame函数来更有效地管理本地引用。有关这个问题的更详细的讨论,请参阅第5.2.1节和第5.2.2节。

  你可以在Java 2 SDK 1.2中指定-verbose:jni选项来要求虚拟机检测并报告过多的本地引用创建。 假设你用这个选项运行一个Foo类:

输出的内容包括下面的信息:

  Baz.g的本地方法实现可能无法正确管理本地引用。

10.13 使用不合法的本地引用

本地引用仅在本地方法的单个调用中有效。在本地方法调用中创建的本地引用会在实现该方法的本地函数返回后自动释放。本地代码不应该在全局变量中存储本地引用,并希望在以后的本地方法调用中使用它。

本地引用仅在创建它们的线程内有效。您不应该将一个线程的本地引用传递给另一个线程。在需要跨线程传递引用时创建全局引用。

10.14 在跨线程中使用JNIEnv

作为每个本地方法的第一个参数传递的JNIEnv指针只能在与其关联的线程中使用。 缓存从一个线程获得的JNIEnv接口指针并在另一个线程中使用该指针是错误的。 8.1.4节解释了如何获得当前线程的JNIEnv接口指针。

10.15 线程模型不匹配

只有主机本地代码和Java虚拟机实现共享相同的线程模型(8.1.5节)时,JNI才能工作。例如,程序员不能将本地平台线程附加到使用用户线程包实现的嵌入式Java虚拟机。

  在Solaris上,Sun提供了一个基于用户线程包(称为“绿色线程”)的虚拟机实现。如果你的本地代码依赖于Solaris本机线程支持,则它不适用于基于绿色线程的Java虚拟机实现。你需要一个旨在与Solaris本地线程一起工作的虚拟机实现。Solaris中的本地线程支持JDK版本1.1需要单独下载。本地线程支持与Solaris Java 2 SDK版本1.2捆绑在一起。

Sun在Win32上的虚拟机实现默认支持本地线程,并且可以很容易地嵌入到本机Win32应用程序中。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注