(给 ImportNew 加星标,提高Java技能)
编译:ImportNew/唐尤华
shipilev.net/jvm/anatomy-quarks/9-jni-critical-gclocker/
1. 写在前面
“[JVM 解剖公园][1]”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。
Aleksey Shipilёv,JVM 性能极客
推特 [@shipilev][2]
问题、评论、建议发送到 [aleksey@shipilev.net][3]
[1]:https://shipilev.net/jvm-anatomy-park
[2]:http://twitter.com/shipilev
[3]:aleksey@shipilev.net
2. 问题
JNI `Get*Critical` 如何与 GC 配合?GC Locker 是什么?
3. 理论
熟悉 JNI 的人知道有两组读取数组内容的方法,包括 `Get<PrimitiveType>Array*` [系列][4]:
>>>
void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);
这两个函数的语义非常类似于 `Get/Release*ArrayElements` 函数。可能的情况 VM 会返回一个指向原始数据的指针,或者进行拷贝。但是,如何使用这些函数有很多限制。
— JNI 指南
第4章: JNI Functions
>>>
[4]:http://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#GetPrimitiveArrayCritical_ReleasePrimitiveArrayCritical
这样做的好处显而易见:VM 返回指针可以提高性能,而不是对 Java 数组进行拷贝。显然这会有一些限制:
>>>
调用 `GetPrimitiveArrayCritical` 后,原生代码在调用 `ReleasePrimitiveArrayCritical` 前不应该长时间运行。两个函数之间的代码应视为“临界区”。在临界区内,原生代码不允许调用其他 JNI 函数;也不允许调用任何其他阻塞当前线程的系统调用,等待其他 Java 线程完成(例如,另一个正在执行写操作,当前线程对写入的 stream 执行读操作)。
即使 VM 本身不支持 pinning,这些限制也能让原生代码更有机会得到数组指针而非数组拷贝。例如,当原生代码通过 `GetPrimitiveArrayCritical` 取得数组指针时,VM 可能暂时禁用垃圾回收。
— JNI 指南
第4章: JNI Functions
>>>
> 译注:CPU pinning,又称 processor affinity,指将进程和某个或者某几个 CPU 关联绑定,绑定后的进程只能在所关联的 CPU 上运行。本文中 pin object 指的是把对象或子空间固定在内存中某个区域。
从上面的介绍中似乎可以得到这样的信息:当进入临界区时 VM 会停止 GC。
对于 VM 来说,实际上真正需要确保已分配的“临界区”对象不会移动。有以下几种实现:
一旦有临界区对象分配成功后”禁用GC“。这是最简单的策略,不影响 GC 的其他部分。缺点是必须无限期禁用 GC 直到用户释放,这可能会有问题。
“固定对象”并在垃圾回收过程中绕过。缺点是如果收集器希望分配连续空间或者希望回收整个堆子空间,那么就很难实现。举例来说,在使用简单逐代回收算法情况下,如果将对象固定在年轻代里,回收完成后就不能“忽略”年轻代中剩下的内容。而且也不能从这里移动对象,因为这会破坏需要保持的对象。
”固定包含指定对象的子空间“。同样的,如果 GC 以 generation 为粒度进行回收,那么这种方法无效。但如果堆按照 region 划分,那么可以固定单个 region 并且只针对该 region 禁用 GC,皆大欢喜。
有人通过 JNI Critical 临时禁用 GC,但这只对第1种情况有效。而且每种收集器都采用这种简单化方法。
实际运行的效果又该如何?
4. 实验
像往常一样,接下来通过设计实验来申请 JNI 关键区 的 `int[]` 数组,然后“故意违反”指南中的建议释放该数组。相反,在 `acquire` 和 `release` 方法之间申请并保存大量对象:
``java public class CriticalGC { static final int ITERS = Integer.getInteger("iters", 100); static final int ARR_SIZE = Integer.getInteger("arrSize", 10_000); static final int WINDOW = Integer.getInteger("window", 10_000_000); static native void acquire(int[] arr); static native void release(int[] arr); static final Object[] window = new Object[WINDOW]; public static void main(String... args) throws Throwable { System.loadLibrary("CriticalGC"); int[] arr = new int[ARR_SIZE]; for (int i = 0; i < ITERS; i++) { acquire(arr); System.out.println("Acquired"); try { for (int c = 0; c < WINDOW; c++) { window[c] = new Object(); } } catch (Throwable t) { // omit } finally { System.out.println("Releasing"); release(arr); } } } } ```
调用的原生代码:
``c #include <jni.h> #include <CriticalGC.h> static jbyte* sink; JNIEXPORT void JNICALL Java_CriticalGC_acquire (JNIEnv* env, jclass klass, jintArray arr) { sink = (*env)->GetPrimitiveArrayCritical(env, arr, 0); } JNIEXPORT void JNICALL Java_CriticalGC_release (JNIEnv* env, jclass klass, jintArray arr) { (*env)->ReleasePrimitiveArrayCritical(env, arr, sink, 0); } ```
编写头文件,把本地原生代码编译为函数库,然后确保 JVM 可以正确调用。完整代码封装在[这里][5]。
[5]:https://shipilev.net/jvm/anatomy-quarks/9-jni-critical-gclocker/critical.zip
1. Parallel 或 CMS
先用 Parallel,执行结果如下:
`` $ make run-parallel java -Djava.library.path=. -Xms4g -Xmx4g -verbose:gc -XX:+UseParallelGC CriticalGC [0.745s][info][gc] Using Parallel ... [29.098s][info][gc] GC(13) Pause Young (GCLocker Initiated GC) 1860M->1405M(3381M) 1651.290ms Acquired Releasing [30.771s][info][gc] GC(14) Pause Young (GCLocker Initiated GC) 1863M->1408M(3381M) 1589.162ms Acquired Releasing [32.567s][info][gc] GC(15) Pause Young (GCLocker Initiated GC) 1866M->1411M(3381M) 1710.092ms Acquired Releasing ... 1119.29user 3.71system 2:45.07elapsed 680%CPU (0avgtext+0avgdata 4782396maxresident)k 0inputs+224outputs (0major+1481912minor)pagefaults 0swaps ```
可以看到,在 `Acquired` 和 `Released` 方法中间没有发生 GC,从输出可以了解其中的实现细节。“GCLocker Initiated GC”就是确凿的证据。[GCLocker][6] 是一种”锁“,当 JNI 进入临界区后可以阻止 GC 运行。在 OpenJDK 代码中可以看到相关[实现][7]。
[6]:http://hg.openjdk.java.net/jdk9/jdk9/hotspot/file/f36e864e66a7/src/share/vm/gc/shared/gcLocker.hpp
[7]:http://hg.openjdk.java.net/jdk9/jdk9/hotspot/file/f36e864e66a7/src/share/vm/prims/jni.cpp#l3173
``c JNI_ENTRY(void*, jni_GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy)) JNIWrapper("GetPrimitiveArrayCritical"); GCLocker::lock_critical(thread); // <--- 获得 GCLocker! if (isCopy != NULL) { *isCopy = JNI_FALSE; } oop a = JNIHandles::resolve_non_null(array); ... void* ret = arrayOop(a)->base(type); return ret; JNI_END JNI_ENTRY(void, jni_ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode)) JNIWrapper("ReleasePrimitiveArrayCritical"); ... // 这里略掉了 array, carray, mode 参数 GCLocker::unlock_critical(thread); // <--- 释放 GCLocker! ... JNI_END ```
如果 GC 试图启动,JVM 会检查是否有人持有该锁。如果有,则对于 Parallel、CMS 和 G1 算法不会继续启动 GC。当临界区最后一个 `release` 操作完成后,VM 会检查是否有 GCLocker 阻塞挂起的 GC。如果有,则[触发 GC][8]。这样就出现了上面“GCLocker Initiated GC”的情况。
[8]:http://hg.openjdk.java.net/jdk9/jdk9/hotspot/file/f36e864e66a7/src/share/vm/gc/shared/gcLocker.cpp#l138
2. G1
既然设计的实验在 JNI 临界区“搞破坏”,那么肯定崩溃。下面是 G1 生成的结果:
`` $ make run-g1 java -Djava.library.path=. -Xms4g -Xmx4g -verbose:gc -XX:+UseG1GC CriticalGC [0.012s][info][gc] Using G1 <HANGS> ```
嗯,程序挂起了。尽管 `jstack` 还是显示进程处于 `RUNNABLE` 状态,但似乎因为一些奇怪的情况挂起了:
`` "main" #1 prio=5 os_prio=0 tid=0x00007fdeb4013800 nid=0x4fd9 waiting on condition [0x00007fdebd5e0000] java.lang.Thread.State: RUNNABLE at CriticalGC.main(CriticalGC.java:22) ```
要定位问题,最简单的办法是使用“fastdebug”构建,运行后报告断言失败如下:
`` # # A fatal error has been detected by the Java Runtime Environment: # # Internal Error (/home/shade/trunks/jdk9-dev/hotspot/src/share/vm/gc/shared/gcLocker.cpp:96), pid=17842, tid=17843 # assert(!JavaThread::current()->in_critical()) failed: Would deadlock # Native frames: (J=compiled Java code, A=aot compiled Java code, j=interpreted, Vv=VM code, C=native code) V [libjvm.so+0x15b5934] VMError::report_and_die(...)+0x4c4 V [libjvm.so+0x15b644f] VMError::report_and_die(...)+0x2f V [libjvm.so+0xa2d262] report_vm_error(...)+0x112 V [libjvm.so+0xc51ac5] GCLocker::stall_until_clear()+0xa5 V [libjvm.so+0xb8b6ee] G1CollectedHeap::attempt_allocation_slow(...)+0x92e V [libjvm.so+0xba423d] G1CollectedHeap::attempt_allocation(...)+0x27d V [libjvm.so+0xb93cef] G1CollectedHeap::allocate_new_tlab(...)+0x6f V [libjvm.so+0x94bdba] CollectedHeap::allocate_from_tlab_slow(...)+0x1fa V [libjvm.so+0xd47cd7] InstanceKlass::allocate_instance(Thread*)+0xc77 V [libjvm.so+0x13cfef0] OptoRuntime::new_instance_C(Klass*, JavaThread*)+0x830 v ~RuntimeStub::_new_instance_Java J 87% c2 CriticalGC.main([Ljava/lang/String;)V (82 bytes) ... v ~StubRoutines::call_stub V [libjvm.so+0xd99938] JavaCalls::call_helper(...)+0x858 V [libjvm.so+0xdbe7ab] jni_invoke_static(...) ... V [libjvm.so+0xdde621] jni_CallStaticVoidMethod+0x241 C [libjli.so+0x463c] JavaMain+0xa8c C [libpthread.so.0+0x76ba] start_thread+0xca ```
仔细观察上面的堆栈跟踪信息可以还原问题现场:先尝试分配新对象,但是没有 [TLAB][9] 满足分配条件,因此转到慢速分配申请新的 TLAB。接着会发现没有可用的 TLAB,分配失败。并且发现需要等待 GCLocker 启动 GC,进入 `stall_until_clear`。由于线程本身持有 GCLocker 等待会导致死锁。[代码][10]
[9]:https://shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/
[10]:http://hg.openjdk.java.net/jdk9/jdk9/hotspot/file/f36e864e66a7/src/share/vm/gc/shared/gcLocker.cpp#l95
出现这个结果是因为已经在 `acquire-release` 代码段中尝试了分配对象,在 JNI 方法结尾没有匹配的 `release` 调用。完成 `acquire-release` 之前,不应该调用 JNI,因此违反了“不应该调用 JNI 函数”原则。
虽然调整测试代码可以让垃圾收集器不报告上述错误,但会出现由于堆剩余空间过小,启动 GC 时强制进入 Full GC。
3. Shenandoah
Shenandoah 的实现和前面讨论的第2种情况一样,收集器会固定包含特定对象的 region,JNI 临界区释放之前不对该对象进行回收。
`` $ make run-shenandoah java -Djava.library.path=. -Xms4g -Xmx4g -verbose:gc -XX:+UseShenandoahGC CriticalGC ... Releasing Acquired [3.325s][info][gc] GC(6) Pause Init Mark 0.287ms [3.502s][info][gc] GC(6) Concurrent marking 3607M->3879M(4096M) 176.534ms [3.503s][info][gc] GC(6) Pause Final Mark 3879M->1089M(4096M) 0.546ms [3.503s][info][gc] GC(6) Concurrent evacuation 1089M->1095M(4096M) 0.390ms [3.504s][info][gc] GC(6) Concurrent reset bitmaps 0.715ms Releasing Acquired .... 41.79user 0.86system 0:12.37elapsed 344%CPU (0avgtext+0avgdata 4314256maxresident)k 0inputs+1024outputs (0major+1085785minor)pagefaults 0swaps ```
从上面的结果可以看到进入 JNI 临界区后 GC 循环开始和结束的整个过程。 Shenandoah 的工作只是把存储数组的 region 固定,接着继续回收其他 region。 这样就可以*不需要 GCLocker*,也不会造成 GC 暂停。
5. 观察
JNI 临界区需要来自 VM 的支持: 使用类似 GCLocker 这样的技术禁用 GC,固定包含特定对象的子空间或者只固定对象。 不同的 GC 处理 JNI 临界区的策略也各有不同,像 GC 周期延迟这样的副作用在其他 GC 上也可能不会出现。
请注意规范中的描述: *“在临界区内,原生代码不能调用其他 JNI 函数”*,这是底线。 上面的示例旨在强调这样一个事实,即便规范允许,代码实现的质量也会破坏规范。 一些 GC 会放松检查,另一些则更严谨。 如果希望保持可移植性,请遵守规范要求,而不是实现细节。
如果依赖实现细节(“ 强烈不推荐 ”),在使用 JNI 时遇到上述问题,那么就需要理解回收器的工作并选择合适的 GC。
(点击标题可跳转阅读)
Java 中 JNI 的使用 ( 上 )
Java 中 JNI 的使用 ( 下 )
杂谈 GC
看完本文有收获?请转发分享给更多人
好文章,我 在看 :heart: