1. 程式人生 > >圖解CMS垃圾回收機制,你值得擁有

圖解CMS垃圾回收機制,你值得擁有

簡書 佔小狼,轉載請註明原創出處,謝謝!

最近在整理JVM相關的PPT,把CMS演算法又過了一遍,每次閱讀原始碼都能多瞭解一點,繼續堅持。

什麼是CMS

CMS全稱 Concurrent Mark Sweep,是一款併發的、使用標記-清除演算法的垃圾回收器,
如果老年代使用CMS垃圾回收器,需要新增虛擬機器引數-"XX:+UseConcMarkSweepGC"。

使用場景:

GC過程短暫停,適合對時延要求較高的服務,使用者執行緒不允許長時間的停頓。

缺點:

服務長時間執行,造成嚴重的記憶體碎片化。
另外,演算法實現比較複雜(如果也算缺點的話)

實現機制

根據GC的觸發機制分為:週期性Old GC(被動)和主動Old GC
個人理解,實在不知道怎麼分才好。

週期性Old GC

週期性Old GC,執行的邏輯也叫Background Collect,對老年代進行回收,在GC日誌中比較常見,由後臺執行緒ConcurrentMarkSweepThread迴圈判斷(預設2s)是否需要觸發。

觸發條件

1、如果沒有設定-XX:+UseCMSInitiatingOccupancyOnly,虛擬機器會根據收集的資料決定是否觸發(建議線上環境帶上這個引數,不然會加大問題排查的難度)。
2、老年代使用率達到閾值 CMSInitiatingOccupancyFraction

,預設92%。
3、永久代的使用率達到閾值 CMSInitiatingPermOccupancyFraction,預設92%,前提是開啟 CMSClassUnloadingEnabled
4、新生代的晉升擔保失敗。

晉升擔保失敗

老年代是否有足夠的空間來容納全部的新生代物件或歷史平均晉升到老年代的物件,如果不夠的話,就提早進行一次老年代的回收,防止下次進行YGC的時候發生晉升失敗。

週期性Old GC過程

當條件滿足時,採用“標記-清理”演算法對老年代進行回收,過程可以說很簡單,標記出存活物件,清理掉垃圾物件,但是為了實現整個過程的低延遲,實際演算法遠遠沒這麼簡單,整個過程分為如下幾個部分:

物件在標記過程中,根據標記情況,分成三類:

  1. 白色物件,表示自身未被標記;
  2. 灰色物件,表示自身被標記,但內部引用未被處理;
  3. 黑色物件,表示自身被標記,內部引用都被處理;

假設發生Background Collect時,Java堆的物件分佈如下:

1、InitialMarking(初始化標記,整個過程STW)

該階段單執行緒執行,主要分分為兩步:

  1. 標記GC Roots可達的老年代物件;
  2. 遍歷新生代物件,標記可達的老年代物件;

該過程結束後,物件分佈如下:

2、Marking(併發標記)

該階段GC執行緒和應用執行緒併發執行,遍歷InitialMarking階段標記出來的存活物件,然後繼續遞迴標記這些物件可達的物件。

因為該階段併發執行的,在執行期間可能發生新生代的物件晉升到老年代、或者是直接在老年代分配物件、或者更新老年代物件的引用關係等等,對於這些物件,都是需要進行重新標記的,否則有些物件就會被遺漏,發生漏標的情況。

為了提高重新標記的效率,該階段會把上述物件所在的Card標識為Dirty,後續只需掃描這些Dirty Card的物件,避免掃描整個老年代。

3、Precleaning(預清理)

通過引數CMSPrecleaningEnabled選擇關閉該階段,預設啟用,主要做兩件事情:

  1. 處理新生代已經發現的引用,比如在併發階段,在Eden區中分配了一個A物件,A物件引用了一個老年代物件B(這個B之前沒有被標記),在這個階段就會標記物件B為活躍物件。
  2. 在併發標記階段,如果老年代中有物件內部引用發生變化,會把所在的Card標記為Dirty(其實這裡並非使用CardTable,而是一個類似的資料結構,叫ModUnionTalble),通過掃描這些Table,重新標記那些在併發標記階段引用被更新的物件(晉升到老年代的物件、原本就在老年代的物件)

4、AbortablePreclean(可中斷的預清理)

該階段發生的前提是,新生代Eden區的記憶體使用量大於引數CMSScheduleRemarkEdenSizeThreshold 預設是2M,如果新生代的物件太少,就沒有必要執行該階段,直接執行重新標記階段。

為什麼需要這個階段,存在的價值是什麼?

因為CMS GC的終極目標是降低垃圾回收時的暫停時間,所以在該階段要盡最大的努力去處理那些在併發階段被應用執行緒更新的老年代物件,這樣在暫停的重新標記階段就可以少處理一些,暫停時間也會相應的降低。

在該階段,主要迴圈的做兩件事:

  1. 處理 From 和 To 區的物件,標記可達的老年代物件
  2. 和上一個階段一樣,掃描處理Dirty Card中的物件

