天天看點

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

作者:Java架構之路

文章較長,請耐心閱讀,希望對您有所幫助。文末有視訊教程擷取方式,内容更詳盡,免費分享!!!

垃圾收集算法

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

分代收集理論

目前虛拟機的垃圾收集都采用分代收集算法,這種算法沒有什麼新的思想,隻是根據對象存活周期的不同将記憶體分為幾塊。一般将java堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合适的垃圾收集算法。

比如在新生代中,每次收集都會有大量對象(近99%)死去,是以可以選擇複制算法,隻需要付出少量對象的複制成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行配置設定擔保,是以我們必須選擇“标記-清除”或“标記-整理”算法進行垃圾收集。注意,“标記-清除”或“标記-整理”算法會比複制算法慢10倍以上。

标記-複制算法

為了解決效率問題,“複制”收集算法出現了。它可以将記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就将還存活的對象複制到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

标記-清除算法

算法分為“标記”和“清除”階段:标記存活的對象, 統一回收所有未被标記的對象(一般選擇這種);也可以反過來,标記出所有需要回收的對象,在标記完成後統一回收所有被标記的對象 。它是最基礎的收集算法,比較簡單,但是會帶來兩個明顯的問題:

  1. 效率問題 (如果需要标記的對象太多,效率不高)
  2. 空間問題(标記清除後會産生大量不連續的碎片)
四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

标記-整理算法

根據老年代的特點特出的一種标記算法,标記過程仍然與“标記-清除”算法一樣,但後續步驟不是直接對可回收對象回收,而是讓所有存活的對象向一端移動,然後直接清理掉端邊界以外的記憶體。

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

垃圾收集器

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

如果說收集算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實作。

雖然我們對各個收集器進行比較,但并非為了挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇适合自己的垃圾收集器。試想一下:如果有一種四海之内、任何場景下都适用的完美收集器存在,那麼我們的Java虛拟機就不會實作那麼多不同的垃圾收集器了。

1.1 Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)

Serial(串行)收集器是最基本、曆史最悠久的垃圾收集器了。大家看名字就知道這個收集器是一個單線程收集器了。它的 “單線程” 的意義不僅僅意味着它隻會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( "Stop The World" ),直到它收集結束。

新生代采用複制算法,老年代采用标記-整理算法。

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

虛拟機的設計者們當然知道Stop The World帶來的不良使用者體驗,是以在後續的垃圾收集器設計中停頓時間在不斷縮短(仍然還有停頓,尋找最優秀的垃圾收集器的過程仍然在繼續)。

但是Serial收集器有沒有優于其他垃圾收集器的地方呢?當然有,它簡單而高效(與其他收集器的單線程相比)。Serial收集器由于沒有線程互動的開銷,自然可以獲得很高的單線程收集效率。

Serial Old收集器是Serial收集器的老年代版本,它同樣是一個單線程收集器。它主要有兩大用途:一種用途是在JDK1.5以及以前的版本中與Parallel Scavenge收集器搭配使用,另一種用途是作為CMS收集器的後備方案。

1.2 Parallel Scavenge收集器(-XX:+UseParallelGC(年輕代),-XX:+UseParallelOldGC(老年代))

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

Parallel Scavenge收集器關注點是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的關注點更多的是使用者線程的停頓時間(提高使用者體驗)。所謂吞吐量就是CPU中用于運作使用者代碼的時間與CPU總消耗時間的比值。 Parallel Scavenge收集器提供了很多參數供使用者找到最合适的停頓時間或最大吞吐量,如果對于收集器運作不太了解的話,可以選擇把記憶體管理優化交給虛拟機去完成也是一個不錯的選擇。

新生代采用複制算法,老年代采用标記-整理算法。

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多線程和“标記-整理”算法。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器(JDK8預設的新生代和老年代收集器)。

1.3 ParNew收集器(-XX:+UseParNewGC)

ParNew收集器其實跟Parallel收集器很類似,差別主要在于它可以和CMS收集器配合使用。

新生代采用複制算法,老年代采用标記-整理算法。

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

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

1.4 CMS收集器(-XX:+UseConcMarkSweepGC(old))

CMS(Concurrent Mark Sweep)收集器是一種以擷取最短回收停頓時間為目标的收集器。它非常符合在注重使用者體驗的應用上使用,它是HotSpot虛拟機第一款真正意義上的并發收集器,它第一次實作了讓垃圾收集線程與使用者線程(基本上)同時工作。

