一、簡介
CMS垃圾收集器是一款用於老年代的,使用復制-清除-整理算法的垃圾收集器。
二、GC階段
1、初始化標記(STW)
暫停應用程序線程,遍歷 GC ROOTS 直接可達的對象並將其壓入標記棧(mark-stack),標記完之后恢復應用程序線程。
2、並發標記
這個階段虛擬機會分出若干線程(GC 線程)去進行並發標記。標記哪些對象呢?標記那些 GC ROOTS 最終可達的對象。具體做法是推出標記棧里面的對象,然后遞歸標記其直接引用的子對象,同樣的把子對象壓到標記棧中,重復推出,壓入。。。直至清空標記棧。這個階段GC線程和應用程序線程同時運行。
當 GC線程 進行並發操作時,應用程序可能會進行新增對象、刪除對象、變更對象引用等一系列操作。這種條件下可能會出現活動對象的漏標的情況,比如下面場景:
- A 是活動對象,A->B,標記 B 可達,將其壓入標記棧,此時 A 所有直接子對象遍歷完,A 出棧,標記線程將不會再訪問 A。
- 同時應用程序移掉了 B 對 C 的引用,讓A重新引用 C。
- B 出棧時無法標記 C 可達,A 雖然引用 C 但標記線程不會再訪問A,此時 C 會被當成不可達對象。
所以單純的並發標記操作並不能保證 GC 的正確性,所以還需要額外的操作,這個操作就是 Write Barrier。
Write Barrier 就是當改寫一個引用時:A.x = C
,執行一些額外操作。如果是上面場景可以假設為:
write_barrier(obj, field, newobj){
if(newobj.mark == FALSE)
newobj.mark = TRUE
push(newobj, $mark_stack)
*field = newobj
}
即當賦值引用時,如果賦值的對象還沒有被標記,將標記該對象將其壓入標記棧。在使用 Write Bariier 之后同樣的情景就不會出現活動對象被遺漏的情況了。
所以我們知道並發標記階段,並不是只有單純的並發標記,還有一個額外的 Write Bariier 操作,避免活動對象被漏標。
3、重新標記(STW)
重新標記可以理解成一個同步刷新對象間引用的操作,整個過程是 STW。在並發標記其間,應用程序不斷變更對象引用,此時的 GC ROOTS 有可能會發生變化,這個時候需要同步更新這個增量變化。於是重新從當前的 GC ROOTS 和指針更新的區域出發(mod-union table)再進行一次標記,所以這個過程被叫作重新標記。需要注意的是:已經標記的對象是不會再遍歷一次,標記線程識別對象在並發階段已經標記過了,就會跳過該對象。所以重新標記只會遍歷那些新增沒有標記過的活動對象和其間有指針更新的活動對象,如果指針更新頻繁,重新標記很有可能會遍歷新生代中的大部分甚至全部對象。所以如果重新標記階段很慢,可以啟動一次YGC,來減少並發標記的工作量減少其停頓時間。
4、並發清除
重新標記結束后,應用程序繼續運行,此時分出一個處理器去進行垃圾回收工作。
老年代的對象通常是存活時間長,回收比例低,所以采用的回收算法是標記-清除。這個階段 GC 回收線程是遍歷整個老年代,遇到沒有被標記的對象(垃圾)就清空掉相應的內存塊,並加入可分配列表。遇到被標記的對象保持原來的位置不動,只是重置其標記位,用於下一次GC。
5、並發重置
Oracle 官方文檔中描述這個階段的工作是:重新調整堆的大小,並為下一次GC做好數據結構支持,比如重置卡表的標位,具體細節有待考證。
三、知識點
1、卡表
CMS 中一個與 YGC 相關並十分重要的數據結構是:卡表(Card Table)。之所以出現卡表這樣的一個數據結構是因為:YGC 時為了標記活動標記對象除了遍歷 GC ROOTS 之外,別忘了老年代里也可能會引用新生代對象。所以正常來說還要掃描一次老年代,如果是掃描整個老年代這將會隨着堆的增大變得越來越慢,特別是現在內存都越來越大了,所以為了提升性能就引入卡表。
卡表提升性能的原理:邏輯上把老年代內存分成一個個大小相等的卡片(Card,論文中提到適合大小是128個字節),然后對每個卡片准備一個與其對應的標記位,並將這些位集中起管理就好像一個表格(mark table)一樣,當改寫對象引用是從老年代指向新生代時,在老年代對應的卡片標記位上設置標志位即可,通常這樣的卡片我們稱之為 Dirty Card。這項操作可以通過上面的提到的 Write Barrier 來實現,這樣就算對象跨多張卡片也不會有什么問題。卡表通常是用 byte 數組實現的,byte 的值只能取 [0,1] 這兩種。所以 btye[i] = 1 就表示第 i + 1 卡片所在內存上有指向新生代引用的老年代對象,這時只要遍歷這個卡片上的對象即可。如果每個 card 大小的是128字節(1024位),那卡表就只占整個老年代的 1/1024 之一。所以遍歷卡表的時間會遠比遍歷整個老年代快得多!這其中背后思想就是典型以空間換時間的思路,這種思路在 G1 中也有體現,只不其對應的數據是 remember set 而已。
2、Concurrent Mode Failure
如果 CMS 在清理掉垃圾對象之前,老年代中沒有足夠的空間存放新產生的對象,就會出現 Concurrent Mode Failure,
四、缺點
- 內存碎片(原因是采用了標記-清除算法)
- 對 CPU 資源敏感(原因是並發時和用戶線程一起搶占 CPU)
- 浮動垃圾:在並發標記階段產生了新垃圾不會被及時回收,而是只能等到下一次GC
然后我產生了一個疑問:既然重新標記可以修正並發標記階段的變動,那么為何還有浮動垃圾問題?
由於標記階段是從 GC Roots 開始標記可達對象,那么在並發標記階段可能產生兩種變動:
- 本來可達的對象,變得不可達了。(浮動垃圾)
- 本來不可達的內存,變得可達了。
浮動垃圾是可容忍的問題,而不是錯誤。那么為什么重新標記階段不處理第一種變動呢?也許是由可達變為不可達這樣的變化需要重新從 GC Roots 開始遍歷,相當於再完成一次初始標記和並發標記的工作,這樣不僅前兩個階段變成多余的,浪費了開銷浪費,還會大大增加重新標記階段的開銷,所帶來的暫停時間是追求低延遲的CMS所不能容忍的。
四、日志解讀
# 年輕代 GC,使用 ParNew
0.210 [GC (Allocation Failure) [ParNew: 279616K->34942K(314560K), 0.0246537 secs] 279616K->79707K(1013632K), 0.0246999 secs] [Times: user=0.06 sys=0.09, real=0.03 secs]
# 老年代 GC
# 初始計標記,速度很快只用了3.1毫秒
# 372071K:老年代的使用量;699072K:老年代的總容量;413038K:堆的使用量;1013632K:整個堆的容量;0.0003105 secs:耗時
0.526: [GC (CMS Initial Mark) [1 CMS-initial-mark: 372071K(699072K)] 413038K(1013632K), 0.0003105 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
# 啟動並發標記步驟
0.526: [CMS-concurrent-mark-start]
0.529: [CMS-concurrent-mark: 0.003/0.003 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
# 啟動預清理步驟,這個階段會盡可能在重新標記前,處理掉一些在並發標記階段發生變化的引用關系,從而降低重新標記階段的停頓時間
# 清理 Eden 區中發生變化的引用(dirty card)
0.529: [CMS-concurrent-preclean-start]
0.530: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
# 啟動可中斷的預清理,這個階段主要處理 from 和 to 區域對象引用 old gen 的變化,同樣也會繼續處理 dirty card 的對象引用。這個階段默認設置的時間是5s,如果執行邏輯超過5s,會自動終止這個階段,或者當eden區使用內存值小於 CMSScheduleRemarkEdenPenetration,默認 50% 時,也會退出這個階段。
0.530: [CMS-concurrent-abortable-preclean-start]
0.844: [CMS-concurrent-abortable-preclean: 0.006/0.315 secs] [Times: user=1.34 sys=0.12, real=0.31 secs]
# 重新標記
# 35015K:年輕代當前的使用量;314560K:年輕代的總容量;
0.845: [GC (CMS Final Remark) [YG occupancy: 35015 K (314560 K)]
# Rescan:進行重新標記,耗時 0.0004597 secs
0.845: [Rescan (parallel) , 0.0004597 secs]
# 處理弱引用
0.845: [weak refs processing, 0.0000086 secs]
# 卸載 class
0.845: [class unloading, 0.0004208 secs]
# 清理類級元數據和內部化字符串的符號和字符串表
0.845: [scrub symbol table, 0.0004006 secs]
0.846: [scrub string table, 0.0001479 secs]
# 老年代的使用情況及堆的使用情況
[1 CMS-remark: 677072K(699072K)] 712088K(1013632K), 0.0014893 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
# 啟動並發清除,任務是清除那些沒有標記的無用對象並回收內存
0.846: [CMS-concurrent-sweep-start]
0.847: [CMS-concurrent-sweep: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
# 啟動並發重置,作用是重新設置CMS算法內部的數據結構
0.847: [CMS-concurrent-reset-start]
0.849: [CMS-concurrent-reset: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]