看看畢昇 JDK 團隊是如何解決 JVM 中 CMS 的 Crash

語言: CN / TW / HK

編者按:筆者遇到一個非常典型 JVM 架構相關問題,在 x86 正常執行的應用,在 aarch64 環境上低概率偶現 JVM 崩潰。這是一個典型的 JVM 內部 bug 引發的問題。通過分析最終定位到 CMS 程式碼存在 bug,導致 JVM 在弱記憶體模型的平臺上 Crash。在分析過程中,涉及到 CMS 垃圾回收原理、記憶體屏障、物件頭、以及 ParNew 並行回收演算法中多個執行緒競爭處理的相關技術。筆者發現並修復了該問題,並推送到上游社群中。畢昇 JDK 釋出的所有版本均解決了該問題,其他 JDK 在 jdk8u292、jdk11.0.9、jdk13 以後的版本修復該問題。

bug 描述

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

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

約束

  • 我們對比了 x86 和 aarch64 架構,發現問題僅在 aarch64 環境下會出現。

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

  • 讀者需要對 JVM 有基本的認知,如垃圾回收,物件佈局,GC 執行緒等,且有一定的 C++ 基礎。

背景知識

GC

GC(Garbage Collection)是 JVM 中必不可少的部分,用於回收不再會被使用到的物件,同時釋放物件佔用的記憶體空間。

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

  • 一種是存活物件不移動,垃圾物件釋放的空間用空閒連結串列(free_list)來管理,通常叫做 標記-清除(Mark-Sweep)。建立新物件時根據物件大小從空閒連結串列中選取合適的記憶體塊存放新物件,但這種方式有兩個問題,一個是空間區域性性不太好,還有一個是容易產生記憶體碎片化的問題。

  • 另一種對剩餘空間的處理方式是 Copy GC,通過移動存活物件的方式,重新得到一個連續的空閒空間,建立新物件時總在這個連續的記憶體空間分配,直接使用碰撞指標方式分配(Bump-Pointer)。這裡又分兩種情況:

  • 將存活物件複製到另一塊記憶體(to-space,也叫 survival space),原記憶體塊全部回收,這種方式叫撤離(Evacuation)

  • 將存活物件推向記憶體塊的一側,另一側全部回收,這種方式也被稱為標記-整理(Mark-Compact)

現代的垃圾回收演算法基本都是分代回收的,因為大部分物件都是朝生夕死的,因此將新建立的物件放到一塊記憶體區域,稱為年輕代;將存活時間長的物件(由年輕代晉升)放入另一塊記憶體區域,稱為老年代。根據不同代,採用不同回收演算法。

  • 年輕代,一般採用 Evacuation 方式的回收演算法,沒有記憶體碎片問題,但會造成部分空間浪費。
  • 老年代,採用 Mark-Sweep 或者 Mark-Compact 演算法,節省空間,但效率低。

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

CMS

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

CMS 是 openjdk 中實現較為複雜的 GC 演算法,條件分支很多,閱讀起來也比較困難。在高版本 JDK 中已經被更優秀和高效的 G1 和 ZGC 替代(CMS 在 JDK 13 之後版本中被移除)。

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

物件佈局

在 Java 的世界中,萬物皆物件。物件儲存在記憶體中的方式,稱為物件佈局。在 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 位舉例):

  1. 低兩位表示物件的鎖標誌:00-輕量鎖,10-重量鎖,11-可回收物件, 01-表示無鎖。
  2. 第三位表示偏向鎖標誌:0-表示無鎖,1-表示偏向鎖,注意當偏向鎖標誌生效時,低兩位是 01-無鎖。即 ---->|101 表示這個物件存在偏向鎖,高 54 位存放偏向的 Java 執行緒。
  3. 第 4-7 位表示物件年齡:一共 4 位,所以物件的年齡最大是 15。

CMS 演算法還會用到 markOop,用來判斷一個記憶體塊是否為 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 指標,啟用壓縮指標的話,每個物件都可以節省 4 個 byte,雖然看起來很小,但實際上卻可以減少 GC 發生的頻率。而壓縮的演算法也很簡單,base + _narrow_klass << offset 。base 和 offset 在 JVM 啟動時會根據執行環境初始化好。offset 常見的取值為 0 或者 3(8 位元組對齊)。

