JVM 鎖 bug 導致 G1 GC 掛起問題分析和解決【畢昇JDK技術剖析 · 第 2 期】
編者按:筆者在 AArch64 中遇到一個 G1 GC 掛起,CPU 利用率高達 300%的案例。經過分析發現問題是由 JVM 的鎖機制導致,該問題根因是併發程式設計中沒有正確理解記憶體序導致。本文著重介紹 JVM 中 Monitor 的基本原理,同時演示了在什麼情況下會觸發該問題。希望通過本文的分析,讀者能夠了解到記憶體序對效能、正確性的影響,在併發程式設計時更加仔細。
現象
本案例是一個典型的弱記憶體模型案例,大致的現象就是 AArch64 平臺上,業務掛死,而程序佔用 CPU 持續維持在 300%。配合 top 和 gdb,可以看到是 3 個 GC 執行緒在 offer_termination 處陷入了死迴圈:
多個並行 GC 執行緒在 Minor GC 結束時呼叫 offer_termination,在 offer_termination 中自旋等待其他並行 GC 執行緒到達該位置,才說明 GC 任務完成,可以終止。(關於並行任務的中止協議問題,可以參考相關論文,這裡不做著重介紹。
簡單地說,在並行任務執行時,多個任務之間可能存在任務不均衡,所以 JVM 內部設計了任務均衡機制,同時必須設計任務終止的機制來保證多個任務都能完成,這裡的 offer_termination 就是嘗試終止任務)。
在該案例中,部分 GC 執行緒完成自己的任務,等待其他的 GC 執行緒。此時出現掛起,很有可能是因為發生了死鎖。所以問題很可能是由於那些尚未完成任務的 GC 執行緒上錯誤地使用鎖。所以使用 gdb 觀察了一下其他 GC 執行緒,發現其他 GC 執行緒全都阻塞在一把 JVM 的鎖上:
而這把 Monitor 中的情況如下:
-
cxq 上積累了大量 GC 執行緒
-
OnDeck 記錄的 GC 執行緒已經消失
-
_owner 記錄的鎖持有者為 NULL
分析
在進一步分析前,首先普及一下 JVM 鎖元件 Monitor 的基本原理,Monitor 類主要包含 4 個核心欄位:
-
“Thread * volatile _owner” 欄位指向這把鎖的持有執行緒
-
“SplitWord_LockWord” 欄位被設計為 1 個機器字長,目的是為了確保操作時天然的原子性,它的最低位被設計為上鎖標記位,而高位區域用來存放 256 位元組對齊的競爭佇列(cxq)地址
-
“ParkEvent * volatile_EntryList” 欄位指向一個等待佇列,跟 cxq 差別不大,個人理解只是為了緩解 cxq 的競爭壓力而設計
-
“ParkEvent * volatile_OnDeck” 欄位指向這把鎖的法定繼承人,同時最低位還充當了內部鎖的角色
接下來通過一組流程圖來介紹加解鎖的具體流程:
上圖是加鎖的一個整體流程,大致分為 3 步:
首先走快速上鎖流程,主要對應鎖本身無人持有的最理想情況
接著是自旋上鎖流程,這是預期將在短時間內獲取鎖的情況
最後是慢速上鎖流程,申請者將會加入等待佇列(cxq),然後進入睡眠,直到被喚醒後發現自己變成了法定繼承者,於是進入自旋,直到完成上鎖。
而且,基於效能考慮,整個上鎖流程中的每一步幾乎都做了“插隊”的嘗試:
如上圖程式碼中所示,“插隊”的意思就是不經過排隊(cxq),直接嘗試置上鎖標誌位。
上圖就是整個解鎖流程了,顯然真正的解鎖操作在第二步中就已經完成了(意味著接下來時刻有“插隊”現象發生),剩下的主要就是選出繼承者的過程,大致分為以下幾步:
-
解鎖執行緒首先需要將內部鎖(_OnDeck)標記上鎖
-
從競爭佇列(cxq)抽取所有等待者放入等待佇列(_EntryList)
-
_ EntryList 取出頭一個元素,寫入_OnDeck 的同時解除內部鎖標記,這代表選出了繼承者
-
喚醒繼承者
當然伴隨著整個解鎖流程每一步的,還有對“插隊”行為的處理。
至此,JVM 鎖元件 Monitor 的原理就介紹到這裡,再回歸到問題本身,一個疑問就是_OnDeck 上記錄的繼承者為何消失?作為繼承者,既然已經消失在競爭佇列和等待佇列裡,顯然意味著它大概率已經持有鎖、然後解鎖走人了,所以問題很可能跟繼承者選取過程有關。基於這種猜測,我們對相關程式碼著重進行了梳理,就發現了下圖兩處紅框標記位置存在疑點,那就是在選繼承者過程第 3 步中:
寫EntryList 和寫_OnDeck 之間沒有 barrier 來保證執行順序,這可能出現_OnDeck 先於EntryList 寫入的情況,一旦繼承人提前持有鎖,後果就可能非常糟糕…
這裡貼了一張可能的問題場景:
-
執行緒 A 處於解鎖流程中,由於亂序,先寫入了繼承者同時解除內部鎖
-
執行緒 B 處於上鎖流程,發現自己就是法定繼承者後,立刻完成上鎖
-
執行緒 B 又迅速進入解鎖流程,並從_EntryList 中取出頭元素(也就是執行緒 B!)作為繼承者寫入_OnDeck,完成解鎖走人
-
執行緒 A 此時才更新_EntryList,然後喚醒繼承者(也就是執行緒 B!),完成解鎖走人
-
_OnDeck 上的繼承者執行緒 B,實際已經完成加解鎖離開,後續等待執行緒再也無法被喚醒。
正巧在社群的高版本上找到了一個相關的修復記錄,這裡貼出 2 個關鍵的程式碼片段:
上面這段程式碼位於慢速上鎖流程,被喚醒後檢查繼承者是否是自己,修復後的程式碼在讀_OnDeck 時加了 Load-Acquire 的 barrier。
上面這段程式碼位於解鎖時選繼承者流程,從_ EntryList 取出頭一個元素,寫入_OnDeck 的同時解除內部鎖標記,修復後的程式碼在寫_OnDeck 時加了 Store-Release 的 barrier。
顯然,圍繞_OnDeck 新增的這對 One-way barrier 可以確保:當繼承者執行緒被喚醒時,該執行緒可以“看”到_EntryList 已經被及時更新。
總結:
在 AArch64 這種弱記憶體模型的平臺上(關於記憶體序更多的知識在接下來的分享中會詳細介紹),一旦涉及多執行緒對公共記憶體的每一次訪問,必須反覆確認是否需要通過 barrier 來嚴格保序,而且除非存在有效的依賴關係,否則 barrier 需要在讀寫端成對使用。
後記
如果遇到相關技術問題(包括不限於畢昇 JDK),可以通過畢昇 JDK 社群求助(目前畢昇 JDK 最新的官網http://bishengjdk.openeuler.org已經上線,可以點選原文進入官網查詢所有相關資源,包括二進位制下載、程式碼倉庫、使用教學、安裝、學習資料等)。
畢昇JDK社群每雙週週二舉行技術例會,同時有一個技術交流群討論GCC、LLVM、JDK和V8等相關編譯技術,感興趣的同學可以新增如下微信小助手,回覆Compiler入群。
畢昇JDK技術剖析系列博文:
第1期:使用 perf 解決 JDK8 小版本升級後效能下降的問題
第2期:JVM 鎖 bug 導致 G1 GC 掛起問題分析和解決
畢昇JDK資訊:
本文分享自微信公眾號 - openEuler(openEulercommunity)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。
- 玩轉機密計算從 secGear 開始
- openEuler資源利用率提升之道06:虛擬機器混部OpenStack排程
- openGauss Cluster Manager RTO Test
- JVM 鎖 bug 導致 G1 GC 掛起問題分析和解決【畢昇JDK技術剖析 · 第 2 期】
- 手把手帶你玩轉 openEuler | openEuler 的使用
- 681名學生中選!暑期2021開啟火熱“開源之夏”!
- 手把手帶你玩轉 openEuler | 初識 openEuler
- StratoVirt 中的 PCI 裝置熱插拔實現
- 使用 NMT 和 pmap 解決 JVM 資源洩漏問題
- JNI 中錯誤的訊號處理導致 JVM 崩潰問題分析
- Java Flight Recorder - 事件機制詳解
- 畢昇 JDK 8u292、11.0.11 釋出!
- StratoVirt 中的虛擬網絡卡是如何實現的?
- openEuler結合ebpf提升ServiceMesh服務體驗的探索
- 我的openEuler社群參與之旅
- StratoVirt 的中斷處理是如何實現的?
- 看看畢昇 JDK 團隊是如何解決 JVM 中 CMS 的 Crash
- 使用 perf 解決 JDK8 小版本升級後效能下降的問題【畢昇JDK技術剖析 · 第 1 期】
- 2021年畢昇 JDK 的第一個重要更新來了
- 漏洞盒子 × openEuler | 廣邀白帽共築安全的Linux開放應用生態