美文网首页
【译】JVM Anatomy Park #9: JNI 临界区

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

作者: 袁世超 | 来源:发表于2018-11-21 23:46 被阅读60次

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

    问题

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

    理论

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

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

    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
    <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
    

    仔细观察调用链,我们可以重建发生的事情:尝试分配新的对象,因为没有 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 临界区

          本文链接:https://www.haomeiwen.com/subject/rhzfqqtx.html