1. 程式人生 > >弄明白CMS和G1,就靠這一篇了

弄明白CMS和G1,就靠這一篇了

目錄

  • 1 CMS收集器
    • 安全點(Safepoint)
    • 安全區域
  • 2 G1收集器
    • 卡表(Card Table)
  • 3 總結
  • 4 參考

在開始介紹CMS和G1前,我們可以劇透幾點:

  • 根據不同分代的特點,收集器可能不同。有些收集器可以同時用於新生代和老年代,而有些時候,則需要分別為新生代或老年代選用合適的收集器。一般來說,新生代收集器的收集頻率較高,應選用效能高效的收集器;而老年代收集器收集次數相對較少,對空間較為敏感,應當避免選擇基於複製演算法的收集器。
  • 在垃圾收集執行的時刻,應用程式需要暫停執行。
  • 可以序列收集,也可以並行收集。
  • 如果能做到併發收集(應用程式不必暫停),那絕對是很妙的事情。
  • 如果收集行為可控,那也是很妙的事情。

CMS和G1作為垃圾收集器裡的大殺器,是需要好好弄明白的,而且面試中也經常被問到。

希望大家帶著下面的問題進行閱讀,有目標的閱讀,收穫更多:

  1. 為什麼沒有一種牛逼的收集器像銀彈一樣適配所有場景?
  2. CMS的優點、缺點、適用場景?
  3. 為什麼CMS只能用作老年代收集器,而不能應用在新生代的收集?
  4. G1的優點、缺點、適用場景?

1 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。這是因為CMS收集器工作時,GC工作執行緒與使用者執行緒可以併發

執行,以此來達到降低收集停頓時間的目的。

CMS收集器僅作用於老年代的收集,是基於標記-清除演算法的,它的運作過程分為4個步驟:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

其中,初始標記重新標記這兩個步驟仍然需要Stop-the-world。初始標記僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快,併發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始階段稍長一些,但遠比並發標記的時間短。

CMS以流水線方式拆分了收集週期,將耗時長的操作單元保持與應用執行緒併發執行。只將那些必需STW才能執行的操作單元單獨拎出來,控制這些單元在恰當的時機執行,並能保證僅需短暫的時間就可以完成。這樣,在整個收集週期內,只有兩次短暫的暫停(初始標記和重新標記),達到了近似併發的目的。

CMS收集器優點:併發收集、低停頓。

CMS收集器缺點:

  • CMS收集器對CPU資源非常敏感。
  • CMS收集器無法處理浮動垃圾(Floating Garbage)。
  • CMS收集器是基於標記-清除演算法,該演算法的缺點都有。

CMS收集器之所以能夠做到併發,根本原因在於採用基於“標記-清除”的演算法並對演算法過程進行了細粒度的分解。前面篇章介紹過標記-清除演算法將產生大量的記憶體碎片這對新生代來說是難以接受的,因此新生代的收集器並未提供CMS版本。

另外要補充一點,JVM在暫停的時候,需要選準一個時機。由於JVM系統執行期間的複雜性,不可能做到隨時暫停,因此引入了安全點的概念。

安全點(Safepoint)

安全點,即程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定既不能太少以至於讓GC等待時間太長,也不能過於頻繁以致於過分增大執行時的負荷。

安全點的初始目的並不是讓其他執行緒停下,而是找到一個穩定的執行狀態。在這個執行狀態下,Java虛擬機器的堆疊不會發生變化。這麼一來,垃圾回收器便能夠“安全”地執行可達性分析。只要不離開這個安全點,Java虛擬機器便能夠在垃圾回收的同時,繼續執行這段原生代碼。

程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的。“長時間執行”的最明顯特徵就是指令序列複用,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

對於安全點,另一個需要考慮的問題就是如何在GC發生時讓所有執行緒(這裡不包括執行JNI呼叫的執行緒)都“跑”到最近的安全點上再停頓下來。

兩種解決方案:

  • 搶先式中斷(Preemptive Suspension)

    搶先式中斷不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它“跑”到安全點上。現在幾乎沒有虛擬機器採用這種方式來暫停執行緒從而響應GC事件。

  • 主動式中斷(Voluntary Suspension)

    主動式中斷的思想是當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方。

安全區域

指在一段程式碼片段中,引用關係不會發生變化。在這個區域中任意地方開始GC都是安全的。也可以把Safe Region看作是被擴充套件了的Safepoint。

2 G1收集器

G1重新定義了堆空間,打破了原有的分代模型,將堆劃分為一個個區域。這麼做的目的是在進行收集時不必在全堆範圍內進行,這是它最顯著的特點。區域劃分的好處就是帶來了停頓時間可預測的收集模型:使用者可以指定收集操作在多長時間內完成。即G1提供了接近實時的收集特性。

G1與CMS的特徵對比如下:

特徵 G1 CMS
併發和分代
最大化釋放堆記憶體
低延時
吞吐量
壓實
可預測性
新生代和老年代的物理隔離

G1具備如下特點:

  • 並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU來縮短Stop-the-world停頓的時間,部分其他收集器原來需要停頓Java執行緒執行的GC操作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。
  • 分代收集
  • 空間整合:與CMS的標記-清除演算法不同,G1從整體來看是基於標記-整理演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現的。但無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC。
  • 可預測的停頓:這是G1相對於CMS的一個優勢,降低停頓時間是G1和CMS共同的關注點。