從名字中的Mark Sweep這兩個詞可以看出,CMS收集器是一種 “标記-清除”算法實作的,它的運作過程相比于前面幾種垃圾收集器來說更加複雜一些。整個過程分為四個步驟:

  • 初始标記: 暫停所有的其他線程(STW),并記錄下gc roots直接能引用的對象,速度很快。
  • 并發标記: 并發标記階段就是從GC Roots的直接關聯對象開始周遊整個對象圖的過程, 這個過程耗時較長但是不需要停頓使用者線程, 可以與垃圾收集線程一起并發運作。因為使用者程式繼續運作,可能會有導緻已經标記過的對象狀态發生改變。
  • 重新标記: 重新标記階段就是為了修正并發标記期間因為使用者程式繼續運作而導緻标記産生變動的那一部分對象的标記記錄(主要是處理漏标問題),這個階段的停頓時間一般會比初始标記階段的時間稍長,遠遠比并發标記階段時間短。主要用到三色标記裡的增量更新算法(見下面詳解)做重新标記。
  • 并發清理: 開啟使用者線程,同時GC線程開始對未标記的區域做清掃。這個階段如果有新增對象會被标記為黑色不做任何處理(見下面三色标記算法詳解)。
  • 并發重置:重置本次GC過程中的标記資料。
四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

從它的名字就可以看出它是一款優秀的垃圾收集器,主要優點:并發收集、低停頓。但是它有下面幾個明顯的缺點:

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

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标記階段(也會對年輕代一起做标記,如果在minor gc就幹掉了很多對垃圾對象,标記階段就會減少一些标記時間)時的開銷,一般CMS的GC耗時 80%都在标記階段
  8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标記的時候多線程執行,縮短STW
  9. -XX:+CMSParallelRemarkEnabled:在重新标記的時候多線程執行,縮短STW;

億級流量電商系統如何優化JVM參數設定(ParNew+CMS)

大型電商系統後端現在一般都是拆分為多個子系統部署的,比如,商品系統,庫存系統,訂單系統,促銷系統,會員系統等等。

我們這裡以比較核心的訂單系統為例

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

對于8G記憶體,我們一般是配置設定4G記憶體給JVM,正常的JVM參數配置如下:

-Xms3072M -Xmx3072M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8           

上節課說過,這樣設定可能會由于動态對象年齡判斷原則導緻頻繁full gc。

于是我們可以更新下JVM參數設定:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8           
四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

這樣就降低了因為對象動态年齡判斷原則導緻的對象頻繁進入老年代的問題,其實很多優化無非就是讓短期存活的對象盡量都留在survivor裡,不要進入老年代,這樣在minor gc的時候這些對象都會被回收,不會進到老年代進而導緻full gc。

對于對象年齡應該為多少才移動到老年代比較合适,本例中一次minor gc要間隔二三十秒,大多數對象一般在幾秒内就會變為垃圾,完全可以将預設的15歲改小一點,比如改為5,那麼意味着對象要經過5次minor gc才會進入老年代,整個時間也有一兩分鐘了,如果對象這麼長時間都沒被回收,完全可以認為這些對象是會存活的比較長的對象,可以移動到老年代,而不是繼續一直占用survivor區空間。

對于多大的對象直接進入老年代(參數-XX:PretenureSizeThreshold),這個一般可以結合你自己系統看下有沒有什麼大對象生成,預估下大對象的大小,一般來說設定為1M就差不多了,很少有超過1M的大對象,這些對象一般就是你系統初始化配置設定的緩存對象,比如大的緩存List,Map之類的對象。

可以适當調整JVM參數如下:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M           

對于JDK8預設的垃圾回收器是-XX:+UseParallelGC(年輕代)和-XX:+UseParallelOldGC(老年代),如果記憶體較大(超過4個G,隻是經驗值),系統對停頓時間比較敏感,我們可以使用ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)

對于老年代CMS的參數如何設定我們可以思考下,首先我們想下目前這個系統有哪些對象可能會長期存活躲過5次以上minor gc最終進入老年代。

無非就是那些Spring容器裡的Bean,線程池對象,一些初始化緩存資料對象等,這些加起來充其量也就幾十MB。

還有就是某次minor gc完了之後還有超過一兩百M的對象存活,那麼就會直接進入老年代,比如突然某一秒瞬間要處理五六百單,那麼每秒生成的對象可能有一百多M,再加上整個系統可能壓力劇增,一個訂單要好幾秒才能處理完,下一秒可能又有很多訂單過來。

