天天看點

JVM垃圾回收器詳解:并發老生代回收并發标記之可終止預清理

作者:大資料架構師

并發标記清除之預清理

預清理指的是在并發标記結束以後,在執行再标記之前,預先做一些工作,以減少再标記的耗時。預清理的思路是針對初始标記結束到目前為止新增的對象進行并發的标記,進而減少再标記階段的時間。預清理的主要工作如下。

1)處理Java引用:Java引用在并發标記完成後就可以處理。處理的思路是針對标記時找到的Java引用,判斷引用管理的對象是否可以回收。

2)标記Survivor分區:在Mutator運作過程中新生代中的對象的引用關系可以被修改,而新生代是老生代的根之一,是以再标記時會重新以新生代為根集合進行标記,在預清理中可以執行Survivor分區作為根集合的标記。

為什麼此時選擇Survivor作為根集合提前處理?Eden不能在這裡處理嗎?因為預清理時可并發執行,Mutator是可以通路Eden的(配置設定對象),如果要保證通路到Eden中所有的對象,則需要對Eden加鎖。而Survivor分區中的對象不會重新配置設定,隻有讀寫通路,實作簡單。

3)标記ModUnionTable(簡稱MUT):MUT記錄了在老生代回收中如果發生Minor GC時晉升的對象,或者Mutator直接配置設定在老生代中的對象。新增對象都被認為是新增的根集合,是以需要再次标記。注意,在預清理階段不能執行前台GC,由于預清理和Mutator并發執行,但是為了保證正确通路對象,隻有在預清理主動放棄CPU的時候,Mutator才能直接在老生代中配置設定對象,如果Mutator不直接在老生代中配置設定對象,則不會與預清理線程發生競争。在處理完MUT中的待标記對象後,MUT相應的位圖會被清除。

4)标記CardTable(簡稱CT):在CMS的設計中,卡表記錄了GC過程中老生代變化的對象,變化的對象既可以是老生代指向新生代引用關系的變化,也可以是指向老生代引用關系的變化。是以再标記需要重新對卡表進行處理。當然,在進行卡表處理時,僅僅針對已經标記過的對象(明确是活躍對象),才會再次對卡表的狀态進行再标記。是否存在卡塊狀态為Dirty,但是對應的對象是死亡狀态的情況?完全有可能,因為卡塊對應的是512位元組的記憶體,是以可能存在隻有部分對象是活躍狀态的情況。另外,在預清理階段,卡塊原來是Dirty狀态,再處理後狀态變為PreClean,這個值表示在執行Minor GC時仍然需要把該卡塊中的對象作為根。

在預清理的過程中需要通路标記位圖,并且在标記位圖中對新增的活躍對象進行标記。同時,Mutator在老生代中直接配置設定對象時也需要寫标記位圖,執行Minor GC時如果有對象晉升,也需要寫标記位圖。是以在預清理的過程中需要擷取BitmapLock這個鎖,進而保證正确性。但是當預清理中獲得BitmapLock鎖以後,Mutator就無法在老生代中配置設定對象,是以預清理中需要在滿足一定條件下主動放棄執行CPU,讓Mutator獲得CPU的執行機會。主動放棄CPU一般發生在執行一定任務後,例如:

1)在對Survivor分區處理時,針對分區中每一個對象處理完成後都會檢查是否需要讓出CPU。為了保證處理的連續性及降低代碼實作的複雜性,僅僅針對根對象檢查是否放棄CPU執行,在周遊對象的成員變量時并不會再次檢查。是以在實際中如果一個對象有很深的對象引用關系,可能會導緻Mutator等待鎖的時間過長。

2)類似地,在進行MUT和CT處理時,也是針對MUT和CT中每一個對象處理完成後檢查是否需要讓出CPU。

