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

原文地址: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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • rljs by sennchi Timeline of History Part One The Cognitiv...
    sennchi阅读 7,312评论 0 10
  • 我走在大街上 感觉天空很静 很静
    看见_08d5阅读 116评论 0 1
  • 许多年过去,仍旧改不掉爱脱鞋的臭毛病。 彼时夏天很热,教室很闷,人也总是很燥。每天的晨跑和课间操压榨了所有凉鞋的风...
    The_outs阅读 369评论 0 0
  • 无意中看到一本书的文案,完美男人,于是我果断的把书放在我的书架。 以前的我认为完美的男人应该是英俊、有...
    熊猫续梦阅读 414评论 0 0
  • 文/忧喜 著名诗人李商隐的“此情可待成追忆?只是当时已惘然”一词,流传千古,吟唱古今。确实,有些事过去了就回不到原...
    忧喜阅读 291评论 0 1