(給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 來說,實際上真正需要確保已分配的“臨界區”物件不會移動。有以下幾種實現:
- 一旦有臨界區物件分配成功後”禁用GC“。這是最簡單的策略,不影響 GC 的其他部分。缺點是必須無限期禁用 GC 直到使用者釋放,這可能會有問題。
- “固定物件”併在垃圾回收過程中繞過。缺點是如果收集器希望分配連續空間或者希望回收整個堆子空間,那麼就很難實現。舉例來說,在使用簡單逐代回收演演算法情況下,如果將物件固定在年輕代裡,回收完成後就不能“忽略”年輕代中剩下的內容。而且也不能從這裡移動物件,因為這會破壞需要保持的物件。
- ”固定包含指定物件的子空間“。同樣的,如果 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。
朋友會在“發現-看一看”看到你“在看”的內容