3)在進行引用處理時比較特殊,由于Survivor、MUT和CT都可以在周遊時控制放棄CPU的時機,而引用進行中并未實作細粒度的放棄CPU的動作,隻有在處理不同引用類型時才會檢查是否需要放棄CPU的執行。是以在預清理中,引用處理可能會導緻該階段耗時較長,如果發現存在這樣的情況,則可以将引用處理放在再标記階段執行(再标記可以并行處理引用,預清理是由CMS控制線程單線程執行引用處理)。

需要指出的是,在對Survivor、引用處理、MUT和CT的處理過程中會遞歸處理活躍對象的成員變量,使用标記棧來儲存成員變量。但是在運作過程中,标記棧可能會溢出,是以需要一個額外的機制來保證标記棧溢出時标記對象不會丢失,通常使用一個連結清單作為标記棧的備份安全機制。關于标記棧溢出更多的介紹可以參考後面擴充閱讀中的相關内容。

對于Survivor分區處理有一個需要優化的地方,那就是當溢出發生時并不使用備份連結清單,而是借用MUT作為備份機制,隻要保證MUT的處理發生在Survivor分區處理之後,就能保證待标記對象不丢失。

另外再提一點,CMS控制線程在放棄CPU執行的時候,Mutator能否順利地擷取CPU并得到執行呢?放棄CPU執行是通過Yield機制完成的,OS關于線程執行Yield動作後其他線程是能否獲得CPU并不确定,例如線程放棄CPU後還可能再次獲得CPU的執行權,是以可能出現CMS控制線程放棄CPU後,Mutator沒有搶到控制權,CMS控制線程繼續執行,導緻Mutator長時間等待(CMS控制線程放棄CPU時釋放相關鎖,Mutator獲得鎖才能執行)的情況。

在這種情況下,更好的處理方式是重新設計Mutator和CMS控制線程的互動方式,例如使用通知/等待機制,但實作較為複雜。在JVM實作中直接讓CMS控制線程在放棄CPU後再睡眠一段時間(睡眠時間通過參數CMSYieldSleepCount控制,預設是0,表示不睡眠)。如果遇到在并發執行階段Mutator長時間等待的情況,則可以設定該參數讓Mutator獲得執行權。

并發标記清除之可終止預清理

可終止預清理指的是在執行過程中如果發現記憶體壓力比較大,會主動終止執行,直接進入再标記階段。可終止預清理階段的可終止指的是當Eden記憶體使用到一定程度時(通過參數CMSScheduleRemarkSamplingRatio控制,預設值是50,表示Eden使用超過50%),不再繼續執行預清理階段,直接轉入再标記階段。其主要原因是Eden剩餘空間不多,而可終止預清理雖然是并發執行的,但是是單線程執行,速度比較慢。如果繼續執行預清理,可能導緻新生代因為記憶體不足觸發老生代回收,而這樣的老生代回收可能會終止目前正在執行的回收,是以引入了可終止預清理。

可終止預清理和預清理階段完全共享代碼。主要差別如下:

1)通過不同的參數控制處理的源,預設情況下,預清理執行引用處理、MUT和CT的處理;可終止預清理執行Survivor、MUT和CT的處理。

2)可終止預清理會額外判斷是否需要終止,如果需要終止,則直接進入再标記階段。

預清理和可終止預清理執行的工作可以通過參數修改,其中預清理階段使用參數CMSPrecleanRefLists1(預設為true)和CMSPrecleanSurvivors1(預設為false)控制引用處理和Survivor的執行;

可終止預清理階段使用參數CMSPrecleanRefLists2(預設為false)和CMSPrecleanSurvivors2(預設為true)控制引用處理和Survivor的執行。

如果遇到預清理階段引用處理時間過長的情況,則可以将CMSPrecleanRefLists1也設定為false,則可跳過引用處理。

MUT和CT在預清理和可終止預清理階段都有處理。在預清理階段,不會主動終止MUT和CT的處理;而在可終止預清理階段,MUT和CT的處理都會嘗試主動讓出CPU,并且也都會主動檢查是否需要終止執行。

