基本上是对Oracle提供的JNI官方文档的笔记整理:
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html
具体函数用法就不记录了,其实看完Design Overview这一章之后,就已经能够对JNI建立起整体的感觉了,并且注意事项也已经全部提到了,具体函数用到时再去查询。

此外,google也提供了在Android上进行jni开发的注意事项,这个注意事项其实不限于Android平台:https://developer.android.com/training/articles/perf-jni

介绍

JNI是Java Native Interface,允许在Java VM中运行的java代码和平台上的用C/C++或者汇编开发的二进制库之间相互操作。
什么时候应该使用JNI?

  • 当你需要使用平台特有的能力,而Java提供的库无法满足需要时;
  • 当你已经有了一个能够在特定平台上运行的程序库,希望能够在Java中使用这个库的能力时;
  • 当你非常需要确保某段代码的运行效率,以至于希望使用汇编,来优化每一条机器指令时;

使用JNI,你可以:

  • 创建、查看和更新Java对象(包括自定义对象、数组、字符串);
  • 调用Java对象方法;
  • 捕获和抛出Java异常;
  • 加载Java类并且获取类信息;
  • 执行运行时的类型检查;

此外,通过配合使用JNI和Invocation API,可以在任意一个原生应用程序中内嵌Java VM。

过去曾经有过一些历史,但如今已经不太被提起了:

  • JDK 1.0 Native Method Interface,是随JDK 1.0推出的接口规范,然而它失败了,主要有两个方面的原因:
    • native代码对Java对象的C内存布局做了假设,而Java Language Specification没有规定Java对象的内存布局;
    • native代码依赖conservative GC,对unhand宏的滥用导致需要对native stack进行 conservative scan;
  • JRI,Netscape曾经提议过Java Runtime Interface,是由JVM提供的通用接口服务,JRI设计时考虑了可移植性,对JVM的实现只做了很少的假设。JRI涉及到很多主题:native方法、调试、反射、内嵌JVM,等等;
  • 微软的Java/COM Interface,微软的JVM提供了两种native method interface,在底层它提供了RNI,Raw Native Interface,RNI提供了源码级别的前向兼容性,要求代码通过RNI接口来和GC合作管理内存;在更高级别上,微软的Java/COM接口提供了语言无关的二进制接口给JVM:JVM对象可以被发布成COM对象,COM对象也可以被当作Java对象使用;
    • RNI是对JDK 1.0的概念,然而RNI依然将java对象结构体暴露给了native代码,并且对java结构的直接访问导致不可能高效的实现”write barriers”,而先进的gc算法都依赖write barriers。

一个统一的、合理的标准接口是必要的,并且应该提供:

  • 二进制兼容性 - 每个Java VM都应该提供统一的二进制接口;
  • 效率:二进制代码必须确保能够高效率运行,但已知的实现VM-independence的技术都会导致性能损耗,因此必须在效率和VM-independence之间进行权衡;
  • 功能性:标准接口必须暴露足够的Java VM接口,允许native代码利用它们完成任何合理的任务;

JNI设计之初,希望基于已有的标准接口进行设计,然而没有一个现存标准满足上面的要求。
JRI和上面的要求最接近,并且是JNI接口设计的起点,JNI设计上和JRI非常贴近,包括命名惯例、对method和field ID的使用、对local reference和global reference的使用,等等。尽管JNI很大程度上借鉴了JRI,但JNI和JRI不是完全二进制兼容的。

整体设计

JNI函数和指针

native代码通过调用JNI函数来访问JVM。JNI方法通过interface pointer来访问,一个interface pointer是一个指向指针的指针,被指向的这个指针指向的是一张函数指针表,这张表的每一项都是指向特定JNI函数的指针:
屏幕快照 2020-05-26 下午7.01.36.png
为什么要这样设计?主要是为了灵活性,这样的设计允许JVM实现预先定义两套JNI函数实现,当运行在JNI严格模式时,让interface pointer指向包含大量合法性校验逻辑版本的JNI函数表,当运行在正常模式时,指向一个效率更高的简洁版本的JNI函数表。
JNI interface pointer是一个只能在当前线程使用的变量,不可以将这个变量在不同线程间传递并使用,JVM实现会在interface pointer指向的JNI函数表中存储线程局部数据。
native方法可以通过方法参数取得interface pointer,JVM会保证在同一个Java线程中每次native方法调用都会用同一个interface pointer作为参数。不过,如果同一个native方法在不同的Java线程中被调用,那就会收到不同的interface pointer参数了。

