前言

之前學習JVM垃圾回收時,主要是過了一遍垃圾收集算法,比如複製算法,標記-清除算法,標記-整理算法,在此基礎上可以增加分代,每代採取不同的回收算法,以提高整體的分配和回收效率。然後過了一遍JVM中的垃圾收集器,比如Serial、Parallel Scavenge、Parallel New、CMS、G1等。

自認爲垃圾收集就是根據GC Root標記所有可達的對象,然後把所有沒有標記的對象清除就ok了。是不是很簡單。事實上垃圾收集也就是這麼一回事,但是很多時候說起來簡單,做起來卻會出現很多問題。這篇文章就是記錄我對CMS垃圾收集器的一些疑問並學習的過程。

首先看一下CMS的整體流程(具體每個流程的詳情就自行了解吧)

CMS流程

如何進行標記?

最近在看Golang的GC算法實現,裏面用到了三色標記法,但是在我的知識庫中對三色標記法有這個概念,是的,我只知道這個概念,不知道三色標記法是怎麼一個流程,也不知道三色標記法在GC中怎麼與運行的。於是就開始了我的探險之旅。

在搜索了一下三色標記法(具體可以看一下文末參考文檔中 三色標記法與讀寫屏障 瞭解詳情)後,發現現代追蹤式(可達性分析)的垃圾回收器幾乎都借鑑了三色標記的算法思想,CMS垃圾收集器也不例外。

GC Root有哪些?

我們知道怎麼進行標記了,但最初標記的時候需要一些根據纔行啊,這些根據就是我們收的GC Root。GC Root有哪些?網上有很多的答案,我的理解就是

  • 當前活躍調用棧中的指向對象的引用
  • 一些不會發生改變的數據所指向的引用

