JNI入门之详细介绍
本文主要介绍下 JNI 相关的概念和一些原理,以便对 JNI 有一个整体认识。
jdk 中的 native 接口
翻翻 java 基础类的源码,你会发现很多 native
关键字修饰的方法,比如 Object
类开头就是一个 native 方法:
1 | private static native void registerNatives(); |
它的特点是在 java 源码中只有方法定义,而没有具体的实现。
JNI 介绍
native 关键字和 JNI
java 中可以使用 native
关键字修饰方法,表示该接口为 JNI(Java Native Interface)。利用这种类型的接口,java 代码可以使用其他语言(c/c++、汇编语言等)编写的类库。
为什么需要 JNI
在编写程序时,仅使用 java 并不能满足所有场景的需求,比如下面一些场景:
- 标准的 java 类库不支持平台相关的功能
- 已经有一个使用其他语言编写的类库,并且希望 java 代码可以直接使用它
- 希望使用低级别的语言(例如汇编)实现一小部分关键性的代码
JNI 支持的特性
JNI 支持如下特性:
- 创建、检查和更新 java 对象(包括数组和 string)
- 调用 java 方法
- 捕获和抛出异常
- 加载类并且获取类信息
- 执行运行时的类型检查
你还可以使用 JNI 的 Invocation API,将任意的本地程序嵌入到 java 虚拟机。这样就可以在不链接虚拟机的情况下,使得现有程序支持 java。
如何编写 JNI
JNI 类型的接口并非真的没有方法的实现,而是这些接口的实现存在于在外部的类库中。实现一个 JNI 接口的流程如下:
- java 代码中定义 native 方法
- 生成对应的 c 头文件
- 编写 c/c++ 实现
- 编译为动态链接库
- 加载动态链接库,调用 native 方法
详细流程可参考「JNI入门之HelloWorld」
JNI 的设计
JNI 接口函数和指针
本地代码(native code)是通过调用 JNI 接口函数来访问 java 虚拟机功能的。而 JNI 接口指针就是用来查找 JNI 接口函数的。
接口指针(interface pointer)本质上是一个指针,它指向了线程内的一个 JNI 数据结构,该数据结构内存在一个指针,它指向了一个接口函数指针数组。该数组可以认为是一个 JNI 函数表,它里面的每一项分别指向一个接口函数。每个接口函数都位于数组中预定义好的偏移位置。如下图所示:
可以发现,JNI 接口使用类似 c++ 虚函数表的结构组织起来,这样的好处是将 JNI 的名称空间和本地代码区分开,虚拟机可以很容易地提供多个 JNI 函数表。例如,虚拟机可以支持两种 JNI 函数表:
- 一个执行比较彻底的非法参数检查,适合于调试
- 另一个执行 JNI 规范要求的最少的检查,这样更有效率
加载和链接本地方法
可使用 System.loadLibrary
加载本地方法。如下示例定义了一个本地方法 f
1 | package pkg; |
System.loadLibrary
的参数是要加载的类库的名字,系统根据平台规范将其转换为本地的类库全名,例如,Solaris 平台下名字 pkg_Cls
会转换为 libpkg_Cls.so
,Win32 平台下则会转换为 pkg_Cls.dll
。
开发者可以使用一个类库存储任意数量的类使用的所有本地方法,只要所有这些类使用同一个类加载器即可。虚拟机内部会为每一个类加载器维持一个已加载的本地库列表。开发者需注意选择类库的名字,以减少名称冲突的可能性。
解析本地方法名
本地方法名根据以下规则生成
- 前缀为
Java_
- 完全限定的类名(包括包名和类的全路径),中间以
_
分割 - 方法名
- 对于重载的 native 方法,方法名后要再跟上
__
和参数标签
虚拟机检查在本地(native)库的方法里的方法名是否匹配,它首先查找短名字(不带参数标签),然后查找长名字(带方法标签)。只有当一个本地方法重载了另一个本地方法时才需要使用长名字。本地方法和非本地方法重名是没有问题的,因为非本地方法不会暴露在本地库中。
如下的示例中,本地方法 g
不需要使用长名字解析,因为另外一个方法 g
不是本地方法,也就不会出现在本地库里。
1 | class Cls1 { |
为了确保能将所有的 Unicode 字符转换合法 c 函数名,还有一些的转换的规则。例如,使用 _
替代类的全限定名里的 /
。因为方法名不能以数字开头,需使用转义后的 _0
、_9
等。
本地方法的参数
JNI 接口指针是本地方法的第一个参数,它的类型是 JNIEnv
。第二个参数的类型依赖于本地方法是静态的或者非静态的。对于非静态方法来说,它是一个指向对象的引用。而对于静态方法,则是一个指向 java 类的引用。剩下的参数和 java 方法中的参数一一对应。本地方法通过返回值将结果传递给调用方。
如下是一个 native 方法
1 | package pkg; |
它对应的本地 c 函数的长名字是 Java_pkg_Cls_f_ILjava_lang_String_2
,如下是使用 c 实现的本地方法。
1 | jdouble Java_pkg_Cls_f__ILjava_lang_String_2 ( |
注意这里始终通过 env 指针操作 java 对象。如下是一个 c++ 版本的实现,相对来说更加简洁。
1 | extern "C" /* c 调用约定 */ |
引用 java 对象
基本类型(例如 int、char 等),在 java 和本地代码使用的是传值方式(直接复制)。另一方面,对于任意的 java 对象,传递的则都是引用。虚拟机需要追踪所有传递给本地代码的对象,以便这些对象不被垃圾收集器回收掉。相反,本地代码必须有一种通知虚拟机的方式,告诉它对象已不再使用。此外,垃圾收集器还必须有能力清理掉本地代码引用的对象。
全局和局部引用
JNI 把本地代码使用的对象引用分成了两类:局部引用和全局引用。局部引用在本地方法调用期间有效,在本地方法调用返回后被自动清理掉。全局引用在显式清理前一直有效。
对象被作为局部引用传递给本地方法,所有的 JNI 函数返回的 java 对象都是局部引用。JNI 允许开发者从局部引用创建全局引用。JNI 函数期望 java 对象既能接收全局引用,也能接收局部引用。本地方法可能会将局部引用或全局引用作为结果返回给虚拟机。
大多数情况下,开发者应该依赖虚拟机在本地方法返回后释放局部引用。然而,有些情况下也需要开发者显式地释放局部引用,例如如下场景:
- 本地方法访问了一个大的 java 对象,因此为该对象创建了一个局部引用。在调用返回之前,本地方法执行了其他计算逻辑。尽管在剩下的计算中不在需要该对象,由于存在对它的局部引用,使得它不能被垃圾收集器回收。
- 本地方法创建了大量的局部引用,尽管它们并非在同一时刻使用。虚拟机需要一定大小的空间追踪一个局部引用,创建太多的局部引用可能导致内存溢出。例如,本地方法循环遍历一个大的数组对象,获取每一个成员作为局部引用,然后每次针对一个成员执行操作。在每一次迭代后,程序已经不再需要前面成员的局部引用。
JNI 允许开发者在本地方法的任意位置手动删除局部引用。为了保证开发者能够手动清除局部引用,JNI 不允许创建除了返回结果之外的局部引用。
实现局部引用
为了实现局部引用,java 虚拟机为从 java 到本地方法的每一次转换控制创建一个注册表。注册表将不可移动的局部引用映射到 java 对象,并且确保这些对象不会被执行垃圾回收。所有传递给本地方法的 java 对象(包括从 JNI 函数调用返回的结果)自动地被添加到注册表。这些注册表会在本地方法返回后删除掉,使得里面所有的对象可以被垃圾回收。
有多种方式实现注册表,比如使用 table、linked list 或者 hash table。尽管引用计数可以用来避免在注册表中产生重复项,JNI 的实现并不需要检查和折叠重复项。
注意,本地引用不仅可以保存在本地栈中,本地代码还可以将其保存到全局的或者堆数据结构中。
访问 java 对象
JNI 为全局引用和局部引用提供了丰富的访问器函数,这意味着同一份「本地方法实现」可以在不同的虚拟机上运行,而不管虚拟机内部是如何表示 java 对象的。这也是 JNI 可以支持多种虚拟机实现的一个重要原因。
通过不透明的引用使用访问器,比直接访问 c 数据结构的代价要大。但是,在大多数情况下,相比 java 程序员使用本地方法完成的不同寻常的工作,这点接口开销可以忽略了。
访问基本类型数组
对于包含了很多基本数据类型的 java 大对象(比如 int 数组和 string)来说,相应开销是难以接受的(例如本地方法执行的是数组计算或矩阵计算)。遍历 java 数组并通过函数调用获取每个元素是非常低效的。
一个解决办法是引入一个叫做「pinning」的概念,这样本地方法可以请求虚拟机遍历数组元素。本地方法直接获取到元素的直接指针。然而这种方法有两个隐含的要求:
- 垃圾回收器必须支持 pinning
- 虚拟机必须在内存中连续放置基础类型数组。尽管对于大多数基础类型数组来说,这是一种很自然的实现方式,boolean 数组却可以实现为压缩的或非压缩的。因此,本地代码还依赖 boolean 数组的布局类型,也就失去了可移植性。
为了解决这两个问题,最终使用了一个妥协的方案:
首先,这里提供一系列的函数用于在 java 数组的一个分段和本地内存缓冲区之间复制基础类型数组。如果本地方法需要访问一个大数组的少量元素,那么可以使用这些方法。
其次,开发者还可以使用另外的一组 pinned-down 版本的函数访问数组元素。需要注意的是,使用这些方法时虚拟机可能需要执行存储分配和复制操作。这些函数是否需要复制数组依赖于虚拟机的实现
- 如果垃圾收集器支持 pinning,并且数组的内存布局也是本地方法所期望的,则不需要复制
- 否则,数组会被复制到一个不可移动的内存块(例如在 c 堆中),并且执行必要的格式转换,然后返回指向复制后内存的指针。
最后,还提供了另外一些函数,用于通知虚拟机本地代码已经不再访问数组元素了。当调用这些函数的时候,系统或是解锁数组,或是将原始数组和它的不可移动拷贝解除关系,并释放拷贝。
访问属性和方法
JNI 允许本地代码访问 java 对象的属性以及调用方法。JNI 通过符号名和类型签名识别方法和属性。例如,要调用 cls
类的 f
方法,本地代码首先获取到一个方法 ID
1 | jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”); |
然后就可以重复使用这个方法 ID,而不需要再次查询
1 | jdouble result = env->CallDoubleMethod(obj, mid, 10, str); |
即使已经获取到属性或方法的 ID,也不会阻止虚拟机卸载类。在类卸载后,方法和属性的 ID 会变成无效的,因此本地代码需要确保以下两点:
- 保持对类的引用
- 或者重新获取方法或属性 ID
参考
- 2019-09-28
本文通过编写一个简单的 HelloWorld 带你熟悉下 JNI,包括如何编写一个 JNI 方法,如何打包成动态链接库,如何加载并调用等。
- 2019-10-13
jdk 中有很多 native 方法,比如 Object 类的 registerNatives 方法、String 类的 intern 方法等。这些方法在 java 层面只有接口定义,具体的方法实现则是在 jdk 中,采用 c/c++ 实现。本文主要讲下如何找到 native 方法的实现。
- 2017-10-15
Java 虚拟机所管理的内存包括多个运行时数据区域,每个区都有自己的特点。