Native方法的Compiling, Loading, Linking

由于JVM本身是支持多线程的,因此native library也必须被编译和链接为支持多线程的。比如,在使用Sun Studio编译器时需要提供 -mt 标记来将C++代码编译成支持多线程的。对于GNU gcc编译器应该使用 -D_REENTRANT或者-D_POSIX_C_SOURCE。更多信息需要参考编译器文档。

Native方法需要通过System.loadLibrary方法加载,比如 static { System.loadLibrary(“pkg_cls”); } ,传入这个方法的参数是库名称,JNI规范遵循平台特定的规则,尝试将这个库名称转换成一个能够在平台上找到的库名称,通常Unix系统下,这个名称会被转换为 libpkg_cls.so,而在Windows系统下则会被转换为pkg_cls.dll。
可以选择将所有native方法打包到一个库中,JVM内部会在每个class loader中维护已经加载的native库。

native库也可以静态的被链接进VM,具体如何做是和平台相关的。
对于一个已经静态链接进VM的库,System.loadLibrary一定能够成功加载这个库。一个库L如果是和VM静态链接的,就必须定义JNI_OnLoad_L方法,并且JNI_OnLoad方法会被忽略,当调用System.loadLibarary(“L”)时,JNI_OnLoad_L会被调用,参数和返回规范和JNI_OnLoad是一样的;当加载了这个静态库的class loader被GC时,JVM会尝试查找并调用这个库中导出的JNI_OnUnload_L函数,JNI_OnUnload函数会被忽略。
一个静态链接进VM的库会阻止同名动态库的加载;
可以使用JNI函数RegisterNatives()来注册native方法到相关联的类上。

本地方法名称解析

动态库链接器通过方法名称来将native方法关联到Java类中定义的native函数。一个native方法名称由以下部分组成:

  • Java_
  • mangled fully-qualified class name
  • _
  • mangled method name
  • 针对重载方法(同名不同参的方法),需要补充一个部分:
    • mangled argument signature

JVM首先检查native libaray中是否有不包含重载部分的短函数名,如果找不到再加上重载部分找长函数名,长函数名只有在存在重载时才是必须的(但其实native方法定义本来就应该避免重载,以获取更清晰的语义。。)。

