首页 > 代码库 > JNI设计概述

JNI设计概述

1.概述

本章概述JNI的设计。必要的时候,也会给出底层技术的实现的动机。设计概述讲解了JNI特有的关键概念:JNIEnv接口指针,局部和全局引用,域和方法ID等。讲述这些技术实现动机是为了帮助读者理解JNI设计的权衡取舍。在某些时刻,我们将会讨论某些特性可能的实现方式。这样的讨论的目的不是要提出一个切实可行的实施策略而是要阐明微妙的语义问题。

桥接不同语言的编程接口的概念并不是新鲜事物。举例来说,c语言可以调用FORTRAN或者汇编所编写的函数。同样的,编程语言的实现如Lisp和Smalltalk支持外围的各种功能接口

JNI所解决的问题,类似于其他编程语言所解决的问题一样,提供了不同语言之间的协作机制。但是JNI和其他语言所支持的协作机制有个重大的不同点。JNI并不是为某一种特定的Java虚拟机设计。JNI是一个本地的接口,可以被所有的Java虚拟机支持。当我们描述JNI设计目的的时候,将会详细的阐述这一点。

1.1 设计目的

JNI设计的最重要的目的是在给定主机环境上不同的Java虚拟机实现之间提供二进制兼容。本地库文件运行在指定主机环境上的不同实现的虚拟机上,不需要重新编译。

为了达到这个目的,JNI的设计不能够假设Java虚拟机实现的任何细节。因为虚拟机的发展很迅速,我们必须足够的仔细以避免和将来的虚拟机实现技术产生冲突。

JNI设计的第二个目的是高效。为了满足对时间要求很严格的代码,JNI的开销应该尽可能的小。然而我们即将看到,我们的第一个目的,实现的独立性,有时候需要我们采取稍微牺牲性能的做法。我们在实现独立性和高效之间寻求一个平衡点。

最后JNI必须功能齐全。它必须提供足够的Java虚拟的功能,使本地方法和应用完成有意义的任务

成为特定Java虚拟机实现的本地编程接口并不是JNI设计的目的。一套标准的接口将会使那些想把本地库代码加载到不同的虚拟机实现中的程序开发者受益。

1.2 加载本地库

在应用可以调用本地方法之前,Java虚拟机必须定位和加载一个包含了本地方法本地方法具体实现的本地库文件。

1.2.1 类加载器

本地库文件的定位由类加载器完成。类加载器在Java虚拟机中有很多的用途,例如:加载类文件,定义类和接口,提供不同组件之间独立的命名空间,解决不同类和接口之间的符号 引用,最后定位本地库文件。我们假设你对类加载器有一个基本的了解,所以我们不打算深入讲解类加载器是如何加载和连接类文件到Java虚拟机中的细节。在论文Dynamic Class Loading in the Java Virtual Machine中你可以找到关于类加载器的更多细节。

类加载器为Java虚拟机上运行的多个组件提供单独的命名空间。类加载器映射类和接口的名字到实际的类和接口类型,这些类型代表了Java虚拟机中实际的对象。每一个类或者接口都和定义它的类加载器关联。类加载器将类文件读入内存中,并确定类和接口的类型。两个类或者接口类型完全一致必须满足类或者接口名一致并且类加载器相同。在下图中类加载器L1和L2都定义了一个类C,但是这两个类C并不一样。确实,它们含有的函数f返回的参数不一样。

虚线代表着类加载器之间的累托关系。

1.2.2 类加载器和本地库文件

现在假设下,两个类C中的f方法都是本地方法。Java虚拟机使用名字C_f定位两个C.f的本地实现。为了确保每一个C都能连接到正确的本地方法,每一个类加载器都必须维护自己的本地库集合。如下图所示:


因为每个类加载器维护一组本地库,程序员可以使用一个单一的库来包含所有的本地方法,无论有多少个类需要这些方法,只要类的加载器相同。

本地库被自动的卸载,当与之关联的类加载器被Java虚拟机的垃圾回收器回收。

1.2.3 定位本地库

本地库文件使用 System.loadLibrary加载。在下面的例子中,类Cls的静态初始化部分加载了定义了函数f的本地库:

package pkg;
class Cls {
     native double f(int i, String s);
     static {
         System.loadLibrary("mypkg");
     }
}

System.loadLibrary的参数是程序员指定的的库的名字。软件开发人员负责选择本地库的名字减少名字冲突的机会。Java虚拟机根据主机环境特定的标准将这个名字转换成一个本地库的名字。例如,在Solaris操作系统上将名字mypkg转换成libmykg.so,32位的window操作系统转换成mypkg.dll。

Java虚拟机启动时,它构建了一个目录列表,被用来定位应用程序类的本地库。目录列表的内容依赖于主机环境和虚拟机的实现。

System.loadLibrary抛出UnsatisfiedLinkError异常,当无法加载本地库的时候。如果之前已经调用过System.loadLibrary加载过相同的本地库,那么System.loadLibrary就什么都不会做。如果底层的操作系统不支持动态连接,所有的本地方法必须被Java虚拟机预先连接。在这种那个情况下,Java虚拟机完成 System.loadLibrary函数调用,但是并没有真的加载库。

Java虚拟机的内部为每一个类加载器维护着已加载本地库链表。它根据三个步骤来确定哪一个类加载器和最新加载的本地库关联:

1.确定System.loadLibrary的直接调用者

2.确定这个直接调用者所属的类

