各位同学,开发者学堂Java 图谱中Java 高级工程师篇的课程“Java 虚拟机原理”的课程给开始更新了,第一课时“JNI in Java”的干货总结来啦!一起学习新课程吧!
课程链接以及图谱地址小编已经为大家指路了,搭配学习效果更佳????
课程名称:JNI in Java
课程地址:https://developer.aliyun.com/learning/course/56/detail/1190
图谱名称:Alibaba Java 技术图谱
图谱地址:https://developer.aliyun.com/graph/java
JNI in Java
一、什么是JNI
(一)什么是JNI (Java Native Interface)
JNI全称是Java Native Interface,顾名思义是Java和Native间的通信桥梁,如下图所示,图的上方是Java世界,下面是Native世界,中间是JNI通信,左边箭头从上往下是Java调用Native的方法,右边是Native调用Java,彼此可以互通。
这种方式带来的好处,Java调用Native,可以去调用非Java实现的库,扩充Java的使用场景,比如调用Tensorflow;反之Native调用Java,可以在别的语言里面调用Java,比如java launcher可以命令启动Java程序。
(二)为什么要学习JNI
掌握Java和Native之间的互相调用,大大丰富java的使用场景。了解原理,对于学习JVM/故障定位更加得心应手。
经典例子,如下图所示,在主函数里面用Selector.open创建一个select,叫select方法,这是Java里面通过NIO取允许网络的方法。
public static void main(String[] args) throws Exception {
java.nio.channels.Selector.open().select();
}
这个方法会阻塞其当前线程,通过java.lang呈现状态是RUNNABLE,看到RUNNABLE总觉得会消耗CPU、NIO的BUG, 其实是一个经典谬误,实际上线程是禁止的。
二、JNI实践和思考
实战一: 从native调用Java
首先要#include <jni.h> ,这个头文件定义了各种Java和Native交互的数据结构以及定义;在主函数里面,首先声明一个JVM的指针,然后一个JNIEnv *env的指针,JVM表示的Java虚拟实例,我通过实例消耗资源进行各种操作。
env其实对应的是一个线程,然后创建JavaVMInitArgs结构体,结构体里面要填充Java参数,用JavaVMOption表示。因为这里不需要参数,场景比较简单,所以用options[0],把 options传入 vm_args.options结构体,最后调用JNI_CreateJavaVM创建 Java虚拟器,如果返回的是“JNI_OK”,说明这次调用成功。
有了JNI指针表示实例以后,就可以用标准方法使用JNI,在这里调用一个Java方法,比如Java数据结构,先通过EMC的FindClass,找到SelectorProvider类,中间有个printf变量叫lock,先通过 GetStaticField获取 field,再通过GetStaticObjectField从 cls对象上获取fid,就是 lock对象,然后把它打印出来,最后jvm->DestroyJavaVM。详情操作如下图所示:
还有一个比较经典的例子Java Launcher, java –jar spring-application执行程序的时候,在后台默默的创建了一个jvm,把Java参数作为 arguments传进去,调用Java入口方法,通过JNI实现。
平时所说,开发jvm其实就是开发jvm的动态库, “libjvm.so”基本上本身是作为“os”提供出去,好处是非常灵活,可以作为独立应用使用,也可以在别的像cer这样的语言调用,使Java调用Native,Native调用Java更加灵活。
JNI实战二: Java调用C
Java调用C是使用JNI最常见的方式,首先定一个类叫HelloJNI,里面有System.loadLibrary("hello"); 系统会自动去找到library libhello.so,这个类里面定义方法叫sayHello,加了C以后调用它,但这是调不通的,因为并没有提供真正的Native实现。实现要通过一个头文件去告诉这个方法的签名,这里实现Java文件,然后通过jni.h生成头文件,这个是自动生成的。
签名是 Java,然后是Java_HelloJNI_sayHello(JNIEnv *, jobject)规范,类名加上方法名,参数第一个是环境;第二个是jobject,无参数,但是 Java的方法默认是有一个this指针作为第一个参数,最后编写它,实现HelloJNI.c,根据这个声明定义实现,然后里面只是printf了一下,把 HelloJNI.c定义成libhelloHello.so这个程序就可以运行起来了。详情如下图所示:
在Java应用里面,可以调通过JNI调用各种库,调用到native以后,因为任何语言跟native都互相交互,大大丰富了Java使用场景。
思考一: Java和Native的数据是怎么传递的
在执行Java方法时,用的是java heap,假设暂时向下增长,需要调用 c函数的时候,它需要去压站,把 object压站、把JNIEnv压站、cstack压站,进入seat stack。然后 object本质上是指向handle的指针,handle指向战上真正的OOP,使用二级指针结构,稍微有点复杂。详情如下图所示:
思考二: 回到问题,为什么select()的线程状态是RUNNABLE
JNI只是提供一种机制,让Java程序可以进入Native状态,Native状态基本上没有办法管理。这段Native代码在做一种非常复杂的数学运算,肯定是RUNNABLE状态,也可以调用系统形象去阻塞,但这个阻塞基本上不知情,所以会一直显示为RUNNABLE,除非通过JNI的特殊接口改变现实状态,到其他状态才会显示为其他状态,所以这里显示为RUNNABLE为正常,不用担心RUNNABLE状态消耗很多CPU等问题。
三、JNI与safepoint
首先有这样两个问题:
1、JNI是否会影响GC进行?
2、GC时JNI修改Java Heap怎么保证一致?
看到第二个问题的时候,已经回答第一个问题,假如GC是不能运行JNI,那也就没有一致性问题,所以在GC时可以执行JNI。
(一)JNI与Safepoint的协作
首先要知道Java的信任状态,Java最主要信任状态是“Thread in Java”状态,这个状态里面在执行一个解释器或者已经编译的方法,纯Java执行。这时候如果发生Safepoint,会通过Interpreter机制把这个线程直接挂起,暂停下来,然后去Safepoint里面进行GC的各种操作。
在Java里面,调用JNI进入Native,会切换到Thread in native状态,这里执行Native函数,在执行的时候跟GC可以并行执行,因为理论上要么执行,要么通过JNI和JNI交互,所有的跟JNI相关的数据结构都可以被管理。然后Native还可以去切换到JVM状态,这是非常关键的状态,这个状态不能发生GC,不用关心。
JNI与Safepoint交互,假如JNI执行时发生Safepoint能并行。JNI执行的时候返回Java,这时候会被阻塞,需要检查状态,卡在Safepoint状态,直到Safepoint结束,继续回到Java。
(二)JNI与GC
透过几个JNI管中窥豹,了解这个机制:
void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);
这个函数叫做GetPrimitiveArrayCritical。Critical作用是把一段内存返回给用户,用户可以直接编辑里面的数据,这时如果发生GC被移动,编辑肯定会导致 heap乱掉,有Critical这段时间里锁住heap,没法发生GC。假如 critical状态发生期间,基本上不会影响GC,会等待,直ReleasePrimitiveArrayCritical发出,这是比较巧妙的互相协作。
下图所示的二级指针模型,还是前面Java调到Native,参数通过jobject到handle保存使用,jobject指向handle,handle指向oop。
java heap时候,假如OOP对象被移动handle,同时会更新 handle里面的地址。所以只要C程序都是通过JNI访问对象,每次对象被移动它都可以被感知,不会出现数据布局之后突然情况。
“GC: handle_are->oops_do(f)”
指有区域专门存放handle,里面所有handle在GC里,都会进行一次指针修正,保证数据一致性。
四、JNI与Intrinsic
(一)高级主题: intrinsic
如下图所示,以非常常见JNA“currentThread”为例子,说明Intrinsic机制。Intrinsic在看到currentThread的时候,不会去JNI,而是通过形成更高效的版本。
这里inline_native_currentThread的时候,最终会调用generate_curent_thread工具。然后看里面的实现核心部分,创建“ThreadLocalNode()”,代表当前JavaThread结构的指针,再通过JavaThread结构里的threadObj_offset()拿到它,通常是一个偏移量,拿到Object以后作为返回值返回。这里是一段AI,真正生成代码时被翻译成非常简约的几条指令,直接返回。所以“currentThread”变得非常高效,这就是Intrinsic机制,主要为性能而生。
(二)Intrinsic性能分析
对比一下Intrinsic与非Intrinsic性能,如下图所示,是用jmh写的Benchmark,可以规避掉一些具体的预热不够,导致性能测试不准的问题,用它进行测试,也是官方推荐的版本。
Intrinsic版本,下面测试叫“jni”,主要区别就是Intrinsic后面接了一个叫isAlive的调用。isAlive本身状态调用看起来非常轻量,但因为他没有做Intrinsic,所以最终会走JNI。
下图所示,对比普通Intrinsic与加上JNI的Intrinsic性能,普通 Intrinsic的性能大概是3亿次每秒;加上JNI的Intrinsic版本的性能是2000万次每秒,差了十几倍,差距很大。
进一步看性能问题,最重要的是performing,performing手段是perform,public第二段JNI版本,前面两个热点方法都是“ThreadStateTransition”现任状态转换。前面说到,假如JNI回到 Java时候做GC肯定要停下来,所以这有个内存同步比较好资源,要等的时间比较长,所以这两个函数是最热的。
下面是“JVM_IsThreadAlive”实现。后面是“HandleMark::pop_and_restore”在调JNI时需要把oop包装成handle,JNI退出时,需要消费handle, restore指有开销。再后面“java_lang_Thread::is_alive”占比4.77% 非常小。
由此可以看出Intrinsic提供性能非常好的机制,直接调用JNI,性能可能差一点,但也可以接受。
(三)案例分析: RocketMQ Intrinsic导致应用卡顿
RocketMQ 是阿里巴巴开源的MQ产品,使用非常广泛,里面有个函数叫“warmMappedFile”,指的是RocketMQ是通过warmMapped机制内存映射磁盘去做IO,在申请完一块磁盘映射的内存以后,会去做预热。
这里有for循环“for (int i = 0, j = 0; i < this.fileSize”,每隔一个PACG_SIZE去“byteBuffer.put(i, (byte) 0)”;,这样的话,操作系统就会发生缺页,把内存真正分配出来,而不只是EMV数据结构。分配出来以后,等到程序真正使用这块内存的时候,就是纯内存IO,不太会触发这种缺页了,可以变得更快,目的是减少程序卡顿。
但是后面加了if这一段,可以想到刚开始这个循环有问题,因为 byteBuffer.put是Intrinsic,最底层是Intrinsic,方法返回的时候,没有方法调用。JVM在方法返回以及循环末尾检查,是否有Safepoints,来看是否要进入GC,但是因为这是一个Intrinsic,所以没有到检查点,同样这是一个CountedLoop,也没法去进入检查点。因为JVM有个机制,如果这是一个 int作为index去Counted次数的话,为了性能是不会去检查,因为它认为这是有限次的循环,所以不用检查次数。
这种机制循环里面非常简单,中间有可能因为操作系统原因带来卡顿,导致循环,基本上没法进入GC,因为线程没有进入Safepoints,整个界面都没法进入GC, 夯住很长时间,当时大家觉得很不可思议,但是通过一个很简单方法修好了,就是每隔1000字循环的时候,去调一个“Thread.sleep(0)”。
刚刚提到,“byteBuffer.put”没法出发,Thread.sleep是个JNI,返回的时候会检查Safepoints,所以就可以让这个程序能够进入到Safepoints,这个代码就不会影响JVM进入到GC了,代码目前还可以从开源软件上看到。
“-XX:+UseCountedLoopSafepoints”
解决这个问题,还有另一种方式,通过一个选项叫“-XX:+UseCountedLoopSafepoints”,可以JVM自动在CountedLoop结尾检查这Safepoints,当然这带来的副作用是,CountedLoop末尾都会检查Safepoints,这样就会影响整体性能。