0
  • 聊天消息
  • 系統(tǒng)消息
  • 評論與回復
登錄后你可以
  • 下載海量資料
  • 學習在線課程
  • 觀看技術視頻
  • 寫文章/發(fā)帖/加入社區(qū)
會員中心
創(chuàng)作中心

完善資料讓更多小伙伴認識你,還能領取20積分哦,立即完善>

3天內(nèi)不再提示

如何解決JVM中一個極小概率發(fā)生的bug

B4Pb_gh_6fde77c ? 來源:openEuler ? 作者:王帥 ? 2021-08-23 17:35 ? 次閱讀

編者按:筆者遇到一個非常典型 JVM 架構相關問題,在 x86 正常運行的應用,在 aarch64 環(huán)境上低概率偶現(xiàn) JVM 崩潰。這是一個典型的 JVM 內(nèi)部 bug 引發(fā)的問題。通過分析最終定位到 CMS 代碼存在 bug,導致 JVM 在弱內(nèi)存模型的平臺上 Crash。在分析過程中,涉及到 CMS 垃圾回收原理、內(nèi)存屏障、對象頭、以及 ParNew 并行回收算法中多個線程競爭處理的相關技術。筆者發(fā)現(xiàn)并修復了該問題,并推送到上游社區(qū)中。畢昇 JDK 發(fā)布的所有版本均解決了該問題,其他 JDK 在 jdk8u292、jdk11.0.9、jdk13 以后的版本修復該問題。

bug 描述

目標進程在 aarch64 平臺上運行,使用的 GC 算法為 CMS(-XX:+UseConcMarkSweepGC),會概率性地發(fā)生 JVM crash,且問題發(fā)生的概率極低。我們在 aarch64 平臺上使用 fuzz 測試,運行目標進程 50w 次只出現(xiàn)過一次 crash(連續(xù)運行了 3 天)。

JBS issue:https://bugs.openjdk.java.net/browse/JDK-8248851

約束

我們對比了 x86 和 aarch64 架構,發(fā)現(xiàn)問題僅在 aarch64 環(huán)境下會出現(xiàn)。

文中引用的代碼段取自 openjdk-8u262:http://hg.openjdk.java.net/jdk8u/jdk8u-dev/。

讀者需要對 JVM 有基本的認知,如垃圾回收,對象布局,GC 線程等,且有一定的 C++ 基礎。

背景知識

GC

GC(Garbage Collection)是 JVM 中必不可少的部分,用于回收不再會被使用到的對象,同時釋放對象占用的內(nèi)存空間。

垃圾回收對于釋放的剩余空間有兩種處理方式:

一種是存活對象不移動,垃圾對象釋放的空間用空閑鏈表(free_list)來管理,通常叫做標記-清除(Mark-Sweep)。創(chuàng)建新對象時根據(jù)對象大小從空閑鏈表中選取合適的內(nèi)存塊存放新對象,但這種方式有兩個問題,一個是空間局部性不太好,還有一個是容易產(chǎn)生內(nèi)存碎片化的問題。

另一種對剩余空間的處理方式是 Copy GC,通過移動存活對象的方式,重新得到一個連續(xù)的空閑空間,創(chuàng)建新對象時總在這個連續(xù)的內(nèi)存空間分配,直接使用碰撞指針方式分配(Bump-Pointer)。這里又分兩種情況:

將存活對象復制到另一塊內(nèi)存(to-space,也叫 survival space),原內(nèi)存塊全部回收,這種方式叫撤離(Evacuation)。

將存活對象推向內(nèi)存塊的一側,另一側全部回收,這種方式也被稱為標記-整理(Mark-Compact)。

現(xiàn)代的垃圾回收算法基本都是分代回收的,因為大部分對象都是朝生夕死的,因此將新創(chuàng)建的對象放到一塊內(nèi)存區(qū)域,稱為年輕代;將存活時間長的對象(由年輕代晉升)放入另一塊內(nèi)存區(qū)域,稱為老年代。根據(jù)不同代,采用不同回收算法。

年輕代,一般采用 Evacuation 方式的回收算法,沒有內(nèi)存碎片問題,但會造成部分空間浪費。

老年代,采用 Mark-Sweep 或者 Mark-Compact 算法,節(jié)省空間,但效率低。

GC 算法是一個較大的課題,上述介紹只是給讀者留下一個初步的印象,實際應用中會稍微復雜一些,本文不再展開。

CMS

CMS(Concurrent Mark Sweep)是一個以低時延為目標設計的 GC 算法,特點是 GC 的部分步驟可以和 mutator 線程(可理解為 Java 線程)同時進行,減少 STW(Stop-The-World)時間。年輕代使用 ParNewGC,是一種 Evacuation。老年代則采用 ConcMarkSweepGC,如同它的名字一樣,采用 Mark-Sweep(默認行為)和 Mark-Compact(定期整理碎片)方式回收,它的具體行為可以通過參數(shù)控制,這里就不展開了,不是本文的重點研究對象。

CMS 是 openjdk 中實現(xiàn)較為復雜的 GC 算法,條件分支很多,閱讀起來也比較困難。在高版本 JDK 中已經(jīng)被更優(yōu)秀和高效的 G1 和 ZGC 替代(CMS 在 JDK 13 之后版本中被移除)。

本文討論的重點主要是年輕代的回收,也就是 ParNewGC 。

對象布局

在 Java 的世界中,萬物皆對象。對象存儲在內(nèi)存中的方式,稱為對象布局。在 JVM 中對象布局如下圖所示:

對象由對象頭加字段組成,我們這里主要關注對象頭。對象頭包括markOop和_matadata。前者存放對象的標志信息,后者存放 Klass 指針。所謂 Klass,可以簡單理解為這個對象屬于哪個 Java 類,例如:String str = new String(); 對象 str 的 Klass 指針對應的 Java 類就是 Ljava/lang/String。

markOop 的信息很關鍵,它的定義如下[1]:

1. // 32 bits:

2. // --------

3. // hash:25 ------------》| age:4 biased_lock:1 lock:2 (normal object)

4. // JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)

5. // size:32 ------------------------------------------》| (CMS free block)

6. // PromotedObject*:29 ----------》| promo_bits:3 -----》| (CMS promoted object)

7. //

8. // 64 bits:

9. // --------

10. // unused:25 hash:31 --》| unused:1 age:4 biased_lock:1 lock:2 (normal object)

11. // JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)

12. // PromotedObject*:61 ---------------------》| promo_bits:3 -----》| (CMS promoted object)

13. // size:64 -----------------------------------------------------》| (CMS free block)

14. //

15. // unused:25 hash:31 --》| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)

16. // JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)

17. // narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 -----》| (COOPs && CMS promoted object)

18. // unused:21 size:35 --》| cms_free:1 unused:7 ------------------》| (COOPs && CMS free block)

對于一般的 Java 對象來說,markOop 的定義如下(以 64 位舉例):

低兩位表示對象的鎖標志:00-輕量鎖,10-重量鎖,11-可回收對象, 01-表示無鎖。

第三位表示偏向鎖標志:0-表示無鎖,1-表示偏向鎖,注意當偏向鎖標志生效時,低兩位是 01-無鎖。即 ----》|101 表示這個對象存在偏向鎖,高 54 位存放偏向的 Java 線程。

第 4-7 位表示對象年齡:一共 4 位,所以對象的年齡最大是 15。

CMS 算法還會用到 markOop,用來判斷一個內(nèi)存塊是否為 freeChunk,詳細的用法見下文分析。

_metadata 的定義如下:

