天天看點

年薪20W才懂的JVM垃圾回收器:并發标記清除回收之再标記、清除

作者:大資料架構師

并發标記清除之再标記

再标記階段是在并發階段(并發标記、預清理、可終止預清理)後執行的,在并發階段,Mutator會修改對象的引用關系,進而導緻部分活躍對象尚未标記。解決問題的思路非常簡單:重新執行一次對象标記。首先,根集合處理與初始标記處理非常類似,包含傳統的根集合、新生代,除此以外,還包括MUT和CT這兩個表中記錄修改的對象,當然也會執行Java對象的引用及類解除安裝相關代碼。再标記發生在STW階段,在執行過程中多個線程并行執行。并行執行的思路對于不同的根處理略有不同,總結如下。

傳統的根集合:每個線程執行一個根集合。

新生代:将Eden和Survivor劃分成記憶體塊,然後每個記憶體塊由一個線

程執行,Eden和Survivor的劃分方法在預清理中已經介紹過。

MUT和CT:将CT中Dirty的卡塊合并到MUT中,CT中卡塊不做任何處理(因為卡塊還可能表示老生代指向新生代引用,Minor GC仍然需要卡表的狀态資料),然後将記憶體劃分為大小相同的記憶體塊進行并行處理。每個記憶體塊的大小通過參數CMSRescanMultiple來控制,預設值為32,表示記憶體塊的大小為32×4KB=128KB。

Java引用本身支援并行處理,通過線程的局部連結清單對引用處理進行并行處理。

最後再來看一下并發執行中卡表的操作。在前面提到,為了保證标記的正确性,Mutator在修改老生代的對象時會在卡表中記錄對象的首位址。但是由于在老生代回收過程中還可以繼續執行Minor GC,Minor GC執行時需要把老生代到新生代的代際引用也記錄在卡表中。為了保證并發标記的正确性,需要關注老生代内對象之間的引用關系,即老生代指向老生代的引用關系也是再标記的根之一。

這裡就有一個問題,在卡表中要區分老生代到新生代的引入值及老生代内對象修改後的值,否則大量的不屬于老生代到新生代的引用也會被周遊到。在Minor GC中使用兩個值cur_youngergen和prev_youngergen分别表示老生代到新生代的引用。當老生代内對象被修改時使用另外一個值Dirty,Dirty和cur_youngergen與prev_youngergen均不相同。是以在再标記、并發預清理、可終止預清理階段都隻需要處理卡表中值為Dirty的卡塊。

并發标記清除之清除

由CMS控制線程負責完成清除,清除階段是并發執行,并且是單線程執行的。清除包含了兩個動作:發現垃圾記憶體并将其添加到空閑清單中;在添加過程中如果發現空閑記憶體塊可以合并,則會執行合并動作。

清除動作算法并不複雜,從[bottom, end)依次周遊記憶體塊,當發現記憶體塊狀态為Free或者Garbage時準備合并。清除算法本質上是一個狀态機,如圖4-28所示。

圖4-28 并發清除狀态機

實際上老生代回收在合并上還有另外的考慮,在應用執行時,一方面,如果小對象過多,JVM内部可能需要不斷地從大的記憶體塊分離出小的記憶體塊;

另一方面,如果大對象過多,JVM内部需要将小的空閑記憶體塊合并成大的記憶體塊。這兩種訴求在應用執行時同時存在。為了提高應用執行的效率,在合并時避免将所有可以合并(隻要記憶體塊首尾位址相連就可以合并)的記憶體塊都合并,在記憶體管理中增加了合并政策,隻有當滿足合并政策時,才可以合并記憶體塊。如何設計合并政策呢?

顯然,要設計好合并政策,需要統計不同大小記憶體塊使用的情況,通常使用一個allocation_stats的資料結構記錄目前尺寸的記憶體塊在應用運作時真正用于配置設定請求的記憶體塊。合并時會根據過去記憶體塊使用的情況預測到下次清除之前需要使用的記憶體塊個數,在合并時當空閑記憶體塊的個數小于預測值時不合并。但在實作層面提供了多樣化處理,下面通過一個例子簡單地介紹合并政策的情況。

假設有兩個記憶體塊,分别記為A、B,其中記憶體塊A的大小為16字,記憶體塊B的大小為40字。這裡假設兩個記憶體塊的大小是為了示範是否滿足合并條件。

滿足合并條件的前提是A的尾位址和B的首位址相連。合并記憶體塊B前,空閑連結清單的狀态如圖4-29所示。

年薪20W才懂的JVM垃圾回收器:并發标記清除回收之再标記、清除

圖4-29 空閑連結清單示意圖

假設應用運作一段時間觸發了老生代回收,在老生代回收中,統計到16字和40字記憶體塊的使用情況,并預測到下一次老生代回收時,16字和40字的記憶體塊都需要2個。當清除記憶體塊B的時候(B可以是Free或者Garbage狀态),是否需要合并A、B可以通過政策來控制,政策的參數通過FLSCoalescePolicy來設定。

