10. 系統分析垃圾收集器

一、垃圾收集算法

垃圾收集常用的算法有三種。標記-清除算法,標記-複製算法,標記-整理算法。下面一個一個來看:

1.1標記清除算法

標記清除算法分為「標記」和「清除」兩個階段:標記存活的對象, 統一回收所有未被標記的對象(一般選擇這種);也可以反過來,標 記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象 。

1.1.1 標記清除算法的原理

當堆中的有效內存空間(available memory)被耗盡的時候,就會停止整個程序(也被稱為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除。

  • 標記: Collector從引用根結點開始遍歷,標記所有被引用的對象。一般是在對象的Header中記錄為可達對象。

  • 清除: Collector對堆內存從頭到尾進行線性的遍歷,如果發現某個對象在其Header中沒有標記為可達對象,則將其回收。

1.1.2 標記清除算法存在的問題

標記清除算法是最基礎的收集算法,比較簡單,但是會帶來 兩個明顯的問題:

1. 效率問題

  • 如果需要標記的對象太多,效率不高
  • 如果內存空間太大,效率也不高

2. 空間問題

  • 標記清除後會產生大量不連續的碎片

1.2標記複製算法

標記複製算法包含兩個步驟:標記和複製。

1.2.1 標記複製算法的原理

標記複製算法的原理是,將指定的一塊內存分為大小相同的兩塊,每次使用其中的一塊。當這一塊的內存使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間清理掉。這樣就使每次的內存回收都是對內存區間的一半進行回收。不難想像,在下一次GC之後,左邊將會再次變成活動區間。如下圖:

1.2.2 標記複製算法存在的問題

標記複製算法需要兩塊空間,對內存要求比較大,內存的利用率比較低。適用於短生存期的對象,持續複製長生存期的對象則導致效率降低

1.3 標記整理算法

1.3.1 標記-整理算法的原理

標記整理算法的標記過程和標記-清除算法一樣,因為標記清除算法會導致很多留下來的內存空間碎片,隨着碎片的增多,嚴重影響內存讀寫的性能,所以在標記-清除之後,會對內存的碎片進行整理。讓所有存活的對象向一端移動,然後直接清理掉另一端的內存。由於壓縮空間需要一定的時間,會影響垃圾收集的時間。通常用在老年代,這也是老年代耗時多的原因之一。如下圖:

1.3.2 標記整理算法存在的問題

標記整理是標記清除的擴展版,在標記清除以後,對內存空間進行整理。這樣會更耗費時間。

二、分代收集理論

GC分代的基本假設:絕大部分對象的生命周期都非常短暫,存活時間短。

通常,我們將java堆分為新生代和老年代,「分代收集」(Generational Collection)就是根據堆劃分的特點採用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,所以可以選擇複製算法,只需要付出少量存活對象的複製成本就可以完成每次垃圾收集。而老年代中對象存活的概率是比較高的,而且沒有額外空間對它進行分配擔保,就使用「標記-清除」或「標記-整理」算法來進行回收。「標記-清除」或「標記-整理」算法會比複製算法慢10倍以上。

三、垃圾收集器

垃圾收集器按照堆空間分類方法分為新生代垃圾收集器,老年代垃圾收集器。常見的新生代垃圾收集器有:Serial、ParNew、Parallel;常見的老年代垃圾收集器有:CMS、Serial Old、Parallel Old。還有既有新生代又有老年代的收集器,如:G1、ZGC等。不同類型的垃圾收集器採用的垃圾收集算法是不同的。通常新生代使用的是標記-複製算法;老年代使用的是標記清除和標記整理算法。

常見的垃圾收集器如下圖:

Serial、ParNew、Parallel Scavenge用於新生代;CMS、Serial Old、Paralled Old用於老年代。並且他們之間以相對固定的組合使用(具體組合關係如上圖)。G1是一個獨立的收集器不依賴其他6種收集器。ZGC是目前JDK 11的實驗收集器。下面來研究一下各種類型的垃圾收集器

3.1 Serial收集器

Serial(串行)收集器是最基本、歷史最悠久的垃圾收集器。Serial收集器是一個單線程收集器。它的 「單線程」 的意義不僅僅意味着它只會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( “Stop The World” ),直到它收集結束。

因為新生代的特點是對象存活率低,所以收集算法用的是【標記複製】算法,把新生代存活對象複製到老年代,複製的內容不多,性能較好。 如下圖:

Serial收集器是新生代垃圾收集器,其對應的Serial Old是老年代垃圾收集器。Serial Old也是單線程收集器,它主要有兩大用途:

  • 一種用途是在JDK1.5以及以前的版本中與Parallel Scavenge收集器搭配使用,
  • 另一種用途是作為CMS收集器的後備方案。

Serial收集器參數配置

啟用Serial收集器, 啟用Serial Old收集器
-XX:+UseSerialGC 
-XX:+UseSerialOldGC

3.2 Parallel收集器

Parallel收集器其實就是Serial收集器的多線程版本,除了使用多線程進行垃圾收集外,其餘行為(控制參數、收集算法、回收策略等等)和Serial收集器類似。默認的收集線程數跟cpu核數相同,當然也可以用參數(-XX:ParallelGCThreads)指定收集線程數,但是一般不推薦修改。

Parallel Scavenge收集器是一個新生代收集器,採用標記複製算法,並行手機垃圾。該收集器關注點是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的關注點更多的是用戶線程的停頓時間(提高用戶體驗)。

什麼是吞吐量呢?

就是CPU中用於運行用戶代碼的時間與CPU總消耗時間的比值。 即:

Parallel Scavenge收集器提供兩個參數控制垃圾回收的執行:

  • -XX:MaxGCPauseMillis,最大垃圾回收停頓時間。這個參數的原理是空間換時間,收集器會控制新生代的區域大小,從而儘可能保證回收少於這個最大停頓時間。簡單的說就是回收的區域越小,那麼耗費的時間也越小。
    所以這個參數並不是設置得越小越好。設太小的話,新生代空間會太小,從而更頻繁的觸發GC。
  • -XX:GCTimeRatio,垃圾回收時間與總時間佔比。這個是吞吐量的倒數,原理和MaxGCPauseMillis相同。

因為Parallel Scavenge收集器關注的是吞吐量,所以當設置好以上參數的時候,同時不想設置各個區域大小(新生代,老年代等)。可以開啟-XX:UseAdaptiveSizePolicy參數,讓JVM監控收集的性能,動態調整這些區域大小參數。

新生代採用複製算法,老年代採用標記-整理算法。

Parallel垃圾收集器對應的老年代垃圾收集器是Parallel Old。Parallel Old採用的也是多線程收集垃圾。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器。

JDK8默認的新生代和老年代收集器

3.3 ParNew收集器

ParNew同樣用於新生代,跟Parallel收集器很類似,也是採用多線程的方式收集垃圾,Par是Parallel的縮寫。ParNew收集器工作的時候同樣需要STW(Stop The World)。ParNew主要和CMS收集器配合使用。另外Parallel收集器更多關注的是吞吐量。當對吞吐量以及CPU要求比較高的情況下,建議使用Parallel收集器。

因為是多線程執行,所以在多CPU下,ParNew效果通常會比Serial好。但如果是單CPU則會因為線程的切換,性能反而更差。

ParNew收集器是許多運行在Server模式下的虛擬機的首要選擇,除了Serial收集器外,只有它能與CMS收集器(真正意義上的並發收集器,後面會介紹到)配合工作。

參數設置

使用-XX:+UseConcMarkSweepGC選項後默認新生代收集器為ParNew收集器;
使用-XX:+UseParNewGC選項強制指定使用ParNew收集器;
使用-XX:ParallelGCThreads參數限制垃圾收集的線程數;

3.4 CMS收集器

1.什麼是CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。它在垃圾收集時使得用戶線程和 GC 線程並發執行,因此在垃圾收集過程中用戶也不會感到明顯的卡頓。是基於多線程的「標記-清除」算法

CMS非常符合在注重用戶體驗的應用上使用,它是HotSpot虛擬機第一款真正意義上的並發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。

2.CMS收集器的工作原理

CMS整個過程比之前的收集器要複雜,整個過程分為四步:

第一步:初始標記。(Stop The World) 只是標記一下 GC Roots 能直接關聯的對象,速度很快,仍然需要暫停所有的工作線程。

第二步:並發標記。(Stop The World) 進行 GC Roots 跟蹤的過程,和用戶線程一起工作,不需要暫停工作線程。

第三步:重新標記 。為了修正在並發標記期間,因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,仍然需要暫停所有的工作線程。

第四步:並發清除 。這裡包含兩個步驟。並發清理和線程重置。

  • 並發清理: 開啟用戶線程,同時GC線程開始對未標記的區域做清掃。這個階段如果有新增對象會被標記為黑色不做任何處理(見下面三色標記算法詳解)。 ,和用戶線程一起工作,不需要暫停工作線程。
  • 線程重置:重置本次GC過程中的標記數據。

由於耗時最長的並發標記並發清除過程中,垃圾收集線程可以和用戶一起並發工作,所以總體上來看CMS 收集器的內存回收和用戶線程是一起並發地執行。

是一款優秀的垃圾收集器,具有並發收集、低停頓的優點。但也有幾個非常明顯的缺點:

  • 對CPU資源敏感(會和服務搶資源);
  • 無法處理浮動垃圾(在並發標記和並發清理階段又產生垃圾,這種浮動垃圾只能等到下一次gc再清理了);
  • 它使用的回收算法-「標記-清除」算法會導致收集結束時會有大量空間碎片產生。我們可以通過參數設置讓jvm在執行完標記清除以後進行整理
XX:+UseCMSCompactAtFullCollection    //可以讓jvm在執行完標記清除後再做整理
  • 執行過程中的不確定性,會存在上一次垃圾回收還沒執行完,然後垃圾回收又被觸發的情況,特別是在並發標記和並發清理階段會出現,一邊回收,系統一邊運行,也許沒回收完就再次觸發full gc,也就是”concurrentmode failure“,此時會進入stop the world,用serial old垃圾收集器來回收

3. cms相關的參數

1. -XX:+UseConcMarkSweepGC:啟用cms 
2. -XX:ConcGCThreads:並發的GC線程數 
3. -XX:+UseCMSCompactAtFullCollection:FullGC之後做壓縮整理(減少碎片) 
4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之後壓縮一次,默認是0,代表每次FullGC後都會壓縮一次
5. -XX:CMSInitiatingOccupancyFraction: 當老年代使用達到該比例時會觸發FullGC(默認是92,這是百分比) 
6. -XX:+UseCMSInitiatingOccupancyOnly:只使用設定的回收閾值(-XX:CMSInitiatingOccupancyFraction設 定的值),如果不指定,JVM僅在第一次使用設定值,後續則會自動調整 
7. -XX:+CMSScavengeBeforeRemark:在CMS GC前啟動一次minor gc,目的在於減少老年代對年輕代的引 用,降低CMS GC的標記階段時的開銷,一般CMS的GC耗時 80%都在標記階段 
8. -XX:+CMSParallellnitialMarkEnabled:表示在初始標記的時候多線程執行,縮短STW 
9. -XX:+CMSParallelRemarkEnabled:在重新標記的時候多線程執行,縮短STW;

4. 既然Mark Sweep會造成內存碎片,那麼為什麼不把算法換成Mark Compact呢?

答案其實很簡答,因為當並發清除的時候,用Compact整理內存的話,原來的用戶線程使用的內存還怎麼用呢?要保證用戶線程能繼續執行,前提的它運行的資源不受影響嘛。Mark Compact更適合「Stop the World」這種場景下使用。