1. class oopDesc {

2. friend class VMStructs;

3. private:

4. volatile markOop _mark;

5. union _metadata {

6. Klass* _klass;

7. narrowKlass _compressed_klass;

8. } _metadata;

9. _// 。.._10. }

_metadata 是一個 union,不啟用壓縮指針時直接存放 Klass 指針,啟用壓縮指針后,將 Klass 指針壓縮后存入低 32 位。高 32 位留作它用。至于為什么要啟用壓縮指針,理由也很簡單,因為每個引用類型的對象都要有 Klass 指針,啟用壓縮指針的話,每個對象都可以節(jié)省 4 個 byte,雖然看起來很小,但實際上卻可以減少 GC 發(fā)生的頻率。而壓縮的算法也很簡單,base + _narrow_klass 《《 offset 。base 和 offset 在 JVM 啟動時會根據(jù)運行環(huán)境初始化好。offset 常見的取值為 0 或者 3(8 字節(jié)對齊)。

memory barrier

內(nèi)存屏障(Memory barrier)是多核計算機為了提高性能,同時又要保證程序正確性,必不可少的一個設計。簡單來說是為了防止因為系統(tǒng)優(yōu)化,或者指令調度等因素導致的指令亂序。

所以多核處理器大都提供了內(nèi)存屏障指令,C++ 也提供了關于內(nèi)存屏障的標準接口,參考 memory order 。

總的來說分為 full-barrier 和 one-way-barrier。

full barrier 保證在內(nèi)存屏障之前的讀寫操作的真正完成之后,才能執(zhí)行屏障之后的讀寫指令。

one-way-barrier 分為 read-barrier 和 write-barrier。以 read-barrier 為例,表示屏障之后的讀寫操作不能亂序到屏障之前,但是屏障指令之前的讀寫可以亂序到屏障之后。

openjdk 中的 barrier 定義[3]

1. class OrderAccess : AllStatic {

2. public:

3. static void loadload();

4. static void storestore();

5. static void loadstore();

6. static void storeload();

8. static void acquire();

9. static void release();

10. static void fence();

11. _// 。.._12. static jbyte load_acquire(volatile jbyte* p);

13. _// 。.._14. static void release_store(volatile jint* p, jint v);

15. _// 。.._16. private:

17. _// This is a helper that invokes the StubRoutines::fence_entry()_18. _// routine if it exists, It should only be used by platforms that_19. _// don‘t another way to do the inline eassembly._20. static void StubRoutines_fence();

21. };

其中 acquire()和 release() 是 one-way-barrier, fence() 是 full-barrier。不同架構依照這個接口,實現(xiàn)對應架構的 barrier 指令。

問題分析

在問題沒有復現(xiàn)之前,我們能拿到的信息只有一個名為 hs_err_$pid.log 的文件,JVM 在發(fā)生 crash 時,會自動生成這個文件,里面包含 crash 時刻 JVM 的詳細信息。但即便如此,分析這個問題還是有相當大的困難。因為沒有 core 文件,無法查看內(nèi)存中的信息。好在我們在一臺測試環(huán)境上成功復現(xiàn)了問題,為最終解決這個問題奠定了基礎。

第一現(xiàn)場

首先我們來看下 crash 的第一現(xiàn)場。

backtrace

通過調用棧我們可以看出發(fā)生 core 的位置是在 CompactibleFreeListSpace::block_size 這個函數(shù),至于這個函數(shù)具體是干什么的,我們待會再分析。從調用棧中我們還可以看到,這是一個 ParNew 的 GC 線程。上文提到 CMS 年輕代使用 ParNewGC 作為垃圾回收器。這里 Par 指的是 Parallel(并行)的意思,即多個線程進行回收。

pc

pc 值是 0x0000ffffb2f320e8,相對這段 Instruction 開始位置 0x0000ffffb2f320c8 偏移為 0x20,將這段 Instructions 用反匯編工具得到如下指令:

根據(jù)相對偏移,我們可以計算出發(fā)生 core 的指令為 02 08 40 B9 ldr w2, [x0, #8],然后從寄存器列表,可以看出 x0(上圖中的 R0)寄存器的值為 0x54b7af4c0,這個值看起來不像是一個合法的地址。所以我們接下來看看堆的地址范圍。

heap

從堆的分布可以看出 0x54b7af4c0 肯定不在堆空間內(nèi),到這里可以懷疑大概率是訪問了非法地址導致 crash,為了更進一步確認這個猜想,我們要結合源碼和匯編,來確認這條指令的目的。

首先我們看看匯編

下圖這段匯編是由 objdump 導出 libjvm.so 得到,對應 block_size 函數(shù)一小部分:

圖中標黃的部分就是 crash 發(fā)生的地址,這個地址在 hs_err_pid.log 文件中也有體現(xiàn),程序運行時應該是由 0x4650ac 這個位置經(jīng)過 cbnz 指令跳轉過來的。而圖中標紅的這條指令是一條邏輯左移指令,結合 x5 寄存器的值是 3,我首先聯(lián)想到 x0 寄存器的值應當是一個 Klass 指針。因為在 64 位機器上,默認會開啟壓縮指針,而 hs_err_$pid.log 文件中的 narrowklass 偏移剛好是 3(heap 中的 Narrow klass shift: 3)。到這里,如果不熟悉 Klass 指針是什么,可以回顧下背景知識中的對象布局。

如果 x0 寄存器存放的是 Klass 指針,那么 ldr w2, [x0, #8] 目的就是獲取對象的大小,至于為什么,我們結合源碼來分析。

源碼分析

CompactibleFreeListSpace::block_size 源碼[4]:

1.size_t CompactibleFreeListSpace::block_size(const HeapWord* p) const {

2.NOT_PRODUCT(verify_objects_initialized());

3.// This must be volatile, or else there is a danger that the compiler_4.// will compile the code below into a sometimes-infinite loop, by keeping_5. // the value read the first time in a register._6. while (true) {

7.// We must do this until we get a consistent view of the object._8. if (FreeChunk::indicatesFreeChunk(p)) {

9. volatile FreeChunk* fc = (volatile FreeChunk*)p;

10.size_t res = fc-》size();

11.

12.// Bugfix for systems with weak memory model (PPC64/IA64)。 The_13.// block’s free bit was set and we have read the size of the_14.// block. Acquire and check the free bit again. If the block is_15.// still free, the read size is correct._16.OrderAccess::acquire();

17.

18.// If the object is still a free chunk, return the size, else it_19.// has been allocated so try again._20.if (FreeChunk::indicatesFreeChunk(p)) {

21.assert(res != 0, “Block size should not be 0”);

22.return res;

23.}

24.} else {

25.// must read from what ‘p’ points to in each loop._26.Klass* k = ((volatile oopDesc*)p)-》klass_or_null();

27.if (k != NULL) {

28.assert(k-》is_klass(), “Should really be klass oop.”);

29.oop o = (oop)p;

30.assert(o-》is_oop(true _/* ignore mark word */_), “Should be an oop.”);

31.

32.// Bugfix for systems with weak memory model (PPC64/IA64)._33.// The object o may be an array. Acquire to make sure that the array_34.// size (third word) is consistent._35.OrderAccess::acquire();

36.

37.size_t res = o-》size_given_klass(k);

38.res = adjustObjectSize(res);

39.assert(res != 0, “Block size should not be 0”);

40.return res;

41.}

42.}

43.}

44.}

這個函數(shù)的功能我們先放到一邊,首先發(fā)現(xiàn) else 分支中有關于 Klass 的判空操作,且僅有這一處,這和反匯編之后的 cbnz 指令對應。如果 k 不等于 NULL,則會馬上調用 size_given_klass(k) 這個函數(shù)[5],而這個函數(shù)第一步就是取 klass 偏移 8 個字節(jié)的內(nèi)容。和 ldr w2, [x0, #8]對應。

1. inline int oopDesc::size_given_klass(Klass* klass) {

2. int lh = klass-》layout_helper();

3. int s;

4. _// 。.._5. }

通過 gdb 查看 Klass 的 fields offset,_layout_helper 的偏移剛好是 8 。

klass-》layout_helper();這個函數(shù)就是取 Klass 的 _layout_helper 字段,這個字段在解析 class 文件時,會自動計算,如果為正,其值為對象的大小。如果為負,表示這個對象是數(shù)組,通過設置 bit 的方式來描述這個數(shù)組的信息。但無論怎樣,這個進程都是在獲取 layouthelper 時發(fā)生了 crash。

到這里,程序 core 在這個位置應該是顯而易見的了,但是為什么 klass 會讀到一個非法值呢?僅憑現(xiàn)有的信息,實在難以繼續(xù)分析。幸運的是,我們通過 fuzz 測試,成功復現(xiàn)了這個問題,雖然復現(xiàn)概率極低,但是拿到了 coredump 文件。

debug

問題復現(xiàn)后,第一步要做的就是驗證之前的分析結論:

上述標號對應指令含義如下:

narrow_klass 的值最初放在 x6 寄存器中,通過 load 指令加載到 x0 寄存器

壓縮指針解壓縮

判斷解壓縮后的 klass 指針是否為 NULL

獲取 Klass 的 layouthelper

查看上述指令相關的寄存器:

寄存器 x0 的值為 0x5b79f1c80

寄存器 x0 的值是一個非法地址

查看 narrow_klass 的 offset

查看 narrow_klass 的 base

narrow_klass 解壓縮,得到的結果是 0x100000200 和 x0 的值對應不上???

查看這個對象是什么類型,發(fā)現(xiàn)是一個 char 類型的數(shù)組。

通過以上調試基本信息,可以確認我們的猜想正確 ,但是問題是我們解壓縮后得到的 Klass 指針是正確的,也能解析出 C,這是一個有效的 Klass。

但是 x0 中的值確實一個非法值。也就是說,內(nèi)存中存放的 Klass 指針是正確的,但是 CPU 看見的 x0,也就是存放 Klass 指針的寄存器值是錯誤的。為什么會造成這種不一致呢,可能的原因是,這個地址剛被其他線程改寫,而當前線程獲取到的是寫入之前的值,這在多線程環(huán)境下是非常有可能發(fā)生的,但是如果程序寫的正確,且加入了正確的 memory barrier,也是不會有問題的,但現(xiàn)在出了問題,只能說明是程序沒有插入適當?shù)?memory barrier,或者插入得不正確。到這里,我們可以知道這個問題和內(nèi)存序有關,但具體是什么原因導致這個地方讀取錯誤,還要結合 GC 算法的邏輯進行分析。

ParNewTask

結合上文的調用棧,這個線程是在做根掃描,根掃描的意思是查找活躍對象的根,然后根據(jù)這個根集合,查找出根引用的對象的集合,進而找到所有活躍對象。因為 ParNew 是年輕代的垃圾回收器,要識別出整個年輕代的所有活躍對象。有一種可能的情況是根引用一個老年代對象 ,同時這個老年代對象又引用了年輕代的對象,那么這個年輕代的對象也應該被識別為活對象。

所以我們需要考慮上述情況,但是沒有必要掃描整個老年代的對象,這樣太影響效率了,所以會有一個表記錄老年代的哪些對象有引用到年輕代的對象。在 JVM 中有一個叫 Card Table的數(shù)據(jù)結構,專門干這個事情。

Card table

關于 Card table 的實現(xiàn)細節(jié),本文不做展開,只是簡單介紹下實現(xiàn)思路。有興趣的讀者可以參考網(wǎng)上其他關于 Card table 的文章。也可以根據(jù)本文的調用棧,去跟一下源碼中的實現(xiàn)細節(jié)。

簡單來說就是使用 1 byte 的空間記錄一段連續(xù)的 512 byte 內(nèi)存空間中老年代的對象引用關系是否發(fā)生變化。如果有,則將這個 card 標記置為 dirty,這樣做根掃描的時候,只關注這些 dirty card 即可。當找到一個 dirty card 之后,需要對整個 card 做掃描,這個時候,就需要計算 dirty card 中的一塊內(nèi)存的大小。回憶下 CMS 老年代分配算法,是采用的 freelist。也就是說,一塊連續(xù)的 dirty card,并不都是一個對象一個對象排布好的。中間有可能會產(chǎn)生縫隙,這些縫隙也需要計算大小。調用棧中的 process_stride 函數(shù)就是用來掃描一個 dirtyCard 的,而最頂層的 block_size 就是計算這個 dirtyCard 中某個內(nèi)存塊大小的。

FreeChunk::indicatesFreeChunk(p) 是用來判斷塊 p 是不是一個 freeChunk,就是這塊內(nèi)存是空的,加在 free_list 里的。如果不是一個 freeChunk,那么繼續(xù)判斷是不是一個對象,如果是一個對象,計算對象的大小,直到整個 card 遍歷完。

晉升

從上文中 gdb 的調試信息不難看出這個對象的地址為 0xc93e2a18(klass 地址 0xc93e2a20 -8),結合 heap 信息,這個對象位于老年代。如果是一個正常的老年代對象,在上一次 GC 完成之后,對象是不會移動的,那么作為對象頭的 markOop 和 Klass 是大概率不會出現(xiàn)寄存器和內(nèi)存值不一致的情況,因為這離現(xiàn)場太遠了。那么更加可能的情況是什么呢?答案就是晉升。

熟悉 GC 的朋友們肯定知道這個概念,這里我再簡單介紹下。所謂晉升就是發(fā)生 Evacuation 時,如果對象的年齡超過了閾值,那么認為這個對象是一個長期存活的對象,將它 copy 到老年代,而不是 survival space。還有一種情況是 survival space 空間已經(jīng)不足了,這時如果還有活的對象沒有 copy,那么也需要晉升到老年代。不管是那種情況,發(fā)生晉升和做根掃描這兩個線程是可以同時發(fā)生的,因為都是 ParNewTask。

到這里,問題的重點懷疑對象,放在了對象晉升和根掃描兩個線程之間沒有做好同步,從而導致根掃描時讀到錯誤的 Klass 指針。

所以簡單看下晉升實現(xiàn)[6]。

1. ConcurrentMarkSweepGeneration::par_promote {

2. HeapWord* obj_ptr = ps-》lab.alloc(alloc_sz);

3. |---》 CFLS_LAB::alloc

4. |---》FreeChunk::markNotFree

5. oop obj = oop(obj_ptr);

6. OrderAccess::storestore();

7. obj-》set_mark(m);

8. OrderAccess::storestore();

9. _// Finally, install the klass pointer (this should be volatile)._10. OrderAccess::storestore();

11. obj-》set_klass(old-》klass());

12. 。..。..

13. void markNotFree() {

14. _// Set _prev (klass) to null before (if) clearing the mark word below_15. _prev = NULL;

16. _#ifdef _LP64_

17. if (UseCompressedOops) {

18. OrderAccess::storestore();

19. set_mark(markOopDesc::prototype());

20. }

21. _#endif_

22. assert(!is_free(), “Error”);

23. }

看到這個地方,隔三岔五的一個 OrderAccess::storestore(); 我感覺到我離真相不遠了,這里已經(jīng)插了這么多 memory barrier 了,難道之前經(jīng)常出過問題嗎?但是已經(jīng)插了這么多了,難道還有問題嗎?哈哈哈…

看下代碼邏輯,首先從 freelist 中分配一塊內(nèi)存,并將其初始化為一個新的對象 oop,這里需要注意的一個地方是 markNotFree 這個函數(shù),將 prev(轉換成 oop 是對象的 Klass)設置為 NULL,然后將需要 copy 的對象的 markOop賦值給這個新對象,再然后 copy 對象體,最后再將需要 copy 對象的 Klass 賦值給新對象。這中間的幾次賦值都插入了 OrderAccess::storestore()?;貞浵卤尘爸R中的 memory barrier ,OrderAccess::storestore() 的含義是,storestore 之前的寫操作,一定比 storestore 之后的寫操作先完成。換句話說,其他線程當看到 storestore 之后寫操作時,那么它觀察到的 storestore 之前的寫操作必定能完成。

根因

通過上面的介紹,相信大家理解了 block_size 的功能,以及 par_promote 的寫入順序。那么這兩個函數(shù),或者說執(zhí)行這兩個函數(shù)的線程是如何造成 block_size 函數(shù)看見的 klass 不一致(CPU 和內(nèi)存不一致)的呢?請看下面的偽代碼:

scan card 線程先讀 klass,此時讀到取到的 klass 是一個非法地址;

par_promote 線程設置 klass 為 NULL;

par_promote 設置 markoop,判斷一塊內(nèi)存是不是一個 freeChunk,就是 markoop 的第 8 位判斷的(回憶背景知識);

scan card 線程根據(jù) markoop 判斷該內(nèi)存塊是一個對象,進入 else 分支;

par_promote 線程此時將正確的 klass 值寫入內(nèi)存;

scan card 線程發(fā)現(xiàn) klass 不是 NULL,訪問 klass 的 _layout_helper,出現(xiàn)非法地址訪問,發(fā)生 coredump。

到這里,所有的現(xiàn)象都可以解釋通了,但是線程真正執(zhí)行的時候,會發(fā)生上述情況嗎?答案是會發(fā)生的。

我們先看 scan card 線程

① 中 isfreeChunk 會讀 p(對應 par_promote 的 oop)的 markoop,④ 會讀 p 的 klass,這兩者的讀寫順序,按照程序員的正常思維,一定是先讀 markoop,再讀 klass,但是 CPU 運行時,為了提高效率,會一次性取多條指令,還可能進行指令重排,使流水線吞吐量更高。所以 klass 是完全有可能在 markoop 之前就被讀取。那么我們實際的期望是先讀 markoop,再讀 klass。那么怎樣確保呢?

接下來看下 par_promote 線程

根據(jù)之前堆 storestore 的解釋,③ 寫入 markoop 之后,scan_card 線程必定能觀察到 klass 賦值為 NULL,但也有可能直接觀察到 ⑤ klass 設置了正確的值。

我們再看下 scan card 線程

試想以下,如果 markoop 比 klass 先讀,那么在 ① 讀到的 klass,要么是 NULL,要么是正確的 Klass,如果讀到是 NULL,則會在 while(true)內(nèi)循環(huán),再次讀取,直到讀到正確的 klass。那么如果反過來 klass 比 markoop 先讀,就有可能產(chǎn)生上述標號順序的邏輯,造成錯誤。

綜上,我們只要確保 scan_card 線程中 markoop 比 klass 先讀,就能確保這段代碼邏輯無懈可擊。所以修復方案也自然而然想到,在 ① 和 ④ 之間插入 load 的 memory barrier,即加入一條 OrderAccess::loadload()。

詳細的修復 patch 見 https://hg.openjdk.java.net/jdk-updates/jdk11u/rev/ae52898b6f0d 。目前已經(jīng) backport 到 jdk8u292,以及 JDK 13。

x86 ?

至于這個問題為什么在 x86 上不會出現(xiàn),這是因為 x86 的內(nèi)存模型是 TSO(Total Store Ordering)的,他不允許讀讀亂序,從架構層面避免了這個問題。而 aarch64 內(nèi)存模型是松散模型(Relaxed),讀和寫可以任意亂序,這個問題也隨之暴露。關于這兩種內(nèi)存模型,Relaxed 的模型理論上肯定是更有性能優(yōu)勢的,但是對程序員的要求也更大。TSO 模型雖然只允許寫后讀提前,但是在大多數(shù)情況下,能夠確保程序順序和執(zhí)行順序保持一致。

總結

這是一個極小概率發(fā)生的 bug,因此隱藏的很深。解這個 bug 也耗費了很長時間,雖然最后修復方案就是一行代碼,但涉及的知識面還是比較廣的。其中 memory barrier 是一個有點繞的概念,GC 算法的細節(jié)也需要理解到位。如果讀者第一次接觸 JVM,希望有耐心看下去,反復推敲,相信你一定會有所收獲。

責任編輯:haq

聲明:本文內(nèi)容及配圖由入駐作者撰寫或者入駐合作網(wǎng)站授權轉載。文章觀點僅代表作者本人,不代表電子發(fā)燒友網(wǎng)立場。文章及其配圖僅供工程師學習之用,如有內(nèi)容侵權或者其他違規(guī)問題,請聯(lián)系本站處理。 舉報投訴
  • 內(nèi)存
    +關注

    關注

    8

    文章

    2903

    瀏覽量

    73541
  • JVM
    JVM
    +關注

    關注

    0

    文章

    155

    瀏覽量

    12168
  • JDK
    JDK
    +關注

    關注

    0

    文章

    80

    瀏覽量

    16550

原文標題:看看畢昇 JDK 團隊是如何解決 JVM 中 CMS 的 Crash

文章出處:【微信號:gh_6fde77c41971,微信公眾號:FPGA干貨】歡迎添加關注!文章轉載請注明出處。

收藏 人收藏

    評論

    相關推薦

    從原理聊JVM):染色標記和垃圾回收算法

    導讀 JAVA簡單易用的特性,能夠讓研發(fā)人員在不了解JVM的底層運行機制的情況下依舊能夠編寫出功能完善的代碼。 但是對JVM的理解,是程序員普通和優(yōu)秀的分水嶺。全面地了解
    的頭像 發(fā)表于 08-20 15:25 ?90次閱讀
    從原理聊<b class='flag-5'>JVM</b>(<b class='flag-5'>一</b>):染色標記和垃圾回收算法

    eclipse設置jvm內(nèi)存大小

    Eclipse是功能強大的集成開發(fā)環(huán)境(IDE),常用于Java開發(fā)。為了保證Eclipse的性能和穩(wěn)定性,我們可以根據(jù)需要來設置JVM內(nèi)存大小。本文將詳細介紹如何在Eclipse中設置J
    的頭像 發(fā)表于 12-06 11:43 ?1581次閱讀

    weblogic設置jvm內(nèi)存大小

    如何設置WebLogic服務器的JVM內(nèi)存大小。 、了解JVM內(nèi)存 JVM(Java Virtual Machine)是Java應用程序的運行環(huán)境。
    的頭像 發(fā)表于 12-05 14:44 ?2641次閱讀

    weblogic jvm參數(shù)配置

    ,讓我們來了解些常用的JVM參數(shù): -Xms 和 -Xmx參數(shù):這些參數(shù)分別用于設置Java虛擬機的初始堆大小和最大堆大小。-Xms設置初始堆大小,-Xmx設置最大堆大小。通過增大-Xmx參數(shù)的值,可以增加JVM能夠使用的內(nèi)存
    的頭像 發(fā)表于 12-05 14:31 ?1144次閱讀

    jvm和jmm的區(qū)別

    程序中的內(nèi)存訪問規(guī)則。盡管 JVM 和 JMM 有許多共同點,但它們也有些顯著的區(qū)別。本文將詳細介紹 JVM 和 JMM 的區(qū)別,幫助讀者更好地理解這兩概念。 首先,我們來看
    的頭像 發(fā)表于 12-05 14:27 ?1095次閱讀

    jvm配置的mx

    JVM配置中的mx參數(shù)主要用于設置JVM的最大堆內(nèi)存大小。本文將詳細介紹mx參數(shù)的作用、配置方法以及如何選擇合適的值。 、mx參數(shù)的作用 在JVM中,堆內(nèi)存用于存放對象實例以及相關數(shù)
    的頭像 發(fā)表于 12-05 14:24 ?559次閱讀

    jvm哪些區(qū)域會發(fā)生oom

    JVM 是 Java 虛擬機的縮寫,是Java程序的運行平臺。JVM 內(nèi)存被劃分為不同的區(qū)域,每個區(qū)域負責不同的任務和存儲不同類型的數(shù)據(jù)。其中,些區(qū)域容易發(fā)生內(nèi)存溢出錯誤(Out
    的頭像 發(fā)表于 12-05 11:51 ?1152次閱讀

    jvm調優(yōu)工具有哪些

    JVM調優(yōu)是提高Java應用程序性能的重要手段,而JVM調優(yōu)工具則是輔助開發(fā)人員進行調優(yōu)工作的利器。下面將介紹些常用的JVM調優(yōu)工具。 JConsole JConsole是JDK自帶
    的頭像 發(fā)表于 12-05 11:44 ?915次閱讀

    jvm參數(shù)的設置和jvm調優(yōu)

    JVM(Java虛擬機)參數(shù)的設置和調優(yōu)對于提高Java應用程序的性能和穩(wěn)定性非常重要。在本文中,我們將詳細介紹JVM參數(shù)的設置和調優(yōu)方法。 、JVM參數(shù)的設置 內(nèi)存參數(shù): -Xms
    的頭像 發(fā)表于 12-05 11:36 ?1047次閱讀

    jvm內(nèi)存模型和內(nèi)存結構

    JVM(Java虛擬機)是Java程序的運行平臺,它負責將Java程序轉換成機器碼并在計算機上執(zhí)行。在JVM中,內(nèi)存模型和內(nèi)存結構是兩重要的概念,本文將詳細介紹它們。 、
    的頭像 發(fā)表于 12-05 11:08 ?733次閱讀

    jvm內(nèi)存溢出該如何定位解決

    超出限制和堆空間不足。 定位JVM內(nèi)存溢出問題是比較復雜的任務,需要結合工具和技術來進行分析和解決。本文將介紹些常用的調試和解決內(nèi)存溢出問題的工具和技術。
    的頭像 發(fā)表于 12-05 11:05 ?1133次閱讀

    jvm內(nèi)存溢出故障排查

    溢出故障排查的方法和步驟。 確認內(nèi)存溢出錯誤 首先,我們需要確認應用程序是否確實發(fā)生了內(nèi)存溢出錯誤。內(nèi)存溢出通常會被JVM報告為OutOfMemoryError。這是致命錯誤,暗示
    的頭像 發(fā)表于 12-05 11:04 ?648次閱讀

    jvm的dump太大了怎么分析

    分析大型JVM dump文件可能會遇到的些挑戰(zhàn)。首先,JVM dump文件通常非常大,可能幾百MB或幾個GB。這是因為它們包含了JVM的完整內(nèi)存快照,包括堆和棧的所有對象和線程信息。
    的頭像 發(fā)表于 12-05 11:01 ?1877次閱讀

    開關PLL有極小概率造成USB 48MHz時鐘異常丟失

    在使用USB時,需要給USB控制器提供48MHz時鐘用于USB的總線采樣,在選擇PLL分頻作為USB 48MHz時鐘源時,開關PLL的過程中,USB分頻器有極小概率出現(xiàn)異常,USB48MHz時鐘會丟失,導致USB不能正常工作。
    發(fā)表于 10-23 08:25

    JVM指針壓縮的工作原理

    當今,Java已經(jīng)成為了世界上最流行的編程語言之。在Java的生態(tài)系統(tǒng)中,JVM(Java虛擬機)是至關重要的組成部分。JVM 是 Java 程序運行的環(huán)境,它負責將 Java 字節(jié)碼翻譯成機器碼,并執(zhí)行程序。在
    的頭像 發(fā)表于 10-16 15:08 ?617次閱讀
    <b class='flag-5'>JVM</b>指針壓縮的工作原理