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


在開始介紹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堆的Region布局.png

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的信息,這個速度影響了復制的速度,進而影響暫停時間。

image.png

卡表(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實戰》《極客時間專欄》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM