目錄
可中斷預清理concurrent abortable preclean
概述
CMS垃圾收集器是一款優秀的老年代併發垃圾收集器,通過與用戶線程併發執行的方式減少GC停頓的時間。本文主要聊一下CMS設計到的相關的數據結構、具體的執行過程、運行中會出現的異常情況。
在CMS之前並行垃圾收集器通過下圖方式進行,雖然GC階段多線程並行執行單此時用戶線程是完全暫停的。如果GC時間過長,將引發服務響應超時、調用接口超時等各類異常。
而CMS垃圾收集器大部分時間GC線程與用戶線程併發執行,只有在初始標記和重新標記階段才暫停用戶線程
總體思路:當達到GC條件時,開始併發標記存活的對象,併發的過程中記錄對象引用關係的變化。併發標記結束後,暫停用戶線程,處理引用關係變化,得到所有存活的對象和可以清理的對象。最終併發清理掉不被使用的對象(存在已經不被引用但本次清理不掉的對象)。
執行過程
初始標記initial mark
暫停用戶線程,標記GC root和新生代直接關聯的對象。
這裏的GC root關聯的對象包含虛擬機棧中引用的對象、類靜態屬性引用的對象、本地方法棧中JNI引用的對象。另外,CMS是老年代的垃圾收集器,被新生代引用的對象應該被標記爲存活,所以這裏還包含新生代對象。
併發標記 concurrent mark
啓動用戶線程,三色遍歷法遍歷標記老年代的所有對象。
三色表示
- 黑色對象:自己被標記,且引用對象已經處理完成。
- 灰色對象:自己被標記,但引用對象未處理。
- 白色對象:沒有對它做標記。
標記過的意思就是認爲這個對象是存活的,本次GC不回收這個對象。
遍歷算法
說到對象的遍歷方式,自然而然會想到藉助棧或隊列進行圖遍歷。對象之間的關係正好構建成一個圖,對象就是節點,引用關係就是邊。但是圖遍歷有這樣一個問題: GC root和新生代的對象都要裝進棧或隊列當中,可能會導致佔用特別大的額外空間。
爲了避免上的問題,CMS採用了線性遍歷的方式。
首先,引入一個bit數組,用它表示內存中每個位置的狀態,每個bit位代表4字節的內存空間,所以它的大小是固定的。
遍歷的策略也直接引用論文中的圖片,感興趣的同學可以細品~
遍歷bitmap,找到被標記爲存活的對象cur,將它壓入棧中,然後開始遍歷這個對象出發的所有對象。遍歷棧的過程中,如果遇到地址比cur低的對象則標記並壓如棧中,遇到地址比cur高則只標記不入棧。所有GC root引用的對象已經在初始標記階段標記成了存活對象,遍歷過程遇到其中一個就開始利用棧遍歷它及它之前的所有對象,這就保證了棧中最多有1個GC root直接引用的對象,有效控制了棧空間的大小。
標記過程舉例
標記的過程伴隨着對象引用的修改,下圖舉例說明了併發標記的過程
- 初始標記(圖中未體現,參考步驟a):初始標記時對a進行了標記。
- 步驟a:對a引用的對象bc,及b引用的對象e進行標記,根據當時的對象引用關係,abcegd是存活的。
- 步驟b:對象引用關係發生了改變,b不再引用c新增引用d,g不再引用d
- 步驟c:完成了併發標記的過程,abceg被標記。第二個和第四個區域內的對象引用關係發生了改變,被記錄了下來。這裏面運用到了card table、mod union table數據結構和write barrier技術,後面進行說明。
- 步驟d:實際上是重新標記後的結果,可以看到對象d在併發標記結束時未進行標記,但是它還在被對象b引用,不應該回收。這就依賴重新標記階段對dirty card(對象引用關係發生變化的區域)的處理。
從上面的例子中可以看到CMS的一些特性:
- 初始標記時,不可達的對象,在本次GC中一定會被回收,如對象f
- 已經標記過的對象,即使最終已經不被引用本次GC也不會回收,如對象c
併發過程中變化的維護
card table與mod union table
card table是一個數組,數組中每個位置存的是一個byte,每個比特位有不同的作用(具體的作用可能得研究下源碼,沒有找到相關的資料)。CMS將老年代的空間分成大小爲512bytes的塊,card table中的每個元素對應着一個塊。
對於新生代:它記錄老年代到新生代的引用,younggc時不用遍歷整個老年代
對於老年代:它記錄併發標記開始引用發生變化的card,併發標記結束後需要處理這些card
由於新生代GC與老年代GC同時使用card table,所以會出現衝突的情況。新生代GC時,發現老年代的dirty card(card的一種狀態)沒有指向新生代的引用,會將這個card設置爲clean(改變了老年代對象引用發生設置的狀態),但這個card必須在remark階段進行重新標記。所以增加了另一個數據結構mod union table解決此問題。
mod union table是一個bit位向量,一個bit表示一個card的狀態。
它由新生代垃圾收集器維護,新生代GC將card設置爲clean之前,把mod union table設置爲dirty。card table狀態爲dirty、或者mod union table標記爲dirty、或者同時兩種數據結構都標記爲dirty的card表示併發標記階段引用發生了變化,需要在後面的階段進行處理。
write barrie
write barrie寫屏障類似於一個切面,用戶線程寫對象引用的時候就觸發write barrier的邏輯,將對象所處的card設置爲dirty。
併發預清理concurrent preclean
處理dirty card,降低remark階段暫停時間。
重新標記的過程是STW的,所以爲了縮短停頓時間,在併發標記之前應該儘可能多的完成重新標記階段的工作。併發預清理就是對dirty card進行遍歷處理,降低重新標記需要處理的dirty card的數量。
可中斷預清理concurrent abortable preclean
繼續處理dirty card,滿足條件時停止當前階段,進入下一階段。
可中斷預清理也會處理dirty card,替remark階段分擔一部分工作。這個階段更主要的目的是控制remark和新生代GC分開執行,避免連續兩次暫停導致總的暫停時間過長,其中運用了一些策略。
預清理階段結束之後,如果Eden空間大於CMSScheduleRemarkEdenSizeThreshold(默認2M),則進入可中斷預清理階段。
當Eden空間達到CMSScheduleRemarkEdenPenetration(默認50%)時進入remark階段。
如果等待超過了CMSMaxAbortablePrecleanTime(默認5s)同樣進入remark階段。
另外,還有個CMSMaxAbortablePrecleanLoops參數可以控制可中斷預清理循環的次數,到達次數則退出預清理階段進入remark,默認是0不限制次數。
重新標記final remark
暫停用戶線程,從GC root(包含新生代對象)出發重新標記,並處理完所有dirty card。
併發階段,老年代可能面對如下變化,重新標記後全部確定哪些是存活的或者不存活的
- 晉升到老年代的對象
- 直接分配到老年代的對象
- 老年代中引用關係改變的對象
- 新生代到老年代引用關係的改變
- GC root到老年代引用關係的改變
新生代大小的影響和控制:
重新標記階段需要遍歷新生代對象,但新生代裏大多都是垃圾,如果remark之前發生一次新生代GC,則會大大減小remark階段需要遍歷的對象數量。可以設置CMSScavengeBeforeRemark參數強制在remark之前執行一次新生代GC。但是正如可中斷預清理階段的分析,新生代GC也是有停頓的,這樣兩次停頓連在一起也可能會很長,需要進行權衡。
併發清除concurrent sweep
併發清除標記爲不可達的對象,回收併合並空閒內存。
併發重置concurrent reset
重新設置CMS相關的各種狀態及數據結構,爲下一個垃圾收集週期做好準備。
缺點
cpu資源敏感,降低吞吐量
CMS沒有運行的時候所有全部cpu資源都供用戶線程使用,CMS開始併發運行後就要跟用戶線程競爭cpu資源,導致應用線程運行變慢。對於cpu資源非常緊缺的系統,假設只有2核,CMS運行起來後將佔用一半的cpu資源,用戶線程將感知到運行速度減半。
併發帶來的好處是可以降低用戶線程的停頓時間,對於在線服務類應用非常有益,因爲長時間的停頓可能導致響應超時等問題。但相對於非併發垃圾收集器,CMS整個週期內很多工作是重複的(比如重新標記階段對dirty card中的對象重新標記,而在併發標記階段可能已經標記過了),導致整體的吞吐量是降低的。
浮動垃圾
因爲CMS垃圾收集器的特性,被標記過的對象,即使最終變成垃圾本次GC也不會回收它,這些垃圾就是浮動垃圾。浮動垃圾的產生意味着內存裏不光裝着存活對象,還要裝着這些浮動垃圾,所以容納同樣多的存活對象CMS需要佔用更大的內存空間。
內存碎片
CMS使用標記清除算法,收集結束之後會產生大量內存碎片。當有大對象需要分配空間時,可能總的空間大小是足夠的,但是沒有連續的空間裝下此對象。
CMS默認開啓UseCMSCompactAtFullCollection 參數,在FullGC時進行內存碎片的合併整理。內存碎片雖然解決了,但負面影響就是停頓時間變長了。還有另外一個CMSFullGCsBeforeCompaction參數可以控制多少次FullGC纔會進行整理,默認是0代表每次FullGC都會進行碎片整理。
運行過程常見問題
concurrent mode failure
併發雖好,但會引入一些問題。對於非併發的垃圾收集器,可以等到老年代無法分配對象時再執行GC。但對於cms則需要預留出空間提前開始GC,預留的空間供併發期間新對象的分配及新生代對象的晉升使用。如果在老年代分配對象發現老年代裝不下,則會觸發concurrent mode failure,此時將會暫停用戶線程執行FullGC或者串行模式的CMS。
promotion failed
這個錯誤涉及到CMS擔保機制,新生代GC之前會根據歷史晉升到老年代對象的大小,預估本次老年代是否足夠容納新生代晉升的對象。如果預估時空間足夠,但新生代GC實際執行時發現容納不了,則會引起promotion failed錯誤。
OutOfMemoryError
CMS垃圾收集器發現大部分時間都浪費在GC上就會拋出OutOfMemoryError異常,具體爲98%的時間在GC但回收不到2%的空間。這樣做實際上是爲了防止程序進入一種雖然在運行實際上一直在GC假死狀態,也可以通過設置-XX:-UseGCOverheadLimit禁用該機制。
閱讀建議:不管是大V或是小號都會有自己的關注點、忽略的點,都有自己理解到位以及理解有偏差的地方。如果有緣讀到本文,建議參考下官方文檔、論文。至於源碼,閱讀成本比較高,感覺看一下自己感興趣的地方就可以了,網上有一些源碼理解的分享。
官網:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
論文:https://www.cs.purdue.edu/homes/hosking/ismm2000/papers/printezis.pdf