當然了,這個邏輯不會一直迴圈下去,打斷這個迴圈的條件有三個:

  1. 可以設定最多迴圈的次數 CMSMaxAbortablePrecleanLoops,預設是0,意思沒有迴圈次數的限制。
  2. 如果執行這個邏輯的時間達到了閾值CMSMaxAbortablePrecleanTime,預設是5s,會退出迴圈。
  3. 如果新生代Eden區的記憶體使用率達到了閾值CMSScheduleRemarkEdenPenetration,預設50%,會退出迴圈。(這個條件能夠成立的前提是,在進行Precleaning時,Eden區的使用率小於十分之一)

如果在迴圈退出之前,發生了一次YGC,對於後面的Remark階段來說,大大減輕了掃描年輕代的負擔,但是發生YGC並非人為控制,所以只能祈禱這5s內可以來一次YGC。

...
1678.150: [CMS-concurrent-preclean-start]
1678.186: [CMS-concurrent-preclean: 0.044/0.055 secs]
1678.186: [CMS-concurrent-abortable-preclean-start]
1678.365: [GC 1678.465: [ParNew: 2080530K->1464K(2044544K), 0.0127340 secs] 
1389293K->306572K(2093120K), 
0.0167509 secs]
1680.093: [CMS-concurrent-abortable-preclean: 1.052/1.907 secs]  
....

在上面GC日誌中,1678.186啟動了AbortablePreclean階段,在隨後不到2s就發生了一次YGC。

5、FinalMarking(併發重新標記,STW過程)

該階段併發執行,在之前的並行階段(GC執行緒和應用執行緒同時執行,好比你媽在打掃房間,你還在扔紙屑),可能產生新的引用關係如下:

  1. 老年代的新物件被GC Roots引用
  2. 老年代的未標記物件被新生代物件引用
  3. 老年代已標記的物件增加新引用指向老年代其它物件
  4. 新生代物件指向老年代引用被刪除
  5. 也許還有其它情況..

上述物件中可能有一些已經在Precleaning階段和AbortablePreclean階段被處理過,但總存在沒來得及處理的,所以還有進行如下的處理:

  1. 遍歷新生代物件,重新標記
  2. 根據GC Roots,重新標記
  3. 遍歷老年代的Dirty Card,重新標記,這裡的Dirty Card大部分已經在clean階段處理過

在第一步驟中,需要遍歷新生代的全部物件,如果新生代的使用率很高,需要遍歷處理的物件也很多,這對於這個階段的總耗時來說,是個災難(因為可能大量的物件是暫時存活的,而且這些物件也可能引用大量的老年代物件,造成很多應該回收的老年代物件而沒有被回收,遍歷遞迴的次數也增加不少),如果在AbortablePreclean階段中能夠恰好的發生一次YGC,這樣就可以避免掃描無效的物件。

如果在AbortablePreclean階段沒來得及執行一次YGC,怎麼辦?

CMS演算法中提供了一個引數:CMSScavengeBeforeRemark,預設並沒有開啟,如果開啟該引數,在執行該階段之前,會強制觸發一次YGC,可以減少新生代物件的遍歷時間,回收的也更徹底一點。

不過,這種引數有利有弊,利是降低了Remark階段的停頓時間,弊的是在新生代物件很少的情況下也多了一次YGC,最可憐的是在AbortablePreclean階段已經發生了一次YGC,然後在該階段又傻傻的觸發一次。

所以利弊需要把握。

主動Old GC

這個主動Old GC的過程,觸發條件比較苛刻:

  1. YGC過程發生Promotion Failed,進而對老年代進行回收
  2. 比如執行了System.gc(),前提是沒有引數ExplicitGCInvokesConcurrent
  3. 其它情況...

如果觸發了主動Old GC,這時週期性Old GC正在執行,那麼會奪過週期性Old GC的執行權(同一個時刻只能有一種在Old GC在執行),並記錄 concurrent mode failure 或者 concurrent mode interrupted。

主動GC開始時,需要判斷本次GC是否要對老年代的空間進行Compact(因為長時間的週期性GC會造成大量的碎片空間),判斷邏輯實現如下:

*should_compact =
    UseCMSCompactAtFullCollection &&
    ((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) ||
     GCCause::is_user_requested_gc(gch->gc_cause()) ||
     gch->incremental_collection_will_fail(true /* consult_young */));

在三種情況下會進行壓縮:

  1. 其中引數UseCMSCompactAtFullCollection(預設true)和 CMSFullGCsBeforeCompaction(預設0),所以預設每次的主動GC都會對老年代的記憶體空間進行壓縮,就是把物件移動到記憶體的最左邊。
  2. 當然了,比如執行了System.gc(),前提是沒有引數ExplicitGCInvokesConcurrent,也會進行壓縮。
  3. 如果新生代的晉升擔保會失敗。

帶壓縮動作的演算法,稱為MSC,標記-清理-壓縮,採用單執行緒,全暫停的方式進行垃圾收集,暫停時間很長很長...

那不帶壓縮動作的演算法是什麼樣的呢?

不帶壓縮動作的執行邏輯叫Foreground Collect,整個過程相對週期性Old GC來說,少了Precleaning和AbortablePreclean兩個階段,其它過程都差不多。

如果執行System.gc(),而且添加了引數ExplicitGCInvokesConcurrent,這時並不屬於主動GC,它會推進週期性Old GC的進行,比如剛剛執行過一次,並不會等2s後檢查條件,而是立馬啟動週期性Old GC。



作者:佔小狼
連結:https://www.jianshu.com/p/2a1b2f17d3e4
來源:簡書