【译】JVM Anatomy Park #9: JNI 临界区 与 GC 锁

原文地址:JVM Anatomy Park #9: JNI Critical and GC Locker

问题

JNI Get*Critical 如何与 GC 协同?GC Locker 是什么?

理论

如果你熟悉 JNI,那么你会知道有两组方法可以获取数组内容。一组是 GetArray* 方法,另一组是这些:

void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);

这两个方法的语义与 Get/Release*ArrayElements 方法类似。如果可能,VM 将返回原始数组的指针;否则返回一个副本。然而,在使用上存在很多限制。

—— 《JNI 指南》 第四章:JNI 方法

这样做的好处很明显:与其给你一个 Java 数组的副本,VM 可以直接给你返回一个指针,这样就能提高性能。当然这样做也有很多坑,下面将一一罗列:

调用 GetPrimitiveArrayCritical 方法之后,本地代码在调用 ReleasePrimitiveArrayCritical 之前不能执行太长时间。我们需要将这两次调用之间的代码当做“临界区”看待。在临界区中,本地代码不能调用其它的 JNI 方法,也不能执行引起当前线程阻塞等待其它线程的系统调用。(例如,当前线程不能读取其它线程写的流)

这些限制使得本地代码更可能获取非拷贝的数组,即使 VM 不支持钉住。例如,当本地代码持有 GetPrimitiveArrayCritical 返回的指针时,VM 可能会暂时关闭垃圾收集。

—— 《JNI 指南》 第四章:JNI 方法

这一段读起来的意思好像是,在临界区执行的时候 VM 将会停止 GC。

实际上对于 VM 来说唯一的强不变式是维护在“临界区”持有的对象不被移动。有很多不同的实现策略可以尝试:

  1. 当持有临界区对象的时候完全关闭 GC。这就是最简单的复制策略,因为这将不会影响接下来的 GC。缺点是你不得不无限期的阻塞 GC(只能寄希望于用户足够快的“释放”),这将会造成很多问题。
  2. 钉住对象,在收集的时候忽略它。如果收集器期望分配连续的空间,或者期望处理整个堆子空间,那么就比较难实现了。例如,如果你将对象分配在新生代,那么就不能简单的“忽略”收集了。你也不能移动对象,因为这就打破了不变式。
  3. 钉住包含对象的子空间。如果 GC 的粒度是整代,那么也很难实现。但是如果你的堆是分块的,那么你可以钉住单个块,让 GC 忽略这个块,这样就能实现不变式了。

我们曾经看到有些人依赖 JNI 临界区暂时关闭 GC,但是这仅仅对第一种策略有效,实际上并不是每个收集器都采用这种最简单的策略。

我们可以通过代码验证么?

实验

像往常一样,我们可以这样构建测试用例,在 JNI 临界区获取 int[] 数组,然后故意忽略释放数组的建议。相反,我们在获取和释放之间分配并持有大量对象:

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);
      }
    }
  }
}

本地代码部分:

#include 
#include 

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 可以加载库文件。所有的文件都打包在这里。

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 是阻止 JNI 临界区发生 GC 的。看一下 OpenJDK 代码中的相关片段:

JNI_ENTRY(void*, jni_GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy))
  JNIWrapper("GetPrimitiveArrayCritical");
  GCLocker::lock_critical(thread);   // <--- acquire 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");
  ...
  // The array, carray and mode arguments are ignored
  GCLocker::unlock_critical(thread); // <--- release GCLocker!
  ...
JNI_END

当尝试执行 GC 的时候,JVM 将会检查这个锁是否被持有。如果某个线程持有锁,那么就不能继续执行 GC,至少在 Parallel、CMS 和 G1 中是这样。当下一个临界区 JNI 操作结束时“释放”了锁,VM 将会检查是否有 GCLocker 阻塞的 GC,如果有那么就触发 GC。这就产生了“GCLocker Initiated GC”。

G1

当然,因为我们正在玩火 —— 在 JNI 临界区做奇怪的事情 —— 所以随时可能爆炸。再观察一下 G1 收集器的行为:

$ make run-g1
java -Djava.library.path=. -Xms4g -Xmx4g -verbose:gc -XX:+UseG1GC CriticalGC
[0.012s][info][gc] Using G1

哎哟!程序中止了。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

仔细观察调用链,我们可以重建发生的事情:尝试分配新的对象,因为没有 TLABs 满足分配,所以尝试获取新的 TLAB。然后发现没有可用的 TLABs,尝试分配,失败,发现需要等待 GCLocker 才能开始 GC。进入 stall_until_clear 方法等待锁。。。但是因为线程一直持有 GCLocker,这里的等待将会导致死锁。爆炸。

这是符合规范的,因为这个测试用例尝试在获取释放代码块中间分配对象。离开 JNI 方法而不调用 release 是错误的。在没有离开 JNI 方法之前,不调用 JNI 是不能进行分配的,而这违反了“不可调用 JNI 方法”的准则。

你可以调整测试用例以避免这样的问题,但是你会发现 GCLocker 将会延迟收集,这意味着仅剩很少空间的时候才会开始 GC,而这将会导致 Full GC。哎哟。

Shenandoah

就像理论描述的那样,分块的收集器可以钉住持有对象的特定内存块,让特定的内存块在 JNI 临界区释放前避免收集。Shenandoah 当前就是这样实现的。

$ 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 临界区持有期间,CC 周期从开始到结束。Shenandoah 仅仅钉住持有数组的内存块,而其他的内存块正常进行收集。当 JNI 临界区持有的对象在被收集的内存块中时也可以执行 GC,首先排除对应的内存块,然后钉住这个块(也就是将它排除出收集集合)。这就能实现不用 GCLocker 的 JNI 临界区,因此也没有 GC 延迟。

观察

处理 JNI 临界区需要 VM 的帮助,或者关闭 GC,或者采用 GCLocker 类似的机制,或者钉住包含对象的子空间,或者仅仅钉住对象。不同的 GCs 采用不同的策略处理 JNI 临界区,某个收集器的副作用 —— 比如延迟 GC 周期 —— 可能并不会出现在另一个收集器中。

请注意规范中:在临界区中,本地代码不能调用其它 JNI 方法,这仅仅是最低的要求。上述测试表明,在规范允许的范围内,实现的质量决定了打破规范时的严重程度。某些 GC 更宽松,而某些会更严格。如果你想要保证可移植性,那么请遵循规范,而不是实现细节。

如果你依赖实现细节(这一个坏主意),使用 JNI 遇到了这些问题,那么需要理解收集器的处理策略,并且选择合适的 GC。

你可能感兴趣的:(【译】JVM Anatomy Park #9: JNI 临界区 与 GC 锁)