【大記憶體服務GC實踐】- 一文看懂”ParNew+CMS”垃圾回收器
因為工作的需要,筆者前前後後分別接觸了HBase RegionServer、HiveServer\Metastore以及HDFS NameNode這些大記憶體JVM服務。 在和這些JVM系統打交道的過程中,GC優化始終是一個繞不過去的話題,有的是因為GC導致NameNode RPC請求耗時增大,有的是因為GC導致RegionServer/HiveServer/Metastore經常宕機。在優化的過程中,筆者花時間系統地學習並梳理了CMS、G1GC以及ZGC這幾款垃圾回收器的原理,並基於這些原理進行了多次線上GC問題的定位以及優化。這個系列的文章初步安排了多篇:
- 【大記憶體服務GC實踐】- 一文看懂”ParNew+CMS”組合垃圾回收器
- 【大記憶體服務GC實踐】- “ParNew+CMS”組合垃圾回收器實踐案例(一)
- 【大記憶體服務GC實踐】- “ParNew+CMS”組合垃圾回收器實踐案例(二)
- 【大記憶體服務GC實踐】- 一文看懂G1垃圾回收器
- 【大記憶體服務GC實踐】- G1垃圾回收器實踐案例(一)
- 【大記憶體服務GC實踐】- G1垃圾回收器實踐案例(二)
- 【大記憶體服務GC實踐】- 一文看懂ZGC垃圾回收器
- 【大記憶體服務GC實踐】- ZGC垃圾回收器實踐案例
這是這個系列的第一篇文章,我們來聊聊”ParNew+CMS”垃圾回收器是怎麼工作的。Java開發工程師同學肯定對這個組合垃圾回收器有所瞭解,因為這通常是面試內容的一部分。根據筆者的面試經驗,大多數面試者是停留在比較基礎、初級的理論知識上面的。無論是關於ParNew還是CMS垃圾回收器,網上其實有很多相關的介紹,但筆者覺得大部分都比較零散,沒有系統完整地對其進行介紹。希望這篇文章能夠比較全方位地、邏輯清晰地、深入地將”ParNew+CMS”垃圾回收器介紹清楚。
從我們的認知說起
面試”ParNew+CMS”相關知識,面試官一般會從這幾個簡單的問題開始:
- JVM記憶體為什麼要分代?
- 新生代GC觸發條件是什麼?簡單介紹一下新生代GC演算法。
- 在哪些條件下物件會從新生代晉升到老年代?
- 老年代GC觸發條件是什麼?簡單介紹一下老年代GC演算法。
- FGC觸發條件是什麼?
- 如果一個Java系統(CMS回收器,下同)新生代GC耗時長,可以考慮從哪些方面分析優化?
- 如果一個Java系統(CMS回收器,下同)老年代GC耗時長,可以考慮從哪些方面分析優化?
- 如果一個Java系統頻繁發生FGC,可以考慮從哪些方面分析優化?
這些問題粗粗一看還是容易回答的,但是如果面試官深入地問其中的一些細節,比如”CMS回收演算法中Card Table的作用主要有哪些?”,估計會難倒不少同學。另外,諸如6、7、8這三個實踐類的問題,更是Java系統優化診斷專家的試金石。 那這篇文章我們就深入地看看前面5個理論性質的問題,接下來一兩篇文章再通過幾個真實大資料生產線上的案例介紹”ParNew+CMS”組合回收器的優化實踐。
JVM堆為什麼要分代?
分代的垃圾回收策略,是基於兩個假設:
- 不同物件的生命週期是不一樣的。可以大體分成兩類,一類稱為短壽物件,這類物件存活時間很短,比如區域性變數、短連結物件。與之對應的稱為長壽物件,比如資料快取、session物件等。
- 大部分Java應用中短壽物件佔比都佔絕大多數,這類物件可以很快就會被回收。
基於這兩個事實假設,大多數GC演算法都採用分代回收機制。將JVM堆劃分成兩個區域,一個小的新生代,一個大的老年代。新生代放置短壽物件,可以採用比較頻繁的回收策略,每次可以回收掉大量的垃圾物件。老年代放置長壽物件,可以採用與新生代不同的回收策略。
新生代GC觸發條件是什麼?簡單介紹一下新生代GC演算法?
應用程式在Eden區生成新物件,一旦Eden區滿了之後就會觸發新生代GC,新生代GC演算法使用複製演算法。複製演算法對Eden區以及S0區的物件進行標記,標記出活躍物件,然後將活躍物件複製到S1區。複製演算法不會產生記憶體碎片。對於標記演算法中如何判斷一個物件是活躍的還是不活躍的(垃圾物件),現在一般使用可達性分析演算法。
對吧,90%以上的同學都會這麼回答。但這裡面有很多細節沒有講清楚,比如標記使用的可達性分析演算法是什麼演算法?具體如何標記一個物件?將活躍物件複製到S1之後,引用這些物件的指標如何變化?這些問題再深入地問下去,能回答出來的就少之又少了,但是理解這些基本知識是接下來深入理解G1\ZGC的基礎,所以非常有必要在這裡進行一番介紹。
如何判斷一個物件是否活躍?
判斷物件是否活躍目前一般使用可達性分析演算法。通過一系列稱為”GC Roots”的元素作為起始點,從這些節點開始向下搜尋,當一個物件到GC Roots沒有任何引用鏈相連時,則說明此物件是不活躍的。
“GC Roots”是什麼?它本質上是一組活躍的引用,注意不是物件。容易理解的有執行緒棧上的引用變數、靜態變數到物件的引用,在分代演算法中從非收集區域指向收集區域物件的引用等。
新生代GC是Stop-The-World(以下簡稱STW)的,即只有標記執行緒工作,這樣就可以將新生代堆想象成一個引用鏈快照,不會有應用執行緒去修改這個引用鏈,這樣就可以使用深度優先演算法進行引用遍歷。JVM中具體的實現演算法是三色標記演算法,演示圖(來自網上,下面相關圖相同)如下:
我們把遍歷物件圖過程中遇到的物件,按”是否訪問過”這個條件標記成以下三種顏色:
- 白色:尚未訪問過。
- 黑色:本物件已訪問過,而且本物件引用到的其他物件也全部訪問過了。
- 灰色:本物件已訪問過,但是本物件引用到的其他物件尚未全部訪問完。全部訪問後,會轉換為黑色。
假設現在有白、灰、黑三個集合(表示當前物件的顏色),其遍歷訪問過程為:
- 初始時,所有物件都在【白色集合】中。
- 將 GC Roots 直接引用到的物件挪到 【灰色集合】中。
- 從灰色集合中獲取物件:
(1)將本物件引用到的其他物件全部挪到 【灰色集合】中。
(2)將本物件挪到【黑色集合】裡面。
- 重複步驟3,直至【灰色集合】為空時結束。
- 結束後,仍在【白色集合】的物件即為 GC Roots 不可達,可以進行回收。
當STW時,物件間的引用是不會發生變化的,可以輕鬆完成標記(這句話重點記得,下文還會提到)。如下圖所示,標記完成後為A、D、E、F和G節點都變成了黑色,B、C和H都是白色,表示B、C、H三個物件GC Roots不可達,為垃圾物件:
如何標記一個活躍物件?
通過三色標記法可以找到所有的活躍物件,那怎麼標記這些活躍物件呢?目前主要使用點陣圖標記活躍物件,堆中每個物件都有一個對應的點陣圖,如果是活躍物件,該點陣圖設定為1,否則設定為0。
如何跨區遷移物件?
到目前為止,通過三色標記法我們找到了新生代中所有活躍物件,並將對應的點陣圖進行了標記。接著,我們需要將這些活躍物件從Eden/S0區移動到S1區。整個移動過程分為如下3個子步驟:
- 在S1區為物件申請特定大小的記憶體。
- 初始化物件,並將物件的欄位進行賦值。
- 全部遷移完成之後將Eden/S0區的所有物件清除。
如何修改引用物件指標地址?
遷移完成之後還有一個問題,既然這些活躍物件從一個地方遷移到了另一個地方,那所有引用這些物件的物件指標就需要相應地修改,對吧?那這裡就有一個問題,引用新生代物件的目標物件怎麼找到呢?
分析一下,這些要找的目標肯定不在新生代,因為新生代存活的物件已經遷移了。那目標物件肯定存在於老年代,也就是有一些老年代的物件引用了新生代的物件,後者地址發生變化之後就要通知前者進行指標變化。問題來了,如何精確找到老年代中的哪個物件引用了新生代的物件呢?
有一個方案是掃描整個老年代的所有物件,但是這樣的效率必然不高,尤其是在老年代記憶體設定非常大的情況下效率就會更差。於是JVM演算法設計者設計了使用一個Card Table記錄老年代物件到新生代物件的引用方案。
這裡多說一句,通常有兩種方法記錄物件之間的引用關係,一種為Point Out,一種為Point In。假設有這樣的引用關係,物件A的成員變數指向物件B(虛擬碼為:ObjA.Field = ObjB),對於Point Out的記錄方式來說,會在物件A(ObjA)的Card Table中記錄物件B(ObjB)的地址。對於Point In的記錄方式來說,會在物件B(ObjB)的Card Table中記錄物件A(ObjA)的地址,這相當於一種反向引用。這二者的區別在於處理時有所不同:Point Out方式在記錄這種引用關係的時候比較簡單,但是在反向查詢時需要對Card Table做全部掃描。Point In記錄引用關係操作相對稍微複雜,但是在標記掃描時可以直接找到有用和無用的物件,不需要進行額外的掃描,因為Card Table裡面的物件可以看作根物件。”ParNew+CMS”組合回收器中老年代到新生代的跨代引用使用的是Point Out模式。
那Card Table是個什麼資料結構呢?我們將老年代這個連續的堆記憶體空間劃分成連續的512Byte記憶體塊,Card Table是一個連續的陣列,陣列的每個元素大小是1Byte,分別對映老年代堆記憶體的512Byte空間。如果老年代的某個物件產生了跨代引用,就將對應Card Table上的陣列元素標記為Dirty,這個Card就是所謂的Dirty Card。如下示意圖所示:
有了Card Table,就不需要掃描整個老年代空間,而只需要掃描Dirty Cards對應的堆空間,遍歷這些堆空間的物件進行必要的調整即可,這樣可以大大提升掃描的效率,調整完成之後將對應的Dirty Card重置。
Card Table上的Card什麼時候會被設定為Dirty?
這裡需要引入一個非常關鍵的概念 – 寫屏障。寫屏障可以簡單理解為一個鉤子函式,對一個物件引用進行寫操作(即引用賦值)之前或之後附加執行的邏輯。JVM通過寫屏障維護Card Table,如果某一個老年代物件引用一個新生代物件,在將引用賦值寫入到記憶體之前,會執行一段特定程式碼將老年代物件所在記憶體區域對應的Card設定為Dirty。沒錯,這聽起來就像AOP。
在哪些條件下物件會從新生代晉升到老年代?
- 躲過15次新生代GC後晉升到老年代(15是預設情況)。
- 大物件直接進入老年代。
- 動態物件年齡判斷機制:假如當Survivor區中,相同年齡的物件總大小大於這Survivor區域總大小的50%,那麼大於等於這批物件年齡的物件,在下次YGC後就會晉升到老年代。
- YGC後存活物件太多超過Survivor區大小,通過分配擔保機制晉升到老年代。
老 年 代GC觸發條件是什麼?簡單介紹一下老 年 代GC演算法?
老年代使用記憶體佔老年代實際大小比例超過一定閾值(可以通過引數-XX:CMSInitiatingOccupancyFraction配置)之後會觸發老年代GC。老年代GC使用併發標記清理演算法,演算法分為初始標記、併發標記、預清理、可中斷的預清理、再標記、併發清理以及併發重置狀態等7個步驟,其中初始標記、再標記以及併發清理3個階段是STW。下面是一段完整老年代GC的日誌片段:
現在我們深入地分析一下這些步驟:
1. 初始標記 。從GC Roots集合以及新生代物件出發,標記直接引用的物件。示意圖如下所示:
2. 併發標記。 從初識標記階段標記出來的物件開始基於”三色標記法”找出所有存活物件並標記。這個階段應用執行緒會和標記執行緒併發執行。假如此時只有標記執行緒工作,那併發標記前後的示意圖如下:
然而實際上,應用執行緒會和標記執行緒一起併發執行,這就可能出現如下幾種情況:
- 物件引用被刪除。
- 應用執行緒直接在老年代分配新物件。
- 新生代物件晉升到老年代。
- 老年代物件之間引用發生變更。
這樣的話,併發標記完成後可能不是上面的示意圖,而是如下這種示意圖:
分別來看這幾種情況:
- 物件引用被刪除(上圖場景1):假如一個物件在被標記為活躍物件之後引用關係被刪除。因為該物件已經被標記為活躍物件,所以它不會在本次GC中被回收。但是理論上來講,這個物件是應該被回收的。應該被回收的物件沒有被回收,這種情況不影響正確性,但會產生”浮動垃圾”。這種現象稱為“多標”。
- 直接在老年代分配新物件(上圖場景2):如果在標記執行緒執行結束之後應用執行緒重新new了一些新物件(比如大物件)併產生了引用關係。這些物件本應該被標記為活躍物件但實際上沒有被標記,就會出現正確性問題。這是“併發標記”引入的第一個問題。
- 新生代物件晉升到老年代(上圖場景3):因為應用執行緒在工作,所以Eden區就可能會滿,進而觸發YGC,YGC之後就會有新生代物件晉升到老年代。晉升到老年代的物件本應該被標記為活躍物件但實際上沒有被標記,就會出現正確性問題。與場景2類似。
- 老年代物件之間引用發生變更(上圖場景4):這個場景稍微複雜一點,使用下面的示意圖分析一下。
假設標記執行緒已經遍歷到物件B(回想三色標記法,物件B變為灰色),這個時候應用執行緒執行了如下程式碼:
objB.fieldC = null; objA.filedC = C;
對應示意圖中物件B和物件C之間的引用關係斷掉,然後物件A和物件C之間建立新的引用關係。注意物件A和物件C之間雖然建立了新的引用關係,但是物件A已經是黑色了,不會再重新做遍歷處理了。最終導致的結果就是:物件C會一直是白色,最後被當作垃圾清理掉。 很顯然,這直接影響到了應用程式的正確性,是不可接受的。這種現象稱為“漏標”,這是“併發標記”引入的第二個問題。
可見,併發標記可以讓應用執行緒與標記執行緒一起工作,不需要STW。但是會引入如下兩個問題:
- 新增老年代物件沒有被標記。
- 引用變更導致的漏標問題。
很顯然,這些新增物件必須被重新標記上。那怎麼重新標記這些新增的物件呢?
對於場景2,只能重新掃描GC Roots。
對於場景3,只能重新掃描新生代物件。
對於場景4,如果在引用變更的時候記錄下對應的物件,比如上述場景如果能夠記錄下物件A,重新標記的時候從物件A開始重新標記,就可以相對快速的完成重新標記。實際實現中再次用到了上文介紹到的Card Table,當引用發生變更時,將物件A所在的Card標記為Dirty。後續只需掃描這些Dirty Cards的物件,避免掃描整個老年代。
這裡有一個問題,如果在老年代併發標記的過程中同時發生了一次YGC。上文我們說過YGC會掃描Card Table中的Dirty Cards,找到跨代引用,同時在YGC完成後將Dirty Cards清空。很顯然,增量更新和YGC這兩個過程共用Card Table會產生衝突,一旦YGC完成之後將某個Dirty Card清空,但是這個Dirty Card剛好是”併發標記”過程中引用變更標記的,就會導致漏標。為了解決這個問題,CMS演算法引入另一種資料結構Mod Union Table,它是一個位數組,陣列中每個元素分別對應一個Card。基於這個新結構,在每次YGC處理完髒卡之前,會將該Dirty Card在Mod Union Table中對應的陣列位置1。這樣CMS在執行重新標記階段的時候,掃描Mod Union Table和Card Table裡面被標記的項,找到所有可能的Dirty Card。
3. 併發預處理。
4. 可中斷預處理。
這兩個階段都是為了儘可能降低重標記階段的耗時,採用增量更新的方式重新標記”併發標記”階段新增的物件。這兩個階段依然是應用執行緒和標記執行緒併發執行的,所以還是會有新增物件產生,不過數量會降低很多。
5. 重標記。
經過上面兩個階段的預處理之後,需要重新標記的新增物件理論上應該不是很多了。這個階段採用STW模式,最後一次標記遺留的新增物件:
- 遍歷GC Roots,標記直接關聯的沒有被標記的老年代物件以及引用鏈上的物件。
- 遍歷新生代物件,標記直接關聯的沒有被標記的老年代物件以及引用鏈上的物件。
- 遍歷老年代的Dirty Cards,重新標記。
“併發標記-預處理-重標記”這個過程,類似於我們使用Distcp工具遷移一個不斷寫入的表。通常使用如下策略:
- 使用distcp全量拷貝一次資料。這個過程distcp和業務寫入併發進行。(對應併發標記)
- 使用distcp -update增量拷貝一次或者多次資料。這個過程distcp和業務寫入併發執行。(對應併發預處理)
- 經過上述兩個步驟之後,可以認為需要增量拷貝的資料已經不多了。這個時候暫停寫入,再使用distcp -update增量拷貝一次就完成了表的遷移。(對應重標記)
通過這種方式遷移對業務的影響應該是最低的。
6. 併發清理。
經過上述一系列的標記之後,沒有被標記的物件就一定是垃圾物件。這些垃圾物件會被併發清理釋放記憶體空間。
7. 併發重置。
進行Card Table等資料結構的重置等,為下一次GC做準備。
FGC觸發條件是什麼?
CMS垃圾回收器中FGC一旦發生,就會暫停所有應用執行緒,並退化成單執行緒進行垃圾回收,整個暫停耗時非常之長。CMS垃圾回收器一般有兩種FGC觸發條件:
- Concurrent Mode Failure模式FGC。上文我們講過老年代使用記憶體佔總堆大小超過閾值-XX:CMSInitiatingOccupancyFraction的話就會觸發老年代GC。老年代GC的”併發標記”階段是應用執行緒和標記執行緒一起工作的,假如在併發標記的過程中,不斷有物件晉升到老年代最終導致老年代記憶體放不下這些物件的話,就會觸發Concurrent Mode Failure模式FGC。根據字面意思也可以猜到這種FGC和併發執行有關係。
- Promotion Failure模式FGC。從字面意思來看是晉升失敗,是一次新生代GC之後部分物件要晉升到老年代,但是老年代沒有足夠記憶體容納這些物件導致FGC。通常來說,是因為老年代存在大量的記憶體碎片導致這種模式的FGC。
本文總結
這篇文章系統介紹了CMS垃圾回收器相關的理論知識,主要從實現原理層面解釋瞭如下幾個常見問題:
- CMS演算法為什麼要分代?
- 其中新生代GC觸發條件是什麼?簡單介紹一下新生代GC演算法。
- 在哪些條件下物件會從新生代晉升到老年代?
- 老年代GC觸發條件是什麼?簡單介紹一下老年代GC演算法。
- FGC觸發條件是什麼?
接下來一兩篇文章將會基於本篇文章介紹CMS垃圾回收器在大資料生產線上的多個優化實踐案例。
參考文章
http://segmentfault.com/a/1190000037752307?utm_source=tag-newest
http://zhuanlan.zhihu.com/p/105495961
http://www.cnblogs.com/jmcui/p/14165601.html
http://www.zhihu.com/question/287945354/answer/458761494
http://zhuanlan.zhihu.com/p/71058481
http://www.jianshu.com/p/2a1b2f17d3e4
最後打個廣告,後面文章會相繼釋出到公眾號:大資料基建。大家掃描如下二維碼關注: