一個(gè) JVM 解釋器 bug 在 AArch64 平臺(tái)導(dǎo)致應(yīng)用崩潰的問(wèn)題分析
[導(dǎo)讀]編者按:筆者遇到一個(gè)非常典型的問(wèn)題,應(yīng)用在X86正常運(yùn)行,在AArch64上JVM就會(huì)崩潰。這個(gè)典型的JVM內(nèi)部問(wèn)題。筆者通過(guò)分析最終定位到是由于JVM中模板解釋器代碼存在bug導(dǎo)致在弱內(nèi)存模型的平臺(tái)上Crash。在分析過(guò)程中,涉及到非常多的JVM內(nèi)部知識(shí),比如對(duì)象頭、GC復(fù)制算...
編者按:筆者遇到一個(gè)非常典型的問(wèn)題,應(yīng)用在 X86 正常運(yùn)行,在 AArch64 上 JVM 就會(huì)崩潰。這個(gè)典型的 JVM 內(nèi)部問(wèn)題。筆者通過(guò)分析最終定位到是由于 JVM 中模板解釋器代碼存在 bug 導(dǎo)致在弱內(nèi)存模型的平臺(tái)上 Crash。在分析過(guò)程中,涉及到非常多的 JVM 內(nèi)部知識(shí),比如對(duì)象頭、GC 復(fù)制算法操作、CAS 操作、字節(jié)碼執(zhí)行、內(nèi)存序等,希望對(duì)讀者有所幫助。本文介紹了一般分析 JVM crash 的方法,并且深入介紹了為什么在 aarch64 平臺(tái)上引起這樣的問(wèn)題,最后還給出了修改方法并推送到上游社區(qū)中。**對(duì)于使用非畢昇 JDK 的其他 JDK 只有在 jdk8u292、jdk11.0.9、jdk15以后的版本才得到修復(fù),讀者使用時(shí)需要注意版本選擇避免這類問(wèn)題發(fā)生。
背景知識(shí)
java 程序在發(fā)生 crash 時(shí),會(huì)生成 hs_err_pid約束
文中描述的問(wèn)題適用于 jdk8u292 之前的版本。現(xiàn)象
某業(yè)務(wù)線隔十天半個(gè)月總會(huì)報(bào)過(guò)來(lái) crash 問(wèn)題,crash 位置比較統(tǒng)一,都是在某處執(zhí)行 young gc 的上下文中,crash 的直接原因是 java 對(duì)象的頭被寫(xiě)壞了,比如這樣:而正常的對(duì)象頭由 markoop 和 metadata 兩部分組成,前者存放該對(duì)象的 hash 值、年齡、鎖信息等,后者存放該對(duì)象所屬的 Klass 指針。這里關(guān)注的是 markoop,64 位機(jī)器上它的具體布局如下:每種布局中每個(gè)字段的詳細(xì)含義可以在 jdk 源碼 jdk8u/hotspot/src/share/vm/oops/markOop.hpp 中找到,這里簡(jiǎn)單給出結(jié)論就是 gc 階段一個(gè)正常對(duì)象頭中的 markoop 不可能是全 0,而是比如這樣:此外,crash 時(shí)間上也有個(gè)特點(diǎn):基本每次都發(fā)生在程序剛啟動(dòng)時(shí)的幾秒內(nèi)。分析
發(fā)生 crash 的 java 對(duì)象有個(gè)一致的特點(diǎn),就是總位于 eden 區(qū),我們仔細(xì)分析了 crash 位置的 gc 過(guò)程邏輯,特別是會(huì)在 gc 期間修改對(duì)象頭的相關(guān)源碼更是重點(diǎn)關(guān)注對(duì)象,因?yàn)槟菈K代碼為了追求性能,使用了無(wú)鎖編程:補(bǔ)充介紹一下 CAS(Compare And Swap),CAS 的完整意思是比較并替換,并且確保整個(gè)操作原子性。CAS 需要 3 個(gè)操作數(shù):內(nèi)存地址 dst,比較值 cmp,要更新的目標(biāo)值 value。當(dāng)且僅當(dāng)內(nèi)存地址 dst 上的值跟比較值 cmp 相等時(shí),將內(nèi)存地址 dst 上的值改寫(xiě)為 value,否則就什么都不做,其在 aarch64 上的匯編實(shí)現(xiàn)類似如下:然而我們經(jīng)過(guò)反復(fù)推敲,這塊 gc 邏輯似乎無(wú)懈可擊,而且位于 eden 區(qū)也意味著沒(méi)有被 gc 搬移過(guò)的可能性,這個(gè)問(wèn)題在很長(zhǎng)時(shí)間里陷入了停滯……直到某一天又收到了一個(gè)類似的 crash,這個(gè)問(wèn)題才迎來(lái)了轉(zhuǎn)機(jī)。在這個(gè) crash 里,也是 java 對(duì)象的頭被寫(xiě)壞了,但特殊的地方在于,頭上的錯(cuò)誤值是 0x2000,憑著職業(yè)敏感,我們猜測(cè)這個(gè)特殊的錯(cuò)誤值是否來(lái)自這個(gè) java 對(duì)象本身呢?這個(gè)對(duì)象的 Java 名字叫 DynamicByteBuffer,來(lái)自某個(gè)基礎(chǔ)組件。反編譯得到了問(wèn)題類 DynamicByteBuffer 的代碼:再結(jié)合 core 信息中其他正常 DynamicByteBuffer 對(duì)象的布局,確定了這個(gè)特殊的 0x2000 值原本應(yīng)該位于 segmentSize 字段上,而且從代碼中注意到這個(gè) segmentSize 字段是 final 屬性,意味著其值只可能在實(shí)例構(gòu)造函數(shù)中被設(shè)置,使用 jdk 自帶的命令 javap 進(jìn)行反匯編,得到對(duì)應(yīng)的字節(jié)碼如下:putfield 這條字節(jié)碼的作用是給 java 對(duì)象的一個(gè)字段賦值,在紅框中的語(yǔ)義就是給 DynamicByteBuffer 對(duì)象的 segmentSize 字段賦值。分析到這里,我們做一下小結(jié),crash 的第一現(xiàn)場(chǎng)并非在 gc 上下文中,而是得往前追溯,發(fā)生在這個(gè) java 對(duì)象被初始化期間,這期間在初始化它的 segmentSize 字段時(shí),因?yàn)槟撤N原因,0x2000 被寫(xiě)到了對(duì)象頭上。接下來(lái)繼續(xù)分析, JDK 在發(fā)生 crash 時(shí)會(huì)自動(dòng)生成的 hs_err 日志,其中有記錄最近發(fā)生的編譯事件 “Compilation events (250 events)”,從中沒(méi)有發(fā)現(xiàn) DynamicByteBuffer 構(gòu)造函數(shù)相關(guān)的編譯事件,所以可以推斷 crash 時(shí) DynamicByteBuffer 這個(gè)類的構(gòu)造函數(shù)尚未被編譯過(guò)(由于 crash 發(fā)生在程序啟動(dòng)那幾秒,JIT 往往需要預(yù)熱后才會(huì)介入,所以可以假設(shè)記錄的比較完整),這意味著,它的構(gòu)造函數(shù)只會(huì)通過(guò)模板解釋器去執(zhí)行,更具體地說(shuō),是去執(zhí)行模板解釋器中的 putfield 指令來(lái)把 0x2000 寫(xiě)到 segmentSize 字段位置。具體怎么寫(xiě)其實(shí)很簡(jiǎn)單,就是先拿到 segmentSize 字段的偏移量,根據(jù)偏移量定位到寫(xiě)的位置,然后寫(xiě)入。然而 JVM 的模板解釋器在實(shí)現(xiàn)這個(gè) putfield 指令時(shí),額外增加了一條快速實(shí)現(xiàn)路徑,在 runtime 期間會(huì)自動(dòng)(具體的時(shí)間點(diǎn)是 “完整” 執(zhí)行完第一次 putfield 指令后)從慢速路徑切到快速路徑上,這個(gè)切換操作的實(shí)現(xiàn)全程沒(méi)有加鎖,同步完全依賴 barrier,由于整個(gè)過(guò)程比較復(fù)雜,這里首先給一個(gè)比較容易理解的并行流程圖:注:圖中 bcp 指的是 bytecode pointer,就是讀字節(jié)碼。上圖表示接近同一時(shí)間點(diǎn)前后,兩條并行流分別構(gòu)建一個(gè) DynamicByteBuffer 類型的對(duì)象過(guò)程中,各自完成 segmentSize 字段賦值的過(guò)程,用 Java 代碼簡(jiǎn)單示意如下:其中第一條執(zhí)行流走的慢速路徑,第二條走的快速路徑,可以留意到,紅色標(biāo)識(shí)的是幾次公共內(nèi)存的訪存操作,barrier 就分布在這些位置前后(標(biāo)在下圖中)。接下來(lái)再給一個(gè)更加精確一點(diǎn)的指令流模型:簡(jiǎn)單介紹一下這個(gè)設(shè)計(jì)模型:- 線程從記錄了指令的內(nèi)存地址 bcp(bytecode pointer) 上取出指令,然后跳轉(zhuǎn)到該指令地址上執(zhí)行,當(dāng)取出的指令是 bcp1(比如 putfeild 指令的慢速路徑)時(shí)就是圖中左邊的指令流;
- 左邊的指令流就是計(jì)算出字段的 offset 并 str 到指定內(nèi)存地址,然后插入 barrier,最后將 bcp2 指令(比如 putfeild 指令的快速路徑)覆寫(xiě)到步驟 1 中的內(nèi)存地址 addr 上;
- 后續(xù)線程繼續(xù)執(zhí)行步驟 1 時(shí),由于取出的指令變成了 bcp2,就改為跳轉(zhuǎn)到圖中右邊的指令流;
- 右邊的指令流就是直接取出步驟 2 中已經(jīng)存到指定內(nèi)存地址中的 offset。