mangle规则是java fully qualified name中的 / 都被替换为 _ ,此外支持下划线开头跟数字的特殊转义序列:

  • _0XXXX - 用于表示Unicode字符
  • 1 - 用于表示名字中的下划线
  • _2 - 用于表示名字中的分号 ;
  • _3 - 用于表示名字中的中括号 [

JNI方法符合平台标准的calling convention,Unix系统下是C calling convention,Win32系统下是__stdcall。

Native方法参数

JNI interface pointer总是native方法的第一个参数,类型是JNIEnv;第二个参数根据native方法是实例方法还是类方法而有所不同,对于实例方法该参数是一个java object的引用,对于类方法是一个java类的引用。native方法返回值会被转换成JVM对应的值返回。

引用Java对象

基础类型如int, char等等都是在Java和Native之间通过拷贝传递的。但是任何Java对象类型,都是通过引用传递的。JVM必须追踪所有曾经被传入native代码的Java对象,避免它们被gc回收。反过来,native代码也必须能够告知JVM某些Java对象已经不用了,可以回收了。此外,GC必须能够移动由native引用的Java对象。

Global Reference 和 Local Reference

JNI将native代码引用的Java对象分成两类:local的和global的。local reference在native方法调用期间有效,会在native方法返回后失效;global reference在它们被显示释放前都是有效的。

下面是一些和方法调用有关的Java对象引用规则:

  • 被传递给native方法的对象都是local reference
  • JNI标准中定义的方法返回的Java对象一定是local reference
  • JNI允许将local reference升级成global reference
  • 任何以java对象为参数的JNI方法都能够接收local或者global reference
  • 一个你自己写的native方法返回的Java对象既可以是local reference也可以是global reference。

一般来说,local reference的释放交给JVM就可以,程序员不必操心,但是,在某些场景下需要程序员显示释放local reference:

  • native方法访问了非常重的Java对象(若不及时释放,会持续占用大量内存的对象),然后在不需要再次访问该对象的情况下继续执行了很多其他逻辑才返回,这种情况下及时释放local reference能减轻内存负担;
  • native方法创建了大量local reference,通常是通过循环,这种情况下每当进入新的循环,前一次循环创建的local reference很大概率上都没用了,但在方法返回前local reference都不会被释放,如果循环次数足够多,就会导致耗尽JVM内存(事实上,一般会先耗尽local reference registry,参考下面的Local Reference实现部分的讨论),这种情况下必须及时释放local reference。

为了允许开发者能够完全控制local reference的数量,JNI规范中的所有方法都不允许创建local reference,唯一的例外是可以创建local reference然后返回该对象。
local reference不能被跨线程传递,只能在当前线程中是有效的。

Local Reference的实现

为了实现local reference,JVM为每一个java到native的方法调用维护了一个registry,registry维护了不可移动的local reference到Java对象的映射,所有被传递到native方法的Java对象,包括在native方法中通过调用JNI方法获得的Java对象,都被维护在这个registry中。当native方法返回时,registry会被自动删除,然后原本registry中维护的Java对象才可以被gc。
registry具体如何实现没有约束,它可以是一个table、LinkedList、HashTable等。

访问Java对象

JNI提供了一系列函数用于访问Java对象,不论这个对象是global reference还是local reference。
必须承认的是通过这些JNI方法访问Java对象比直接把Java对象当成C结构体访问要更加麻烦,但一般来说需要用到JNI解决问题的人一定能够克服这点障碍。🤦‍♂️🤦‍♀️

访问基础类型数组

当需要处理大量数据时,每次获取一个数据项都要求进行一次方法调用所带来的额外开销是不可接受的。因此,针对Java基础类型和String类型数组,JNI提供了某种途径,允许native method直接访问数组中的元素。

JNI考虑过一种可选的途径,称作”pinning”,native method可以要求JVM将数组内容暂时摁住固定下来,然后native method可以获取一个指向数组内容的指针。这种方案有以下两点影响:

  • GC必须支持”pinning”,允许暂时固定一块Java数组内存;
  • VM必须将数组内容连续的存放在内存中,一般来说所有VM都是这么支持数组的,但对于bool数组来说不同VM可能会选择packed或者unpacked的布局,因此访问bool数组的native method可能无法做到VM无关性。

最终JNI选择了另一种折衷途径,来某种程度上克服上面提到的所有问题:
提供了一系列用于拷贝Java数组内容到native内存中的JNI函数,当需要访问的数组规模不大时可以采用这种方案;
此外还提供了一组用于固定数组元素的JNI函数,就是上面描述的pinning途径,对于支持gc pinning的VM而言提供给native method的指针就是直接指向Java数组元素的指针,但如果VM不支持gc pinning,也可以选择先将要被访问的Java数组元素拷贝到一个专门的内存区域然后返回一个指向该内存的指针。当native method用完了数组之后,可以告知VM,这时候VM会解除gc pinning或者释放专门内存区域。

JNI实现必须确保一个Java数组可以同时在多个线程中被读取,但JNI也同时规定在多个线程中同时修改一个数组的内容是非法的,会导致不确定的后果。

访问属性和方法

JNI允许native代码访问Java对象的属性,或者调用Java对象的方法。为了访问Java对象属性或方法,需要做如下两步:

  • 通过Java对象类型引用和要访问的属性或方法的签名,获取属性或方法的id:
    • jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”);
  • 通过Java对象引用和属性或方法ID来访问属性或调用方法:
    • jdouble result = env->CallDoubleMethod(obj, mid, 10, str);

属性或者方法ID不会阻止对应Java类的unload,如果JVM决定要unload一个Java类,那之前native method获取的对应ID都会失效,下次Java类再被加载时,需要重新获取新的ID。
JNI规范没有要求JVM具体如何实现属性或方法ID。

报告程序错误

JNI本身不检查程序错误,诸如传入NULL或者不符合JNI函数定义/声明类型的参数都不会被检查。JNI这么做是有原因的:

  • 进行额外的错误检查会导致拖慢没有错误的代码;
  • 大部分场景下,都无法获取足够的运行时的信息来执行期望的错误检查;

大部分C库函数都不会对编程错误进行检查。例如printf函数,当传入的参数不符合预期时(传入了一个非法的内存地址),会直接导致运行时异常,而不是返回一个错误码。C的惯例是异常由库的用户检查,在库的实现内部做异常检查可能会导致同样的检查逻辑重复执行:一次是用户调用库函数前做的,一次是库函数实现做的。
C程序员必须确保不传入非法的指针或者错误的参数类型给JNI函数,这类错误都是编程错误,需要程序员自己避免。

