Java语言较难表达,甚至是无法表达的应用场景。如使用汇编语言(X86_64的SIMD指令)来提升关键代码的性能;如调用Java核心类库无法提供的,某个体系架构或者操作系统特有的功能。
在以上情况下,会牺牲可移植性,在Java代码中调用C/C++代码,并在其中实现所需功能。这种跨语言的调用,需要借助JVM的Java Native Interface(JNI)机制。
Java中标记为native的、没有方法体的方法。当在Java代码中调用这些native方法时,JVM将通过JNI调用至对应的C函数中。
1 | public class Object { |
native方法的链接
在调用native方法前,JVM需要将该native方法链接至对应的C函数上。
链接方式主要有两种。
链接方式一:自动链接
让JVM自动查找符合默认命名规范的C函数,并且链接起来。
事实上,并不需要记住所谓的命名规范,而是采用javac -h命令,便可以根据Java程序中的native方法声明,自动生成包含符合命名规范的C函数的头文件。
1 | //Foo类有三个native方法,分别为静态方法foo以及两个重载的实例方法bar |
通过执行javac -h . org/example/Foo.java命令,将在当前文件夹(对应-h后面跟着的.)生成名为org_example_Foo.h的头文件。
1 | /* DO NOT EDIT THIS FILE - it is machine generated */ |
native方法对应的C函数都需要以Java_为前缀,之后跟着完整的包名和方法名。由于C函数名不支持.字符,因此需要将.转换为_,而原本方法名中的_符号,则需要转换为_1。(org.example包下的Foo类的foo方法,JVM会将其自动链接至名为Java_org_example_Foo_foo的C函数中。)
当某个类出现重载的native方法时,JVM还会将参数类型纳入自动链接对象的考虑范围之中。具体的做法便是在前面C函数名的基础上,追加_ _以及方法描述符作为后缀。
方法描述符的特殊符号同样会被替换掉,如引用类型所使用的;会被替换为_2,数组类型所使用的[会被替换为_3。
链接方式二:主动链接
在C代码中主动链接。
这种链接方式对C函数名没有要求。通常会使用一个名为registerNatives的native方法,并按照第一种链接方式定义所能自动链接的C函数。在该C函数中,将手动链接该类的其他native方法。
1 | // 注:Object类的registerNatives方法的实现位于java.base模块里的C代码中 |
这段代码中的C函数将调用RegisterNativesAPI,注册Object类中其他native方法所要链接的C函数。并且这些C函数的名字并不符合默认命名规则。
当使用第二种方式进行链接时,需要在其他native方法被调用之前完成链接工作。因此往往会在类的初始化方法里调用该registerNatives方法。
1 | public class Object { |
采用第一种链接方式,实现其中的bar(String, Object)方法。
1 | //foo.c |
可以通过gcc命令将其编译成为动态链接库:
1 | # 该命令仅适用于macOS |
动态链接库的名字须以lib为前缀,以.dylib(或Linux上的.so)为扩展名。在Java程序中,可以通过System.loadLibrary(“foo”)方法来加载libfoo.dylib。
1 | package org.example; |
如果libfoo.dylib不在当前路径下,可以在启动JVM时配置java.library.path参数,使其指向包含libfoo.dylib的文件夹。
1 | java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo |
JNI的API
在C代码中,也可以使用Java的语言特性,如instanceof测试等。这些功能都是通过特殊的JNI函数(JNI Functions)来实现的。
JVM会将所有JNI函数的函数指针聚合到一个名为JNIEnv的数据结构之中。这是一个线程私有的数据结构。JVM会为每个线程创建一个JNIEnv,并规定C代码不能将当前线程的JNIEnv共享给其他线程,否则JNI函数的正确性将无法保证。
这么设计的原因主要有两个。一是给JNI函数提供一个单独命名空间。二是允许JVM通过更改函数指针替换JNI函数的具体实现,例如从附带参数类型检测的慢速版本,切换至不做参数类型检测的快速版本。
在HotSpot虚拟机中,JNIEnv被内嵌至Java线程的数据结构之中。部分虚拟机代码甚至会从JNIEnv的地址倒推出Java线程的地址。因此,如果在其他线程中使用当前线程的JNIEnv,会使这部分代码错误识别当前线程。
JNI会将Java层面的基本类型以及引用类型映射为另一套可供C代码使用的数据结构。
其中基本类型的对应关系如下表:
Java类型 | C数据结构 |
---|---|
boolean | jboolean |
byte | jbyte |
char | jchar |
short | jshort |
int | jint |
long | jlong |
float | jfloat |
double | jdouble |
void | void |
引用类型对应的数据结构之间也存在着继承关系,具体如下:
1 | jpbject |
Foo类的3个native方法对应的C函数的参数。
1 | JNIEXPORT void JNICALL Java_org_example_Foo_foo (JNIEnv *, jclass); |
静态native方法foo将接收两个参数,分别为存放JNI函数的JNIEnv指针,以及一个jclass参数,用来指代定义该native方法的类,即Foo类。
两个实例native方法bar的第二个参数则是jobject类型的,用来指代该native方法的调用者,也就是Foo类的实例。
如果native方法声明了参数,那么对应的C函数将接收这些参数。在例子中,第一个bar方法声明了int型和long型的参数,对应的C函数则接收jint和jlong类型的参数;第二个bar方法声明了String类型和Object类型的参数,对应的C函数则接收jstring和jobject类型的参数。
foo.c,并在C代码中获取Foo类实例的i字段。
1 | //foo.c |
在JNI中访问字段类似于反射API:首先需要通过类实例获得FieldID,然后再通过FieldID获得某个实例中该字段的值。与Java代码相比,上述代码不用处理异常。
尝试获取不存在的字段j:
1 | java org.example.Foo |
printf语句照常执行并打印出Hello, World 0x5,但这个数值明显是错误的。当从C函数返回至main方法时,JVM又会抛出NoSuchFieldError异常。
实际上,当调用JNI函数时,JVM便已生成异常实例,并缓存在内存中的某个位置。与Java编程不一样的是,它并不会显式的跳转至异常处理器或者调用者中,而是继续执行接下来的C代码。
因此,当从可能触发异常的JNI函数返回时,需要通过JNI函数ExceptionOccurred检查是否发生了异常,并且作出相应的处理。如果无须抛出该异常,那么需要通过JNI函数ExceptionClear显式的清空已缓存的异常。
具体如下(仅在第一个GetFieldID后检查异常以及清空异常):
1 | //foo.C |
局部引用与全局引用
在C代码中,可以访问所传入的引用类型参数,也可以通过JNI函数创建新的Java对象。这些Java对象显然也会受到垃圾回收器的影响。因此,JVM需要一种机制,来告知垃圾回收算法,不要回收这些C代码中可能引用到的Java对象。
这种机制便是JNI的局部引用(Local Reference)和全局引用(Global Reference)。垃圾回收算法会将被这两种引用指向的对象标记为不可回收。
事实上,无论是传入的引用类型参数,还是通过JNI函数(除NewGlobalRef及NewWeakGlobalRef之外)返回的引用类型对象,都属于局部引用。不过,一旦从C函数中返回至Java方法之中,那么局部引用将失效。也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用。
这就意味着,不能缓存局部引用,以供另一C线程或者下一次native方法调用时使用。对于这种应用场景,需要借助JNI函数NewGlobalRef,将该局部引用转换为全局引用,以确保其指向的Java对象不会被垃圾回收。相应的,还可以通过JNI函数DeleteGlobalRef来消除全局引用,以便回收被全局引用指向的Java对象。此外,当C函数运行时间极其长时,也应该考虑通过JNI函数DeleteLocalRef,消除不再使用的局部引用,以便回收被引用的Java对象。
另一方面,由于垃圾回收器可能会移动对象在内存中的位置,因此JVM需要另一种机制,来保证局部引用或全局引用将正确的指向移动过后的对象。
HotSpot虚拟机是通过句柄(handle)来完成上述需求的。这里句柄指的是内存中Java对象的指针的指针。当发生垃圾回收时,如果Java对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。
实际上无论是局部引用还是全局引用,都是句柄。其中,局部引用所对应的句柄有两种存储方式,一是在本地方法栈帧中,主要用于存放C函数所接收的来自Java层面的引用类型参数;另一种则是线程私有的句柄块,主要用于存放C函数运行过程中创建的局部引用。
当从C函数返回至Java方法时,本地方法栈帧中的句柄将会被自动清除。而线程私有句柄块则需要由JVM显式清理。
进入C函数时对引用类型参数的句柄化,和调整参数位置(C调用和Java调用传参的方式不一样),以及从C函数返回时清理线程私有句柄块,共同造就了JNI调用的额外性能开销。
总结
Java中的native方法的链接方式主要有两种。一是按照JNI的默认规范命名所要链接的C函数,并依赖于JVM自动链接。另一种则是在C代码中主动链接。
JNI提供了一系列API来允许C代码使用Java语言特性。这些API不仅使用了特殊的数据结构来表示Java类,还拥有特殊的异常处理模式。
JNI中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的Java对象。不同的是,局部引用在native方法调用返回之后便会失效。传入参数以及大部分JNI API函数的返回值都属于局部引用。