0:表示即使A、B滿足合并的前提(位址相連)也不會合并。該參數值會導緻記憶體碎片較多,要慎重使用,适用于應用對象分布比較均勻的場景。

1:表示A、B滿足合并的前提,同時要求A和B對應的空閑連結清單中空閑記憶體塊的個數均超過預測值時才會嘗試合并B。在該例中,由于40字的連結清單中隻有一個空閑塊,低于預測值2,不滿足合并條件,B将被加入空閑連結清單中。該參數值會導緻記憶體碎片較多,要慎重使用。

2:表示A、B滿足合并的前提,要求A對應的空閑連結清單中空閑記憶體塊的個數超過預測值時才會嘗試合并B。在該例中由于16字的連結清單中有3個空閑塊,超過預測值,滿足合并條件,A和B可以被合并。在A和B合并時,A對應的連結清單空閑記憶體塊的個數變成了2,如果後續再要從該連結清單中合并記憶體塊時就不滿足預測值。該參數值是JVM預設的值,由于清除階段是從左到右執行的,執行合并時僅僅判斷左側的記憶體塊更容易實作合并。

3:表示A、B滿足合并的前提,要求A和B對應的空閑連結清單中空閑記憶體塊的個數有一個超過預測值時就會嘗試合并B。在該例中,由于16字的連結清單中有3個空閑塊,超過預測值,滿足合并條件,A和B可以被合并。該參數值和預設值相比可以合并更多的記憶體塊,但效果有限。

4:表示隻要A、B滿足合并的前提,就會合并A和B,不用考慮配置設定的效率。該參數值可以盡可能多地合并記憶體塊,但對記憶體配置設定效率會有一定的影響。

實際工作中常使用預設值2或者激進的合并政策4,讀者可以根據應用運作的情況來選擇相應的參數值。

另外,JVM還對最大空閑記憶體塊的合并做了特殊處理,原因是最大空閑記憶體塊越大,滿足應用配置設定請求的機率就更高。是以,當遇到最大空閑時盡可能地合并。其具體的實作如下:

1)找到第一個最大的空閑記憶體塊。

2)根據該空閑記憶體塊的位址向前計算一個門檻值,當空閑塊的位址落在門檻值之後的位址空間時,總是合并空閑塊,而不考慮合并政策。門檻值的計算公式如下:假設Offset是最大空間塊A距離記憶體起始位址O的偏移量,即Offset=A-O;門檻值threshold=Offset×FLSLar-gestBlockCoalesceProximity+O,記憶體塊落在[threshold, A)之間時都會強制進行合并。

FLSLargestBlockCoalesceProximity的預設值為0.99。在實際生成中,如果合并政策選擇1、2、3,當發現遇到記憶體碎片化導緻無法響應記憶體配置設定時,可以設定該值,将其值變小,可以有效地提高最大空閑記憶體塊合并的機率。

最後,再對清除的并發操作做一些提示。清除操作和Mutator并發執行,而Mutator可以在清除執行期間從老生代中申請記憶體并初始化對象。兩者之間的同步通過FreeListLock這個鎖來保證,即隻有得到FreeListLock這個鎖的線程才能通路老生代。要進行清除,必須獲得鎖,當Mutator需要配置設定記憶體時,必須等待清除階段釋放鎖。為了保證Mutator的執行,在清除階段會執行Yield動作。具體方法是在每處理完一個記憶體塊之前都先檢查是否需要放棄執行,如果需要,則放棄CPU的占用。在放棄CPU占用時會先釋放鎖,進而使Mutator得到執行。

但是兩者并發執行可能會存在一個問題,那就是Mutator從老生代配置設定了記憶體,但是尚未完成初始化,就被清除線程搶占了CPU重新執行。對于這種情況,清除線程在實作中需要做額外的處理。主要原因是在進行清除工作時需要通路中繼資料擷取對象記憶體的大小,而尚未完成初始化的對象的中繼資料資訊并不存在,無法正确擷取。具體的方法是:對于已經申請但尚未完成初始化的記憶體塊,當配置設定時,在标記位圖中做特殊的标記。

普通對象在标記位圖中僅僅标記對象的首位址對應的位圖,對于已經配置設定但尚未完成初始化的對象,對對象的首位址、第二個字和最後一個字對應的位圖都進行設定。因為對象的大小最小為3字,是以通過上述方法可以将兩者區分開來。正常對象标記位圖中前兩位為10,已經配置設定但尚未完成初始化動作的對象标記位圖中前兩位為11,繼續查找标記位圖,直到遇到标記1,該位址就是未初始化對象的尾位址,通過這樣的方式就可以獲得尚未初始化的對象的大小。

本文給大家講解的内容是JVM垃圾回收器詳解:并發标記清除回收,并發的老生代回收-并發标記清除之再标記、清除

  1. 下篇文章給大家講解的内容是JVM垃圾回收器詳解:并發标記清除回收,并發的老生代回收-并發标記清除之記憶體空間調整、複位、并發算法難點、Full GC
  2. 感謝大家的支援!

繼續閱讀