memory barrier

記憶體屏障(Memory barrier)是多核計算機為了提高效能,同時又要保證程式正確性,必不可少的一個設計。簡單來說是為了防止因為系統優化,或者指令排程等因素導致的指令亂序。

所以多核處理器大都提供了記憶體屏障指令,C++ 也提供了關於記憶體屏障的標準介面,參考 memory order 。

總的來說分為 full-barrierone-way-barrier

  • full barrier 保證在記憶體屏障之前的讀寫操作的真正完成之後,才能執行屏障之後的讀寫指令。

  • one-way-barrier 分為 read-barrierwrite-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-barrierfence()full-barrier。不同架構依照這個介面,實現對應架構的 barrier 指令。

問題分析

在問題沒有復現之前,我們能拿到的資訊只有一個名為 hs_err_$pid.log 的檔案,JVM 在發生 crash 時,會自動生成這個檔案,裡面包含 crash 時刻 JVM 的詳細資訊。但即便如此,分析這個問題還是有相當大的困難。因為沒有 core 檔案,無法檢視記憶體中的資訊。好在我們在一臺測試環境上成功復現了問題,為最終解決這個問題奠定了基礎。

第一現場

首先我們來看下 crash 的第一現場。

  • backtrace

通過呼叫棧我們可以看出發生 core 的位置是在 CompactibleFreeListSpace::block_size 這個函式,至於這個函式具體是幹什麼的,我們待會再分析。從呼叫棧中我們還可以看到,這是一個 ParNew 的 GC 執行緒。上文提到 CMS 年輕代使用 ParNewGC 作為垃圾回收器。這裡 Par 指的是 Parallel(並行)的意思,即多個執行緒進行回收。

  • pc

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

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

  • heap

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

  • 首先我們看看彙編

下圖這段彙編是由 objdump 匯出 libjvm.so 得到,對應 block_size 函式一小部分:

