查看原文
其他

JVM 解剖公园:JNI 临界区与 GC Locker

ImportNew ImportNew 2021-12-02

(给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 来说,实际上真正需要确保已分配的“临界区”对象不会移动。有以下几种实现:


  1. 一旦有临界区对象分配成功后”禁用GC“。这是最简单的策略,不影响 GC 的其他部分。缺点是必须无限期禁用 GC 直到用户释放,这可能会有问题。

  2. “固定对象”并在垃圾回收过程中绕过。缺点是如果收集器希望分配连续空间或者希望回收整个堆子空间,那么就很难实现。举例来说,在使用简单逐代回收算法情况下,如果将对象固定在年轻代里,回收完成后就不能“忽略”年轻代中剩下的内容。而且也不能从这里移动对象,因为这会破坏需要保持的对象。

  3. ”固定包含指定对象的子空间“。同样的,如果 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


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存