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

2019 Java 开发者跳槽指南.pdf (吐血整理)….>>>

编译: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` 方法之间申请并保存大量对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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);
      }
    }
  }
}

调用的原生代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#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,执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 生成的结果:

1
2
3
4
$ make run-g1
java -Djava.library.path=. -Xms4g -Xmx4g -verbose:gc -XX:+UseG1GC CriticalGC
[0.012s][info][gc] Using G1
<HANGS>

嗯,程序挂起了。尽管 `jstack` 还是显示进程处于 `RUNNABLE` 状态,但似乎因为一些奇怪的情况挂起了:

1
2
3
"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”构建,运行后报告断言失败如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#
# 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 临界区释放之前不对该对象进行回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ 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。

 

原文始发于微信公众号(ImportNew):JVM 解剖公园:JNI 临界区与 GC Locker