圖中標黃的部分就是 crash 發生的地址,這個地址在 hs_err_pid.log 檔案中也有體現,程式執行時應該是由 0x4650ac 這個位置經過 cbnz 指令跳轉過來的。而圖中標紅的這條指令是一條邏輯左移指令,結合 x5 暫存器的值是 3,我首先聯想到 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._
6while (true) {
7.// We must do this until we get a consistent view of the object._
8if (FreeChunk::indicatesFreeChunk(p)) {
9volatile 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.}

這個函式的功能我們先放到一邊,首先發現 else 分支中有關於 Klass 的判空操作,且僅有這一處,這和反彙編之後的 cbnz 指令對應。如果 k 不等於 NULL,則會馬上呼叫 size_given_klass(k) 這個函式[5],而這個函式第一步就是取 klass 偏移 8 個位元組的內容。和 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();這個函式就是取 Klass 的 _layout_helper 欄位,這個欄位在解析 class 檔案時,會自動計算,如果為正,其值為物件的大小。如果為負,表示這個物件是陣列,通過設定 bit 的方式來描述這個陣列的資訊。但無論怎樣,這個程序都是在獲取 layouthelper 時發生了 crash。

到這裡,程式 core 在這個位置應該是顯而易見的了,但是為什麼 klass 會讀到一個非法值呢?僅憑現有的資訊,實在難以繼續分析。幸運的是,我們通過 fuzz 測試,成功復現了這個問題,雖然復現概率極低,但是拿到了 coredump 檔案。

debug

問題復現後,第一步要做的就是驗證之前的分析結論:

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

  1. narrow_klass 的值最初放在 x6 暫存器中,通過 load 指令載入到 x0 暫存器
  2. 壓縮指標解壓縮
  3. 判斷解壓縮後的 klass 指標是否為 NULL
  4. 獲取 Klass 的 layouthelper

檢視上述指令相關的暫存器:

  1. 暫存器 x0 的值為 0x5b79f1c80
  2. 暫存器 x0 的值是一個非法地址
  3. 檢視 narrow_klassoffset
  4. 檢視 narrow_klassbase
  5. narrow_klass 解壓縮,得到的結果是 0x100000200x0 的值對應不上???
  6. 檢視這個物件是什麼型別,發現是一個 char 型別的陣列。

通過以上除錯基本資訊,可以確認我們的猜想正確 ,但是問題是我們解壓縮後得到的 Klass 指標是正確的,也能解析出 C,這是一個有效的 Klass。

但是 x0 中的值確實一個非法值。也就是說,記憶體中存放的 Klass 指標是正確的,但是 CPU 看見的 x0,也就是存放 Klass 指標的暫存器值是錯誤的。為什麼會造成這種不一致呢,可能的原因是,這個地址剛被其他執行緒改寫,而當前執行緒獲取到的是寫入之前的值,這在多執行緒環境下是非常有可能發生的,但是如果程式寫的正確,且加入了正確的 memory barrier,也是不會有問題的,但現在出了問題,只能說明是程式沒有插入適當的 memory barrier,或者插入得不正確。到這裡,我們可以知道這個問題和記憶體序有關,但具體是什麼原因導致這個地方讀取錯誤,還要結合 GC 演算法的邏輯進行分析。

ParNewTask

結合上文的呼叫棧,這個執行緒是在做根掃描,根掃描的意思是查詢活躍物件的根,然後根據這個根集合,查找出根引用的物件的集合,進而找到所有活躍物件。因為 ParNew 是年輕代的垃圾回收器,要識別出整個年輕代的所有活躍物件。有一種可能的情況是根引用一個老年代物件 ,同時這個老年代物件又引用了年輕代的物件,那麼這個年輕代的物件也應該被識別為活物件。

所以我們需要考慮上述情況,但是沒有必要掃描整個老年代的物件,這樣太影響效率了,所以會有一個表記錄老年代的哪些物件有引用到年輕代的物件。在 JVM 中有一個叫 Card Table的資料結構,專門幹這個事情。

Card table

關於 Card table 的實現細節,本文不做展開,只是簡單介紹下實現思路。有興趣的讀者可以參考網上其他關於 Card table 的文章。也可以根據本文的呼叫棧,去跟一下原始碼中的實現細節。

簡單來說就是使用 1 byte 的空間記錄一段連續的 512 byte 記憶體空間中老年代的物件引用關係是否發生變化。如果有,則將這個 card 標記置為 dirty,這樣做根掃描的時候,只關注這些 dirty card 即可。當找到一個 dirty card 之後,需要對整個 card 做掃描,這個時候,就需要計算 dirty card 中的一塊記憶體的大小。回憶下 CMS 老年代分配演算法,是採用的 freelist。也就是說,一塊連續的 dirty card,並不都是一個物件一個物件排布好的。中間有可能會產生縫隙,這些縫隙也需要計算大小。呼叫棧中的 process_stride 函式就是用來掃描一個 dirtyCard 的,而最頂層的 block_size 就是計算這個 dirtyCard 中某個記憶體塊大小的。

FreeChunk::indicatesFreeChunk(p) 是用來判斷塊 p 是不是一個 freeChunk,就是這塊記憶體是空的,加在 free_list 裡的。如果不是一個 freeChunk,那麼繼續判斷是不是一個物件,如果是一個物件,計算物件的大小,直到整個 card 遍歷完。

晉升

從上文中 gdb 的除錯資訊不難看出這個物件的地址為 0xc93e2a18(klass 地址 0xc93e2a20 -8),結合 heap 資訊,這個物件位於老年代。如果是一個正常的老年代物件,在上一次 GC 完成之後,物件是不會移動的,那麼作為物件頭的 markOop 和 Klass 是大概率不會出現暫存器和記憶體值不一致的情況,因為這離現場太遠了。那麼更加可能的情況是什麼呢?答案就是晉升。

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

到這裡,問題的重點懷疑物件,放在了物件晉升和根掃描兩個執行緒之間沒有做好同步,從而導致根掃描時讀到錯誤的 Klass 指標。

所以簡單看下晉升實現[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(); 我感覺到我離真相不遠了,這裡已經插了這麼多 memory barrier 了,難道之前經常出過問題嗎?但是已經插了這麼多了,難道還有問題嗎?哈哈哈…

看下程式碼邏輯,首先從 freelist 中分配一塊記憶體,並將其初始化為一個新的物件 oop,這裡需要注意的一個地方是 markNotFree 這個函式,將 prev(轉換成 oop 是物件的 Klass)設定為 NULL,然後將需要 copy 的物件的 markOop賦值給這個新物件,再然後 copy 物件體,最後再將需要 copy 物件的 Klass 賦值給新物件。這中間的幾次賦值都插入了 OrderAccess::storestore()。回憶下背景知識中的 memory barrier ,OrderAccess::storestore() 的含義是,storestore 之前的寫操作,一定比 storestore 之後的寫操作先完成。換句話說,其他執行緒當看到 storestore 之後寫操作時,那麼它觀察到的 storestore 之前的寫操作必定能完成。

根因

通過上面的介紹,相信大家理解了 block_size 的功能,以及 par_promote 的寫入順序。那麼這兩個函式,或者說執行這兩個函式的執行緒是如何造成 block_size 函式看見的 klass 不一致(CPU 和記憶體不一致)的呢?請看下面的虛擬碼:

  1. scan card 執行緒先讀 klass,此時讀到取到的 klass 是一個非法地址;
  2. par_promote 執行緒設定 klass 為 NULL
  3. par_promote 設定 markoop,判斷一塊記憶體是不是一個 freeChunk,就是 markoop 的第 8 位判斷的(回憶背景知識);
  4. scan card 執行緒根據 markoop 判斷該記憶體塊是一個物件,進入 else 分支;
  5. par_promote 執行緒此時將正確的 klass 值寫入記憶體;
  6. scan card 執行緒發現 klass 不是 NULL,訪問 klass_layout_helper,出現非法地址訪問,發生 coredump

到這裡,所有的現象都可以解釋通了,但是執行緒真正執行的時候,會發生上述情況嗎?答案是會發生的。

  • 我們先看 scan card 執行緒

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

  • 接下來看下 par_promote 執行緒

根據之前堆 storestore 的解釋,③ 寫入 markoop 之後,scan_card 執行緒必定能觀察到 klass 賦值為 NULL,但也有可能直接觀察到 ⑤ klass 設定了正確的值。

  • 我們再看下 scan card 執行緒

試想以下,如果 markoopklass 先讀,那麼在 ① 讀到的 klass,要麼是 NULL,要麼是正確的 Klass,如果讀到是 NULL,則會在 while(true)內迴圈,再次讀取,直到讀到正確的 klass。那麼如果反過來 klassmarkoop 先讀,就有可能產生上述標號順序的邏輯,造成錯誤。

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

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

x86 ?

至於這個問題為什麼在 x86 上不會出現,這是因為 x86 的記憶體模型是 TSO(Total Store Ordering)的,他不允許讀讀亂序,從架構層面避免了這個問題。而 aarch64 記憶體模型是鬆散模型(Relaxed),讀和寫可以任意亂序,這個問題也隨之暴露。關於這兩種記憶體模型,Relaxed 的模型理論上肯定是更有效能優勢的,但是對程式設計師的要求也更大。TSO 模型雖然只允許寫後讀提前,但是在大多數情況下,能夠確保程式順序和執行順序保持一致。

總結

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

後記

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

參考

[1]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markoop.hpp: L37~L54

[2]https://developer.arm.com/documentation/100941/0100/barriers

[3]https://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/orderaccess.hpp:L243~L316

[4]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/bca17e38de00/src/share/vm/gc_implementation/concurrentmarksweep/compactiblefreelistspace.cpp:L986~L1017

[5]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.inline.hpp:L403~L481

[6]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/gc_implementation/concurrentmarksweep/concurrentmarksweepgeneration.cpp:L1354https://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/orderAccess.hpp:L243~L316

[4]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/bca17e38de00/src/share/vm/gc_implementation/concurrentmarksweep/compactiblefreelistspace.cpp:L986~L1017

[5]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.inline.hpp:L403~L481

[6]http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/gc_implementation/concurrentmarksweep/concurrentmarksweepgeneration.cpp:L1354


本文分享自微信公眾號 - openEuler(openEulercommunity)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。