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

栏目: Java · 发布时间: 7年前

内容简介:编译:ImportNew/唐尤华shipilev.net/jvm/anatomy-quarks/9-jni-critical-gclocker/

(给 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。

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

关注「ImportNew」,提升Java技能

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

好文章,我 在看 :heart:


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Python for Everyone

Python for Everyone

Cay S. Horstmann、Rance D. Necaise / John Wiley & Sons / 2013-4-26 / GBP 181.99

Cay Horstmann's" Python for Everyone "provides readers with step-by-step guidance, a feature that is immensely helpful for building confidence and providing an outline for the task at hand. "Problem S......一起来看看 《Python for Everyone》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

SHA 加密
SHA 加密

SHA 加密工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具