這裏我使用的是引用,而不是對象,因爲R大是這樣說的(具體的問題見參考文檔 java的gc爲什麼要分代?

所謂“GC roots”,或者說tracing GC的“根集合”,就是 一組必須活躍的引用

例如說,這些引用可能包括:

  • 所有Java線程當前活躍的棧幀裏指向GC堆裏的對象的引用;換句話說,當前所有正在被調用的方法的引用類型的參數/局部變量/臨時值。
  • VM的一些靜態數據結構裏指向GC堆裏的對象的引用,例如說HotSpot VM裏的Universe裏有很多這樣的引用。
  • JNI handles,包括global handles和local handles
  • (看情況)所有當前被加載的Java類
  • (看情況)Java類的引用類型靜態變量
  • (看情況)Java類的運行時常量池裏的引用類型常量(String或Class類型)
  • (看情況)String常量池(StringTable)裏的引用

注意,是一組必須活躍的 引用 ,不是對象。

現在知道了GC Root,但是我們都知道有分代的概念,新生代的gc和老年的代的gc回收的區域是不一樣,那麼這裏的GC Root是不是應該不一樣呢?肯定是不一樣的。

首先看一下 新生代的GC

新生代的區域一般都比較小,而且對象的存活率都比較低,所以按照前面說的GC Root在新生代的區域掃描就行了。但是會有一個問題?老年代存在引用新生代對象的可能啊?如果只掃描新生代的區域,會漏掉被老年代引用的對象,這些對象就會被清除掉,這是不允許的。

如果這樣的話,那是不是掃描一下老年代的對象,看是否引用新生代的對象是不是就ok了?嗯這麼做肯定是ok的,但是老年代一般很大,而且存活的對象很多,會導致掃描佔用很長的時間。那這個問題如何解?JVM是如何避免Minor GC時掃描全堆的?

經過統計信息顯示,老年代持有新生代對象引用的情況不足1%,根據這一特性JVM引入了卡表(card table)來實現這一目的。如下圖所示:

CardTable

卡表的具體策略是將老年代的空間分成大小爲512B的若干張卡(card)。卡表本身是單字節數組,數組中的每個元素對應着一張卡,當發生老年代引用新生代時,虛擬機將該卡對應的卡表元素設置爲適當的值。如上圖所示,卡表3被標記爲髒(卡表還有另外的作用,標識併發標記階段哪些塊被修改過),之後Minor GC時通過掃描卡表就可以很快的識別哪些卡中存在老年代指向新生代的引用。這樣虛擬機通過空間換時間的方式,避免了全堆掃描。

所以新年代GC的GC Root包含2部分

  • 新生代中滿足GC Root定義的對象
  • 卡表中老年代引用新生代的對象

老年代的GC

前面我們說了新生代的gc,我們已同樣的思路來看看老年代的gc,老年代的GC Root如何來標記呢?只掃描老年代可以嗎?當然是不行的,因爲新生代中也可能存在老年代對象的引用,好在新生代並不大,所以老年代GC的時候還需要掃描一遍新生代。

新生代GC的Root

所以老年代GC的GC Root包含2部分

  • 老生代中滿足GC Root定義的對象,如圖節點1;
  • 標記年輕代中活着的對象引用到的老年代的對象(指的是年輕代中還存活的引用類型對象,引用指向老年代中的對象)如圖節點2、3;

併發標記的好壞?

標記作爲垃圾回收的第一步,現在知道如何進行標記,接下來就是遍歷這些對象,將所有未標記的對象清理就完成GC了。

然而事實上並沒有這麼簡單,如果標記的時候是STW的,那就是這麼簡單,但是如果標記過程都STW會造成暫停時間過長,給人的感覺就是系統一卡一卡的。

於是就把標記的過程改成併發的進行,也就是CMS中併發標記的過程,然而這就是一切複雜問題的源頭。雖然併發標記提升了標記的效率,但是因此卻引發了一系列的問題。

因爲併發標記時,gc線程和用戶線程是並行的,所以在這個過程中會出現下面的情況(需要了解 三色標記法與讀寫屏障 ):

  • 新生代晉升到老年代
  • 黑色對象取消對灰色對象的引用(浮動垃圾)
  • 黑色對象新增對白色對象的引用(漏標)

其實在 三色標記法與讀寫屏障 文中已經給出瞭解決方法--添加讀寫屏障

  • 寫屏障 + SATB
  • 寫屏障 + 增量更新
  • 讀屏障(Load Barrier)

在CMS併發標記階段,使用 寫屏障 + 增量更新 的方法,將上面出現的情況標記爲dirty,這樣最後再遍歷處理一下Dirty集合中的對象就ok了

標記爲dirty

重新標記階段爲什麼還要掃描新生代?

因爲存在 跨代引用 ,但是前面說過這種情況,通過讀寫屏障的方式標記這些爲dirty,只需要掃描老年代和dirty集合就行了啊?哎,看來我還是太年輕,如果只掃描老年代和dirty集合會漏掉一部分,會是哪部分呢?老年代和dirty集合還沒有覆蓋完嗎?

是的,老年代和dirty集合的確沒有覆蓋完。我們來分析一下。老年代中經過初始標記和併發標記後,只有黑色對象和白色對象了,黑色的就是要留下的,白色的就是要被清除的。黑色對象是怎麼來的?根據GC Root找到的,所以只要併發標記過程中,GC Root不發生變化,黑色對象就沒有問題(不會漏標),如果在併發標記過程中GC Root發生了變化呢?

當併發標記過程中GC Root增加了,並且這個GC Root還引用了老年代中的對象,此時如果只掃描老年代和dirty集合就會漏標。因此重新標記階段仍然需要掃描新生代。

預處理階段都幹了啥?

預處理階段其實有2部分:

  • 預清理階段
  • 可終止的預處理

這個階段的目的都是爲了減輕後面的重新標記的壓力,提前做一點重新標記階段的工作。一般CMS的GC耗時80%都在remark階段,所以預處理階段也是爲了減少remark階段的STW時間。

重新標記階段需要做一下工作:

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

遍歷新生代對象時,可能很多對象已經是不可達了,但是還是需要掃描。遍歷Dirty Card做處理。

這2部分其實就是預處理階段幫助重新標記減輕壓力的地方

  • 預清理階段和可終止的預處理都會掃描Dirty Card做處理
  • 可終止的預處理,儘量進行一次ygc,讓不可達的對象被回收掉,remark階段遍歷新生代的對象成本小一點

具體這個階段的詳情見參考文檔 圖解CMS垃圾回收機制,你值得擁有

參考文檔

歡迎關注我們的微信公衆號,每天學習Go知識

相關文章