JNI入门之详细介绍

本文主要介绍下 JNI 相关的概念和一些原理,以便对 JNI 有一个整体认识。

jdk 中的 native 接口

翻翻 java 基础类的源码,你会发现很多 native 关键字修饰的方法,比如 Object 类开头就是一个 native 方法:

1
2
3
4
private static native void registerNatives();
static {
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
2
3
4
5
6
7
package pkg;  
class Cls {
native double f(int i, String s);
static {
System.loadLibrary(“pkg_Cls”);
}
}

System.loadLibrary 的参数是要加载的类库的名字,系统根据平台规范将其转换为本地的类库全名,例如,Solaris 平台下名字 pkg_Cls 会转换为 libpkg_Cls.so,Win32 平台下则会转换为 pkg_Cls.dll

开发者可以使用一个类库存储任意数量的类使用的所有本地方法,只要所有这些类使用同一个类加载器即可。虚拟机内部会为每一个类加载器维持一个已加载的本地库列表。开发者需注意选择类库的名字,以减少名称冲突的可能性。

解析本地方法名

本地方法名根据以下规则生成

  • 前缀为 Java_
  • 完全限定的类名(包括包名和类的全路径),中间以 _ 分割
  • 方法名
  • 对于重载的 native 方法,方法名后要再跟上 __参数标签

虚拟机检查在本地(native)库的方法里的方法名是否匹配,它首先查找短名字(不带参数标签),然后查找长名字(带方法标签)。只有当一个本地方法重载了另一个本地方法时才需要使用长名字。本地方法和非本地方法重名是没有问题的,因为非本地方法不会暴露在本地库中。

如下的示例中,本地方法 g 不需要使用长名字解析,因为另外一个方法 g 不是本地方法,也就不会出现在本地库里。

1
2
3
4
class Cls1 { 
int g(int i);
native int g(double d);
}

为了确保能将所有的 Unicode 字符转换合法 c 函数名,还有一些的转换的规则。例如,使用 _ 替代类的全限定名里的 / 。因为方法名不能以数字开头,需使用转义后的 _0_9 等。

本地方法的参数

JNI 接口指针是本地方法的第一个参数,它的类型是 JNIEnv。第二个参数的类型依赖于本地方法是静态的或者非静态的。对于非静态方法来说,它是一个指向对象的引用。而对于静态方法,则是一个指向 java 类的引用。剩下的参数和 java 方法中的参数一一对应。本地方法通过返回值将结果传递给调用方。

如下是一个 native 方法

1
2
3
4
5
package pkg;  
class Cls {
native double f(int i, String s);
...
}

它对应的本地 c 函数的长名字是 Java_pkg_Cls_f_ILjava_lang_String_2,如下是使用 c 实现的本地方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 ( 
JNIEnv *env, /* 接口指针 */
jobject obj, /* this 对象指针 */
jint i, /* 参数 1 */
jstring s) /* 参数 2 */
{
/* 复制 java 的 String */
const char *str = (*env)->GetStringUTFChars(env, s, 0);

/* 处理 string */
...

/* 处理完毕 */
(*env)->ReleaseStringUTFChars(env, s, str);

return ...
}

注意这里始终通过 env 指针操作 java 对象。如下是一个 c++ 版本的实现,相对来说更加简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern "C" /* c 调用约定 */  
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* 接口指针 */
jobject obj, /* this 对象指针 */
jint i, /* 参数 1 */
jstring s) /* 参数 2 */
{
const char *str = env->GetStringUTFChars(s, 0);

...

env->ReleaseStringUTFChars(s, str);

return ...
}

引用 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

参考

Java Native Interface Specification