另外,在MUT的進行中還進行了額外的優化,主要是為了控制執行的時間,在這兩個階段都會控制處理的對象數量。以下兩種情況會主動終止MUT的處理。

1)MUT的處理在放棄次數不超過3次(可以通過參數CMSPrecleanIter控制,預設值為3)的情況下還會繼續重試執行MUT。

2)當MUT處理卡塊的個數小于1000(可以通過參數CMSPrecleanThreshold控制,預設值為1000),或者每次MUT處理卡塊的個數沒有出現遞減并且達到一定程度時會主動終止(通過參數CMSPrecleanDenominator和CMSPrecleanNumerator控制數量變化的程度,預設值分别是3和2,表示最新一次MUT處理的個數大于上一次MUT處理個數的2/3)。

再來分析一下在進行CT處理時卡塊被設定為PreClean的正确性。CMS控制線程在預清理和可終止預清理階段都會将老生代的卡塊設定為PreClean。而Mutator也有可能修改對象的引用關系并設定卡塊的值,Mutator會将卡塊的值修改為Dirty。因為CMS控制線程和Mutator都可能修改同一卡塊,是以存在競争問題。那麼在修改卡塊時是否需要加鎖?

如何設計才能保證算法的正确性?下面通過一個簡單的例子來說明CMS是如何解決這個問題的。假設Mutator(記為T1)修改老生代中對象的引用關系(記為Write Heap,簡寫為Wh),需要寫卡塊(記為Write Dirty,簡寫為Wd),可以抽象為先寫堆再寫卡塊;CMS控制線程(記為T2)正在執行預清理或可終止預清理,對卡塊為Dirty的進行重新标記,當标記時先将卡塊修改為PreClean(記為WritePreClean,簡寫為Wp),再讀對象(記為Read,簡寫為R),可以抽象為先寫卡塊再讀堆。T1和T2互動執行,可能有以下6種執行順序。

1)如果T2先于T1執行,那麼整個執行順序為Wp→R→Wh→Wd,最後卡塊的結果為Dirty,并發預清理和可終止預清理正确執行,同時下一次的MinorGC也正确執行。

2)如果T1先于T2執行,那麼整個執行順序為Wh→Wd→Wp→R,最後卡塊的結果為PreClean,并發預清理和可終止預清理正确執行。對于Minor GC需要稍微增強,在執行Minor GC處理時,除了要把Dirty看作代際引用之外,也要把PreClean看作代際引用,以保證對象标記的正确性(在4.2節中提到PreClean也是根的原因)。

3)如果T1和T2互動執行,T1修改引用關系,T2修改卡塊為PreClean,T1修改寫卡塊為Dirty,T2再讀對象。整個執行順序為Wh→Wp→Wd→R,卡塊最後的狀态為Dirty,T2讀到的是修改後的對象,對象會被正确地再标記。

同時由于卡塊狀态為Dirty,是以再标記中還會再處理一次卡塊對應的對象,相當于額外多執行一次标記動作,但是正确性沒有問題。

4)如果T1和T2互動執行,T1修改引用關系,T2修改卡塊為PreClean并讀對象,T1最後修改寫卡塊為Dirty。整個執行順序為Wh→Wp→R→Wd,卡塊最後的狀态為Dirty,T2讀到的是修改後的對象,對象會被正确地再标記,會額外多執行一次卡表的标記動作。

5)如果T1和T2互動執行,T2修改卡塊為PreClean,T1修改引用關系,T1修改寫卡塊為Dirty,T2再讀對象。整個執行順序為Wp→Wh→Wd→R,卡塊最後的狀态為Dirty,T2讀到的是修改後的對象,對象會被正确地再标記,會額外多執行一次卡表的标記動作。

6)如果T1和T2互動執行,T2修改卡塊為PreClean并讀對象,T1修改引用關系,T1最後修改寫卡塊為Dirty。整個執行順序為Wp→Wh→R→Wd,卡塊最後的狀态為Dirty,T2讀到的是修改後的對象,對象會被正确地再标記,會額外多執行一次卡表的标記動作。