我們可以估算下大概每隔五六分鐘出現一次這樣的情況,那麼大概半小時到一小時之間就可能因為老年代滿了觸發一次Full GC,Full GC的觸發條件還有我們之前說過的老年代空間配置設定擔保機制,曆次的minor gc挪動到老年代的對象大小肯定是非常小的,是以幾乎不會在minor gc觸發之前由于老年代空間配置設定擔保失敗而産生full gc,其實在半小時後發生full gc,這時候已經過了搶購的最高峰期,後續可能幾小時才做一次FullGC。

對于碎片整理,因為都是1小時或幾小時才做一次FullGC,是可以每做完一次就開始碎片整理,或者兩到三次之後再做一次也行。

綜上,隻要年輕代參數設定合理,老年代CMS的參數設定基本都可以用預設值,如下所示:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3           

垃圾收集底層算法實作

三色标記

在并發标記的過程中,因為标記期間應用線程還在繼續跑,對象間的引用可能發生變化,多标和漏标的情況就有可能發生。漏标的問題主要引入了三色标記算法來解決。

三色标記算法是把Gc roots可達性分析周遊對象過程中遇到的對象, 按照“是否通路過”這個條件标記成以下三種顔色:

  • 黑色: 表示對象已經被垃圾收集器通路過, 且這個對象的所有引用都已經掃描過。 黑色的對象代表已經掃描過, 它是安全存活的, 如果有其他對象引用指向了黑色對象, 無須重新掃描一遍。 黑色對象不可能直接(不經過灰色對象) 指向某個白色對象。
  • 灰色: 表示對象已經被垃圾收集器通路過, 但這個對象上至少存在一個引用還沒有被掃描過。
  • 白色: 表示對象尚未被垃圾收集器通路過。 顯然在可達性分析剛剛開始的階段, 所有的對象都是白色的, 若在分析結束的階段, 仍然是白色的對象, 即代表不可達。
四、垃圾收集器ParNew&CMS與底層三色标記算法詳解
/**
 * 垃圾收集算法細節之三色标記
 * 為了簡化例子,代碼寫法可能不規範,請忽略
 * Created by 諸葛老師
 */
public class ThreeColorRemark {

    public static void main(String[] args) {
        A a = new A();
        //開始做并發标記
        D d = a.b.d;   // 1.讀
        a.b.d = null;  // 2.寫
        a.d = d;       // 3.寫
    }
}

class A {
    B b = new B();
    D d = null;
}

class B {
    C c = new C();
    D d = new D();
}

class C {
}

class D {
}           

多标-浮動垃圾

在并發标記過程中,如果由于方法運作結束導緻部分局部變量(gcroot)被銷毀,這個gcroot引用的對象之前又被掃描過(被标記為非垃圾對象),那麼本輪GC不會回收這部分記憶體。這部分本應該回收但是沒有回收到的記憶體,被稱之為“浮動垃圾”。浮動垃圾并不會影響垃圾回收的正确性,隻是需要等到下一輪垃圾回收中才被清除。

另外,針對并發标記(還有并發清理)開始後産生的新對象,通常的做法是直接全部當成黑色,本輪不會進行清除。這部分對象期間可能也會變為垃圾,這也算是浮動垃圾的一部分。

漏标-讀寫屏障

漏标會導緻被引用的對象被當成垃圾誤删除,這是嚴重bug,必須解決,有兩種解決方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB) 。

增量更新就是當黑色對象插入新的指向白色對象的引用關系時, 就将這個新插入的引用記錄下來, 等并發掃描結束之後, 再将這些記錄過的引用關系中的黑色對象為根, 重新掃描一次。 這可以簡化了解為, 黑色對象一旦新插入了指向白色對象的引用之後, 它就變回灰色對象了。

原始快照就是當灰色對象要删除指向白色對象的引用關系時, 就将這個要删除的引用記錄下來, 在并發掃描結束之後, 再将這些記錄過的引用關系中的灰色對象為根, 重新掃描一次,這樣就能掃描到白色的對象,将白色對象直接标記為黑色(目的就是讓這種對象在本輪gc清理中能存活下來,待下一輪gc的時候重新掃描,這個對象也有可能是浮動垃圾)

以上無論是對引用關系記錄的插入還是删除, 虛拟機的記錄操作都是通過寫屏障實作的。

寫屏障

給某個對象的成員變量指派時,其底層代碼大概長這樣:

/**
* @param field 某對象的成員變量,如 a.b.d 
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { 
    *field = new_value; // 指派操作
}            

所謂的寫屏障,其實就是指在指派操作前後,加入一些處理(可以參考AOP的概念):

void oop_field_store(oop* field, oop new_value) {  
    pre_write_barrier(field);          // 寫屏障-寫前操作
    *field = new_value; 
    post_write_barrier(field, value);  // 寫屏障-寫後操作
}           
  • 寫屏障實作SATB

當對象B的成員變量的引用發生變化時,比如引用消失(a.b.d = null),我們可以利用寫屏障,将B原來成員變量的引用對象D記錄下來:

void pre_write_barrier(oop* field) {
    oop old_value = *field;    // 擷取舊值
    remark_set.add(old_value); // 記錄原來的引用對象
}           
  • 寫屏障實作增量更新

當對象A的成員變量的引用發生變化時,比如新增引用(a.d = d),我們可以利用寫屏障,将A新的成員變量引用對象D記錄下來:

void post_write_barrier(oop* field, oop new_value) {  
    remark_set.add(new_value);  // 記錄新引用的對象
}           

讀屏障

oop oop_field_load(oop* field) {
    pre_load_barrier(field); // 讀屏障-讀取前操作
    return *field;
}           

讀屏障是直接針對第一步:D d = a.b.d,當讀取成員變量時,一律記錄下來:

void pre_load_barrier(oop* field) {  
    oop old_value = *field;
    remark_set.add(old_value); // 記錄讀取到的對象
}           

現代追蹤式(可達性分析)的垃圾回收器幾乎都借鑒了三色标記的算法思想,盡管實作的方式不盡相同:比如白色/黑色集合一般都不會出現(但是有其他展現顔色的地方)、灰色集合可以通過棧/隊列/緩存日志等方式進行實作、周遊方式可以是廣度/深度周遊等等。

對于讀寫屏障,以Java HotSpot VM為例,其并發标記時對漏标的處理方案如下:

  • CMS:寫屏障 + 增量更新
  • G1,Shenandoah:寫屏障 + SATB
  • ZGC:讀屏障

工程實作中,讀寫屏障還有其他功能,比如寫屏障可以用于記錄跨代/區引用的變化,讀屏障可以用于支援移動對象的并發執行等。功能之外,還有性能的考慮,是以對于選擇哪種,每款垃圾回收器都有自己的想法。

為什麼G1用SATB?CMS用增量更新?

我的了解:SATB相對增量更新效率會高(當然SATB可能造成更多的浮動垃圾),因為不需要在重新标記階段再次深度掃描被删除引用對象,而CMS對增量引用的根對象會做深度掃描,G1因為很多對象都位于不同的region,CMS就一塊老年代區域,重新深度掃描對象的話G1的代價會比CMS高,是以G1選擇SATB不深度掃描對象,隻是簡單标記,等到下一輪GC再深度掃描。

記憶集與卡表

在新生代做GCRoots可達性掃描過程中可能會碰到跨代引用的對象,這種如果又去對老年代再去掃描效率太低了。

為此,在新生代可以引入記錄集(Remember Set)的資料結構(記錄從非收集區到收集區的指針集合),避免把整個老年代加入GCRoots掃描範圍。事實上并不隻是新生代、 老年代之間才有跨代引用的問題, 所有涉及部分區域收集(Partial GC) 行為的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都會面臨相同的問題。

垃圾收集場景中,收集器隻需通過記憶集判斷出某一塊非收集區域是否存在指向收集區域的指針即可,無需了解跨代引用指針的全部細節。

hotspot使用一種叫做“卡表”(Cardtable)的方式實作記憶集,也是目前最常用的一種方式。關于卡表與記憶集的關系, 可以類比為Java語言中HashMap與Map的關系。

卡表是使用一個位元組數組實作:CARD_TABLE[ ],每個元素對應着其辨別的記憶體區域一塊特定大小的記憶體塊,稱為“卡頁”。

hotSpot使用的卡頁是2^9大小,即512位元組

四、垃圾收集器ParNew&CMS與底層三色标記算法詳解

一個卡頁中可包含多個對象,隻要有一個對象的字段存在跨代指針,其對應的卡表的元素辨別就變成1,表示該元素變髒,否則為0.

GC時,隻要篩選本收集區的卡表中變髒的元素加入GCRoots裡。

卡表的維護

卡表變髒上面已經說了,但是需要知道如何讓卡表變髒,即發生引用字段指派時,如何更新卡表對應的辨別為1。

Hotspot使用寫屏障維護卡表狀态。

感謝您的耐心閱讀,私信擷取視訊教程,内容更詳盡,免費分享!!!

繼續閱讀