3.获取这个调用类的加载器

1.2.4 类型安全限制

Java虚拟机不允许一个给定的本地库被多个类加载器加载。如果试图在多个类加载器中加载相同的本地库,会导致 UnsatisfiedLinkError异常。这个限制的目的是为了确保基于类加载器的独立命令空间在本地库上也得以保护。如果没有这个限制,通过本地库方法混合不同的类加载器的类和接口将会变的更加的容易。

1.2.5 卸载本地库

当与本地库相关联的类加载器被垃圾回收的时候,Java虚拟机会卸载本地库。

1.3 链接本地方法

Java虚拟机会试图在第一次调用本地方法之前链接每一个本地方法。链接一个本地方牵涉以下步骤:

?  确定定义了本地方法的类所属的类加载器

搜索类加载器的本地库结合,定位本地方法

建立数据结构以便将来对本地方法的调用可以直接跳转到本地方法处

1.4 调用规范

调用规范决定了本地函数如何接收参数和返回结果值。对于不同的本地语言并没有一个标准的规范。确定一个标准是很困难,并且是不可能的,因为这要求Java虚拟机和广泛的本地语言调用规范打交道,每一种语言的规范可能还不一样。JNI 要求本地方法按照与主机环境相适应的指定的调用规范标准编写。例如,JNI遵循UNIX上的c语言调用规定,在 Win32遵循 stdcall调用规范。

当程序员需要调用遵循不同调用规范的本地函数时,他们必须自己编写转换接口,将JNI的调用规范转换成与本地语言适配的规范。

1.5  JNIEnv指针

本地代码访问Java虚拟机的功能,通过JNIEnv指针所提供的接口。

1.5.1 JNIEnv指针的组织

一个JNIEnv接口指针指向一个本地线程数据其中又包含一个指向函数的指针表。每一个接口函数都有一个预先定义好的函数表偏移量。JNIEnv指针组织形式类似于c++的虚函数表或者是微软的COM接口。


实现了本地方法的函数接收JNIEnv作为第一个参数,虚拟机保证在同一个线程中调用本地方法时传送同一个JNIEnv。

1.6 数据传递

原生数据类型,比如整形,字节等,在Java虚拟机和本地代码之间直接复制。对象,使用引用来传递。每一个引用包含一个指针指向底层的一个对象。本地代码不会直接使用对象的直接指针。从本地代码的角度看引用,是不透明的。传递引用而不是直接指向对象的指针,使虚拟机可以采取更加灵活的方式管理对象。当本地代码持有对象的一个引用,虚拟机可能进行垃圾回收,导致对象从一个地方复制到另一个地方。虚拟机可以自动的更新引用的值,所以即使对象的地址发生了改变,但是引用仍然有效。

1.6.1 全局和局部引用

JNI为本地代码创造了两种引用:局部和全局引用。局部引用在本地函数调用期间有效,当本地函数返回的时候自动的被释放。全局引用一直有效,直到被显示的释放。对象被以局部引用的形式传递到本地函数。大多数的JNI函数返回局部引用。JNI允许程序员从局部引用创建一个全局引用。

局部引用只在创建他们的线程中有效。本地代码不能将一个局部引用从一个线程传递到另外一个线程。引用值为NULL的JNI引用代表Java虚拟机中的null对象。

1.7 访问对象

JNI提供了丰富的函数集合用于从引用访问对象。这就意味着同样的本地函数可以在不同的虚拟机上工作,而不管虚拟机内部是如何表示对象。

1.7.1 访问原生数组

通过函数重复访问原生数据类型数组的开销是不可接受的。考虑本地函数用于执行vector和matrix计算。使用函数调用的方式来枚举数组中的每一个值是非常的不高效的。

pinning 是我们要介绍的一个概念,本地函数通过这种方式告诉虚拟机不要移动数组的内容。本地方法获取一个指针,直接指向数组的元素。这种方式有两种弊端:

? 垃圾回收器必须支持pinning。在很多的实现中,pinning不是那么令人渴望,因为它是虚拟机的垃圾回收算法复杂化,并且导致内存碎片

? 虚拟机必须在内存中为数组分配连续的空间

JNI采取折中的方式,以解决上面两个问题。

首先,JNI提供函数集(例如GetIntArrayRegion和SetIntArrayRegion),拷贝原生数组的数据到本地内存buffer。如果本地方法需要访问大型数组的一小部分,或者需要复制数组,就可以使用这些函数。

其次,程序员可以使用另外一组函数(例如 GetIntArrayElements)来试图获取一个pin版本的数组元素。依赖于虚拟机的实现,这种方式可能导致内存分配和复制。是否会导致复制数组,依赖于虚拟机的实现:

? 如果垃圾回收器支持pinning,并且数组元素的布局和本地代码中数组元素的布局一样,就不需要复制

? 否则数组就要被复制到一块不可移动的内存中(比如c的堆),并且要执行必要的格式转换。接着会返回一个复制后的数组指针

本地代码调用第三个函数集通知虚拟机本地代码不在访问这些数组元素。发生了这种情况后,虚拟就会接触数组锁定,或者使最初的数组内容和位于不可移动的数组拷贝中的内容一致,并且释放拷贝内存。

这种方式提供了一种灵活性。垃圾回收器可以对数组采取复制或者锁定的操作。对于不同的虚拟机实现来说,对于小的数组可能采取拷贝的方式而对于大型数组可以采取锁定的方式。