[轉帖]一個 JVM 直譯器bug在 AArch64 平臺導致應用崩潰的問題分析原創

济南小老虎發表於2024-05-23
https://heapdump.cn/article/2589275

編者按:筆者遇到一個非常典型的問題,應用在 X86 正常執行,在 aarch64 上 JVM 就會崩潰。這個典型的 JVM 內部問題。筆者透過分析最終定位到是由於 JVM 中模板直譯器程式碼存在 bug 導致在弱記憶體模型的平臺上 Crash。在分析過程中,涉及到非常多的 JVM 內部知識,比如物件頭、GC 複製演算法操作、CAS 操作、位元組碼執行、記憶體序等,希望對讀者有所幫助。本文介紹了一般分析 JVM crash 的方法,並且深入介紹了為什麼在 aarch64 平臺上引起這樣的問題,最後還給出了修改方法並推送到上游社群中。對於使用非畢昇 JDK 的其他 JDK 只有在 jdk8u292、jdk11.0.9、jdk15以後的版本才得到修復,讀者使用時需要注意版本選擇避免這類問題發生。

背景知識

java 程式在發生 crash 時,會生成 hs_err_pid<XXX>.log 檔案,以及 core 檔案(需要作業系統開啟相關設定),其中 hs_err 檔案以文字格式記錄了 crash 發生位置的小範圍精確現場資訊(呼叫棧、暫存器、執行緒棧、致命訊號、指令上下文等)、jvm 各元件狀態資訊(java 堆、jit 事件、gc 事件)、系統層面資訊(環境變數、入參、記憶體使用資訊、系統版本)等,精簡記錄了關鍵資訊。而 core 檔案是程式崩潰時程序的二進位制快照,完整記錄了崩潰現場資訊,可以使用 gdb 工具來開啟 core 檔案,恢復出一個崩潰現場,方便分析。

約束

文中描述的問題適用於 jdk8u292 之前的版本。

現象

某業務線隔十天半個月總會報過來 crash 問題,crash 位置比較統一,都是在某處執行 young gc 的上下文中,crash 的直接原因是 java 物件的頭被寫壞了,比如這樣:
image.png
而正常的物件頭由 markoop 和 metadata 兩部分組成,前者存放該物件的 hash 值、年齡、鎖資訊等,後者存放該物件所屬的 Klass 指標。這裡關注的是 markoop,64 位機器上它的具體佈局如下:
image.png
每種佈局中每個欄位的詳細含義可以在 jdk 原始碼 jdk8u/hotspot/src/share/vm/oops/markOop.hpp 中找到,這裡簡單給出結論就是 gc 階段一個正常物件頭中的 markoop 不可能是全 0,而是比如這樣:
image.png
此外,crash 時間上也有個特點:基本每次都發生在程式剛啟動時的幾秒內。

分析

發生 crash 的 java 物件有個一致的特點,就是總位於 eden 區,我們仔細分析了 crash 位置的 gc 過程邏輯,特別是會在 gc 期間修改物件頭的相關原始碼更是重點關注物件,因為那塊程式碼為了追求效能,使用了無鎖程式設計:
image.png
補充介紹一下 CAS(Compare And Swap),CAS 的完整意思是比較並替換,並且確保整個操作原子性。CAS 需要 3 個運算元:記憶體地址 dst,比較值 cmp,要更新的目標值 value。當且僅當記憶體地址 dst 上的值跟比較值 cmp 相等時,將記憶體地址 dst 上的值改寫為 value,否則就什麼都不做,其在 aarch64 上的彙編實現類似如下:
image.png
然而我們經過反覆推敲,這塊 gc 邏輯似乎無懈可擊,而且位於 eden 區也意味著沒有被 gc 搬移過的可能性,這個問題在很長時間裡陷入了停滯……
直到某一天又收到了一個類似的 crash,這個問題才迎來了轉機。在這個 crash 裡,也是 java 物件的頭被寫壞了,但特殊的地方在於,頭上的錯誤值是 0x2000,憑著職業敏感,我們猜測這個特殊的錯誤值是否來自這個 java 物件本身呢?這個物件的 Java 名字叫 DynamicByteBuffer,來自某個基礎元件。反編譯得到了問題類 DynamicByteBuffer 的程式碼:
image.png
再結合 core 資訊中其他正常 DynamicByteBuffer 物件的佈局,確定了這個特殊的 0x2000 值原本應該位於 segmentSize 欄位上,而且從程式碼中注意到這個 segmentSize 欄位是 final 屬性,意味著其值只可能在例項建構函式中被設定,使用 jdk 自帶的命令 javap 進行反彙編,得到對應的位元組碼如下:
image.png
putfield 這條位元組碼的作用是給 java 物件的一個欄位賦值,在紅框中的語義就是給 DynamicByteBuffer 物件的 segmentSize 欄位賦值。
分析到這裡,我們做一下小結,crash 的第一現場並非在 gc 上下文中,而是得往前追溯,發生在這個 java 物件被初始化期間,這期間在初始化它的 segmentSize 欄位時,因為某種原因,0x2000 被寫到了物件頭上。

