編者按:筆者遇到一個非常典型 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 位舉例):
低兩位表示對象的鎖標志:00-輕量鎖,10-重量鎖,11-可回收對象, 01-表示無鎖。
第三位表示偏向鎖標志:0-表示無鎖,1-表示偏向鎖,注意當偏向鎖標志生效時,低兩位是 01-無鎖。即 ----》|101 表示這個對象存在偏向鎖,高 54 位存放偏向的 Java 線程。
第 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-barrier 和 one-way-barrier。
full barrier 保證在內存屏障之前的讀寫操作的真正完成之后,才能執行屏障之后的讀寫指令。
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。不同架構依照這個接口,實現對應架構的 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._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.}
這個函數的功能我們先放到一邊,首先發現 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
問題復現后,第一步要做的就是驗證之前的分析結論:
上述標號對應指令含義如下:
narrow_klass 的值最初放在 x6 寄存器中,通過 load 指令加載到 x0 寄存器
壓縮指針解壓縮
判斷解壓縮后的 klass 指針是否為 NULL
獲取 Klass 的 layouthelper
查看上述指令相關的寄存器:
寄存器 x0 的值為 0x5b79f1c80
寄存器 x0 的值是一個非法地址
查看 narrow_klass 的 offset
查看 narrow_klass 的 base
narrow_klass 解壓縮,得到的結果是 0x100000200 和 x0 的值對應不上???
查看這個對象是什么類型,發現是一個 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 和內存不一致)的呢?請看下面的偽代碼:
scan card 線程先讀 klass,此時讀到取到的 klass 是一個非法地址;
par_promote 線程設置 klass 為 NULL;
par_promote 設置 markoop,判斷一塊內存是不是一個 freeChunk,就是 markoop 的第 8 位判斷的(回憶背景知識);
scan card 線程根據 markoop 判斷該內存塊是一個對象,進入 else 分支;
par_promote 線程此時將正確的 klass 值寫入內存;
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 線程
試想以下,如果 markoop 比 klass 先讀,那么在 ① 讀到的 klass,要么是 NULL,要么是正確的 Klass,如果讀到是 NULL,則會在 while(true)內循環,再次讀取,直到讀到正確的 klass。那么如果反過來 klass 比 markoop 先讀,就有可能產生上述標號順序的邏輯,造成錯誤。
綜上,我們只要確保 scan_card 線程中 markoop 比 klass 先讀,就能確保這段代碼邏輯無懈可擊。所以修復方案也自然而然想到,在 ① 和 ④ 之間插入 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,希望有耐心看下去,反復推敲,相信你一定會有所收獲。
責任編輯:haq
-
內存
+關注
關注
8文章
3040瀏覽量
74167 -
JVM
+關注
關注
0文章
158瀏覽量
12241 -
JDK
+關注
關注
0文章
81瀏覽量
16604
原文標題:看看畢昇 JDK 團隊是如何解決 JVM 中 CMS 的 Crash
文章出處:【微信號:gh_6fde77c41971,微信公眾號:FPGA干貨】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論