在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。在堆的結構設計時,G1打破了以往將收集範圍固定在新生代或老年代的模式,G1將堆分成許多相同大小的區域單元,每個單元稱為Region。Region是一塊地址連續的記憶體空間,G1模組的組成如下圖所示:

G1收集器將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。Region的大小是一致的,數值是在1M到32M位元組之間的一個2的冪值數,JVM會盡量劃分2048個左右、同等大小的Region,這一點可以參看如下原始碼。其實這個數字既可以手動調整,G1也會根據堆大小自動進行調整。

#ifndef SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP
#define SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

#include "memory/allocation.hpp"

class HeapRegionBounds : public AllStatic {
private:
  // Minimum region size; we won't go lower than that.
  // We might want to decrease this in the future, to deal with small
  // heaps a bit more efficiently.
  static const size_t MIN_REGION_SIZE = 1024 * 1024;

  // Maximum region size; we don't go higher than that. There's a good
  // reason for having an upper bound. We don't want regions to get too
  // large, otherwise cleanup's effectiveness would decrease as there
  // will be fewer opportunities to find totally empty regions after
  // marking.
  static const size_t MAX_REGION_SIZE = 32 * 1024 * 1024;

  // The automatic region size calculation will try to have around this
  // many regions in the heap (based on the min heap size).
  static const size_t TARGET_REGION_NUMBER = 2048;

public:
  static inline size_t min_size();
  static inline size_t max_size();
  static inline size_t target_number();
};

#endif // SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1會通過一個合理的計算模型,計算出每個Region的收整合本並量化,這樣一來,收集器在給定了“停頓”時間限制的情況下,總是能選擇一組恰當的Regions作為收集目標,讓其收集開銷滿足這個限制條件,以此達到實時收集的目的。

對於打算從CMS或者ParallelOld收集器遷移過來的應用,按照官方 的建議,如果發現符合如下特徵,可以考慮更換成G1收集器以追求更佳效能:

  • 實時資料佔用了超過半數的堆空間;
  • 物件分配率或“晉升”的速度變化明顯;
  • 期望消除耗時較長的GC或停頓(超過0.5——1秒)。

原文如下:
Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.

  • More than 50% of the Java heap is occupied with live data.
  • The rate of object allocation rate or promotion varies significantly.
  • Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)

G1收集的運作過程大致如下:

  • 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的物件,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時,能在正確可用的Region中建立新物件,這階段需要停頓執行緒,但耗時很短。
  • 併發標記(Concurrent Marking):是從GC Roots開始堆中物件進行可達性分析,找出存活的物件,這階段耗時較長,但可與使用者程式併發執行。
  • 最終標記(Final Marking):是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程Remembered Set Logs裡面,最終標記階段需要把Remembered Set Logs的資料合併到Remembered Set中,這階段需要停頓執行緒,但是可並行執行。
  • 篩選回收(Live Data Counting and Evacuation):首先對各個Region的回收價值和成本進行排序,根據使用者所期望的GC停頓時間來制定回收計劃。這個階段也可以做到與使用者程式一起併發執行,但是因為只回收一部分Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅提高收集效率。

全域性變數和棧中引用的物件是可以列入根集合的,這樣在尋找垃圾時,就可以從根集合出發掃描堆空間。在G1中,引入了一種新的能加入根集合的型別,就是記憶集(Remembered Set)。Remembered Sets(也叫RSets)用來跟蹤物件引用。G1的很多開源都是源自Remembered Set,例如,它通常約佔Heap大小的20%或更高。並且,我們進行物件複製的時候,因為需要掃描和更改Card Table的資訊,這個速度影響了複製的速度,進而影響暫停時間。

卡表(Card Table)

有個場景,老年代的物件可能引用新生代的物件,那標記存活物件的時候,需要掃描老年代中的所有物件。因為該物件擁有對新生代物件的引用,那麼這個引用也會被稱為GC Roots。那不是得又做全堆掃描?成本太高了吧。

HotSpot給出的解決方案是一項叫做卡表(Card Table)的技術。該技術將整個堆劃分為一個個大小為512位元組的卡,並且維護一個卡表,用來儲存每張卡的一個標識位。這個標識位代表對應的卡是否可能存有指向新生代物件的引用。如果可能存在,那麼我們就認為這張卡是髒的。

在進行Minor GC的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找髒卡,並將髒卡中的物件加入到Minor GC的GC Roots裡。當完成所有髒卡的掃描之後,Java虛擬機器便會將所有髒卡的標識位清零。

想要保證每個可能有指向新生代物件引用的卡都被標記為髒卡,那麼Java虛擬機器需要截獲每個引用型例項變數的寫操作,並作出對應的寫標識位操作。

卡表能用於減少老年代的全堆空間掃描,這能很大的提升GC效率。

我們可以看下官方文件對G1的展望(這段英文描述比較簡單,我就不翻譯了):

Future:
G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.

3 總結

查了下度娘有關G1的文章,絕大部分文章對G1的介紹都是停留在JDK7或更早期的實現很多結論已經存在較大偏差了,甚至一些過去的GC選項已經不再推薦使用。舉個例子,JDK9中JVM和GC日誌進行了重構,如PrintGCDetails已經被標記為廢棄,而PrintGCDateStamps已經被移除,指定它會導致JVM無法啟動。

本文對CMS和G1的介紹絕大部分內容也是基於JDK7,新版本中的內容有一點介紹,倒沒做過多介紹(本人對新版本JVM還沒有深入研究),後面有機會可以再出專門的文章來重點介紹。

4 參考

《深入理解Java虛擬機器》《HotSpot實戰》《極客時間專