接下來繼續分析, JDK 在發生 crash 時會自動生成的 hs_err 日誌,其中有記錄最近發生的編譯事件 “Compilation events (250 events)”,從中沒有發現 DynamicByteBuffer 建構函式相關的編譯事件,所以可以推斷 crash 時 DynamicByteBuffer 這個類的建構函式尚未被編譯過(由於 crash 發生在程式啟動那幾秒,JIT 往往需要預熱後才會介入,所以可以假設記錄的比較完整),這意味著,它的建構函式只會透過模板直譯器去執行,更具體地說,是去執行模板直譯器中的 putfield 指令來把 0x2000 寫到 segmentSize 欄位位置。
具體怎麼寫其實很簡單,就是先拿到 segmentSize 欄位的偏移量,根據偏移量定位到寫的位置,然後寫入。然而 JVM 的模板直譯器在實現這個 putfield 指令時,額外增加了一條快速實現路徑,在 runtime 期間會自動(具體的時間點是 “完整” 執行完第一次 putfield 指令後)從慢速路徑切到快速路徑上,這個切換操作的實現全程沒有加鎖,同步完全依賴 barrier,由於整個過程比較複雜,這裡首先給一個比較容易理解的並行流程圖:
image.png
注:圖中 bcp 指的是 bytecode pointer,就是讀位元組碼。
上圖表示接近同一時間點前後,兩條並行流分別構建一個 DynamicByteBuffer 型別的物件過程中,各自完成 segmentSize 欄位賦值的過程,用 Java 程式碼簡單示意如下:
image.png
其中第一條執行流走的慢速路徑,第二條走的快速路徑,可以留意到,紅色標識的是幾次公共記憶體的訪存操作,barrier 就分佈在這些位置前後(標在下圖中)。
接下來再給一個更加精確一點的指令流模型:
image.png
簡單介紹一下這個設計模型:
1.執行緒從記錄了指令的記憶體地址 bcp(bytecode pointer) 上取出指令,然後跳轉到該指令地址上執行,當取出的指令是 bcp1(比如 putfeild 指令的慢速路徑)時就是圖中左邊的指令流;
2.左邊的指令流就是計算出欄位的 offset 並 str 到指定記憶體地址,然後插入 barrier,最後將 bcp2 指令(比如 putfeild 指令的快速路徑)覆寫到步驟 1 中的記憶體地址 addr 上;
3.後續執行緒繼續執行步驟 1 時,由於取出的指令變成了 bcp2,就改為跳轉到圖中右邊的指令流;
4.右邊的指令流就是直接取出步驟 2 中已經存到指定記憶體地址中的 offset。
回顧整個設計模型,左邊的指令流透過一個等效於完整 dmb 的 barrier 來保證 str offset 和 str bcp2 這兩條 str 指令的執行順序並且全域性可見;而右邊的指令流中,ldr bcp 和 ldr offset 這兩條 ldr 指令之間沒有任何 barrier,設計者可能認為一個無條件跳轉指令可以為兩條 ldr 指令建立依賴,從而保證執行順序,然而從實測結果來看是不成立的。
這裡先來簡單補充介紹一下記憶體順序模型的概念,現代 CPU 為了提高執行效率,在指令的執行順序上擁有很大的自主權,對每個獨立的 CPU 來說,只要確保語義不變,實際如何執行都有可能,這種方式對於單個 CPU 來說沒有問題,當放到多個 CPU 共享資料的時候,這種亂序執行的行為就會引發每個 CPU 看到資料的順序不一致問題,導致跨 CPU 的程式邏輯亂套了。這就需要對讀、寫記憶體指令進行約束,來規範每個 CPU 看到的記憶體生效行為,由此提出了記憶體順序模型的概念:
image.png
其中 ARM 採用的是一種弱記憶體模型,這種模型預設對讀、寫指令沒有任何約束,需要由程式設計師自己透過插入 barrier 來手動保證。
再回到這個問題上,測試方式是在 ldr offset 指令後額外加了檢測指令:
image.png
就是檢查 offset 值是否為 0,如果為 0 則直接強制 crash(設計上保證了 java 物件的任何例項欄位的 offset 不可能是 0)。
經過長時間測試,程式果然在這個位置觸發了 crash!這說明上面提到的兩條 ldr 指令不存在依賴關係,或者說這種依賴關係類似 ARMv8 手冊中描述的條件依賴,並不能保證執行順序。ldr offset 指令先於 ldr bcp 執行,使得讀到一個非法的 offset 值 0。更說明了,這才是這個案例的第一案發現場!
找到了問題的根因後,解決方法也就順利出爐了,那就是在兩條 ldr 指令之間插入 barrier 來確保這兩條 ldr 指令不發生亂序。實測證明,這種修復方案非常有效,這類 crash 現象消失。
詳細的修復 patch 見 https://hg.openjdk.java.net/jdk/jdk/rev/b9529fcbbd33 。目前已經 backport 到 jdk8u292、jdk11.0.9、jdk15。

總結

Java 虛擬機器 (JVM) 為了追求效能,大量使用了無鎖程式設計進行設計,而且這麼多年以來 JDK(特別是 JDK8)主要都是面向 X86 平臺開發的,如今才慢慢的開始支援 aarch64 平臺,所以 aarch64 弱記憶體序問題是我們面臨的一個比較嚴峻的挑戰。

後記

如果遇到相關技術問題(包括不限於畢昇JDK),可以進入畢昇JDK社群查詢相關資源(點選原文進入官網),包括二進位制下載、程式碼倉庫、使用教學、安裝、學習資料等。畢昇JDK社群每雙週週二舉行技術例會,同時有一個技術交流群討論GCC、LLVM、JDK和V8等相關編譯技術,感興趣的同學可以新增如下微信小助手,回覆Compiler入群。

相關文章