Java异常

JNI允许native method抛出Java异常,native method也可以检查是否有未捕获的Java异常,如果存在Java异常且在native method中没出捕获,那么异常会在native method返回后传播回JVM。

异常和错误码

少数JNI函数使用Java异常机制来报告错误。大部分情况下JNI函数都通过返回错误码并且抛出Java异常来报告错误。使用JNI的应用程序可以:

  • 通过返回的错误码快速的检查JNI调用是否成功;
  • 通过调用JNI函数:ExceptionOccurred()来获取更详细的错误原因;

在两种场景下,无法检查错误码,必须直接检查Java异常:

  • JNI函数调用了Java方法,此时必须调用ExceptionOccurred来检查Java方法执行过程中是否有异常被抛出;
  • 一些JNI数组访问函数不返回错误码,但可能抛出ArrayIndexOutOfBoundsException或者ArrayStoreException;

除此之外的场景下,只需要检查错误码,错误码如果表示函数执行正常就一定没有Java异常被抛出。

异步异常

首先了解下什么是异步异常:
https://stackoverflow.com/questions/13506900/java-asynchronous-exceptions-can-i-catch-them
然后,似乎遇到异步异常也没什么好的处理办法。。。
异步异常会触发VM的全局异常回调,一般会导致VM被终止,而native method中则应该在可能长时间执行的逻辑中去显示通过ExceptionOccoured检查异步异常,如果出现异步异常则终止。

TODO: 这块描述有点含糊了,如果VM挂了,那通过VM调用的native代码执行也应该会被立即终止?那么,在native代码中做异步异常检查,还有意义吗?

异常检查

native代码有两种方式应对异常:

  • 直接返回到Java层,让异常被传播到Java层,在Java层看来是这个jni方法抛出了异常;
  • native代码获取异常并处理,然后调用ExceptionClear()清除异常;

一旦有异常被抛出,native method必须首先处理异常,在异常被清除之前,大部分JNI函数都是不可用的,这些JNI函数会检查是否有未处理的异常,若有则直接返回错误。只有和资源清理,以及异常处理相关的JNI函数在此时可用。

JNI类型和数据结构

第三章和第四章都是具体的API规范了,用到时参考即可。
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/types.html

JNI函数

https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html

Invocation API

https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/invocation.html
Invocation API让Vendor能够在native应用中加载JVM。Vendor可以在不必链接JVM源码的情况下提供支持Java的应用。
下面是一个C++应用启动JVM并执行Main.test静态方法的代码,使用Invocation API可以使用JNI接口指针使用JVM提供的能力。

  1. #include <jni.h> /* where everything is defined */
  2. ...
  3. JavaVM *jvm; /* denotes a Java VM */
  4. JNIEnv *env; /* pointer to native method interface */
  5. JavaVMInitArgs vm_args; /* JDK/JRE 6 VM initialization arguments */
  6. JavaVMOption* options = new JavaVMOption[1];
  7. options[0].optionString = "-Djava.class.path=/usr/lib/java";
  8. vm_args.version = JNI_VERSION_1_6;
  9. vm_args.nOptions = 1;
  10. vm_args.options = options;
  11. vm_args.ignoreUnrecognized = false;
  12. /* load and initialize a Java VM, return a JNI interface
  13. * pointer in env */
  14. JNI_CreateJavaVM(&jvm, (void**)&env, &vm_args);
  15. delete options;
  16. /* invoke the Main.test method using the JNI */
  17. jclass cls = env->FindClass("Main");
  18. jmethodID mid = env->GetStaticMethodID(cls, "test", "(I)V");
  19. env->CallStaticVoidMethod(cls, mid, 100);
  20. /* We are done. */
  21. jvm->DestroyJavaVM();

NIO和JNI

Java 1.4版本支持 java.nio ,支持Direct Buffer,同时引入了新的JNI接口。

https://docs.oracle.com/javase/8/docs/technotes/guides/jni/jni-14.html

新引入的用于配合Direct Buffer使用的JNI接口如下:

DirectBuffer是没有被JVM管理在堆上的一段内存,这段内存从创建到释放,地址不会改变(API文档说的是所有方法都会“尽量保证地址不变”,但如果地址变了,这东西就没什么用了),因此C代码可以放心地访问这段内存。

相比HeadBuffer,DirectBuffer的创建和销毁成本相对较高,当需要分配大片内存,或需要在JVM和C之间共享内存时,才应该使用DirectBuffer。