另外,預清理階段和可終止預清理階段除了做上述标記工作以外,還可能做一些其他的工作(依賴于參數CMSEdenChunksRecordAlways的設定,該參數的預設值是true,表示不需要預清理做額外的工作)。在執行再标記的時候,需要重新把新生代作為老生代的根進行标記,為了加速再标記的執行,會将Eden劃分為成大小盡量相同的記憶體塊(chunk),由多個線程并行執行對象的标記,記憶體塊的大小可以通過參數CMSSamplingGrain來控制(預設值是16K[3]字)。

但是直接按照大小對Eden進行劃分會存在一個問題,那就是每個chunk的第一字不是對象的首位址,是以需要額外的資料結構輔助(例如BOT)找到對象的首位址,然後在周遊對象時根據對象的首位址開始進行标記。使用輔助結構需要額外的記憶體消耗及時間來查找對象,是以在CMS中提供了另外一種實作,即使用了一個額外的數組記錄每個chunk中第一個對象的首位址,而數組中元素的更新政策有兩種,通過參數CMSEdenChunksRecordAlways來控制。

1)當參數設定為true時,在進行對象配置設定的時候判斷對象是否跨chunk,如果對象進入下一個chunk,則直接更新數組;該參數為true時會降低Mutator對象配置設定的效率。

2)當參數設定為false時,在進行對象配置設定時并不記錄,而是在預清理或可終止預清理階段在周遊對象時對Eden進行采樣,如果發現Eden目前可用的位址處于一個新的chunk中,則更新數組。這樣的方式雖然不影響對象配置設定的效率,但是數組記錄對象并不均勻,數組元素之間的位址跨度可能比較大(依賴于應用運作的情況)。

另外,在實作時對于是否啟動抽樣還要提供額外的參數控制,當滿足下面的條件時才會真正啟動抽樣,公式為

JVM垃圾回收器詳解:并發老生代回收并發标記之可終止預清理

其中CMSScheduleRemarkSamplingRatio的預設值為5,CMSScheduleRemarkEden-Penetration的預設值為50,表示Eden使用的記憶體低于1/10容量時才會啟動抽樣。如果無法成功啟動抽樣,在執行再标記時性能可能受損,整個Eden會被一個線程處理(當然JVM内部有多線程的任務均衡機制來解決負載不均衡的問題)。

根據筆者個人經驗,在CMS的實作這一部分邏輯中存在一個小問題,通常不建議讀者對這幾個參數做修改,直接使用預設配置即可。

在可終止預清理階段還提供了以下幾種通過參數主動終止執行的控制。

參數CMSMaxAbortablePrecleanLoops控制預清理主動執行的次數,在一次預清理進行中處理Survivor、MUT和CT時都可以主動終止。該參數表示如果遇到主動終止,則判斷是否需要再次進入預清理工作。該參數的預設值為0,表示不使用該方式控制是否主動終止。

參數CMSMaxAbortablePrecleanTime控制預清理主動執行的總體時間,當進行多次預清理處理時,總體執行的時間不能超過該門檻值。該參數的預設值為5000,表示最大允許該階段執行5秒,超過5秒會立即進入再标記。

參數CMSAbortablePrecleanMinWorkPerIteration用于控制可終止預清理的效率,要求一次預清理處理至少處理一定數量的對象,當低于該門檻值時,暫時進入休眠狀态,休眠時間通過參數CMSAbortablePrecleanWaitMillis來控制。這兩個參數的預設值都是100,表示一次預清理工作的處理對象少于100個時會休眠100毫秒。

這幾個參數隻有在很少的情況下才會被使用到,一般的程式員無須關心這些參數。

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

  1. 下篇文章給大家講解的内容是JVM垃圾回收器詳解:并發标記清除回收,并發的老生代回收-并發标記清除之再标記、清除
  2. 感謝大家的支援!

繼續閱讀