歡迎光臨
每天分享高質量文章

JVM 解剖公園:JNI 臨界區與 GC Locker

(給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 的人知道有兩組讀取陣列內容的方法,包括 `GetArray*` [系列][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 
#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 可以正確呼叫。完整程式碼封裝在[這裡][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);   // 
  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); // 
  ...
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

```

 

嗯,程式掛起了。儘管 `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。

贊(0)

分享創造快樂