JVM經典垃圾收集器

這個關係不是一成不變的,由於維護和兼容性測試的成本,在JDK 8時將Serial+CMS、 ParNew+Serial Old這兩個組合聲明為廢棄(JEP 173),並在JDK 9中完全取消了這些組合的支持(JEP 214)。
Serial/Serial Old收集器運行示意圖
它只會使用一個處理器或一條收集線程去完成垃圾收集工作,更重要的是強 調在它進行垃圾收集時,必須暫停其他所有工作線程,直到它收集結束。
迄今為止,它依然是HotSpot虛擬機運行在客戶端模式下的默認新生代收集器,有着優於其他收集器的地方,那就是簡單而高效(與其他收集器的單線程相比)。
- 對於內存資源受限的環境,它是所有收集器里額外內存消耗(Memory Footprint)最小的。
- 對於單核處理器或處理器核心數較少的環境來說,Serial收集器沒有線程交互的開銷,可以獲得最高的單線程收集效率。
- 分配給虛擬機管理的內存不大的情況下,例如收集幾十兆甚至一兩百兆的新生代,垃圾收集的停頓時間完全可以控制在十幾、幾十毫秒,最多一百多毫秒以內,只要不是頻繁發生收集,這點停頓時間對許多用戶來說是完全可以接受的。
ParNew/Serial Old收集器運行示意圖

ParNew收集器實質上是Serial收集器的多線程並行版本,除了同時使用多條線程進行垃圾收集之外 , 其餘的行為包括Serial收集器可用的所有控制參數、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一致。
ParNew收集器除了支持多線程並行收集之外,是JDK 7之前的遺留系統中首選的新生代收集器,而且是除了Serial收集器外,目前只有它能與CMS 收集器配合工作。
默認開啟的收集線程數與處理器核心數量相同,在處理器核心非常多(譬如32個,現在CPU都是多核加超線程設計,服務器達到或超過32個邏輯核心的情況非常普遍)的環境中,可以使用-XX:ParallelGCT threads參數來限制垃圾收集的線程數。
名稱解釋
- 並行(Parallel):並行描述的是多條垃圾收集器線程之間的關係,說明同一時間有多條這樣的線程在協同工作,通常默認此時用戶線程是處於等待狀態。
- 並發(Concurrent):並發描述的是垃圾收集器線程與用戶線程之間的關係,說明同一時間垃圾收集器線程與用戶線程都在運行。由於用戶線程並未被凍結,所以程序仍然能響應服務請求,但由於垃圾收集器線程佔用了一部分系統資源,此時應用程序的處理的吞吐量將受到一定影響。
JDK服務器模式下默認垃圾收集器
jdk1.7 默認垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默認垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默認垃圾收集器G1
Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器,它同樣是基於標記-複製算法實現的收集器,也是能夠並行收集的多線程收集器。
它的目標則是達到一個可控制的吞吐 量(Throughput),經常被稱作「吞吐量優先收集器」。通過UseAdaptiveSizePolicy參數,就不需要人工指定新生代的大小(-Xmn)、Eden與Survivor區的比 例 ( -XX : SurvivorRatio)、晉陞老年代對象大小 ( -XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱為垃圾收集的自適應的調節策略(GC Ergonomics)。只需要把基本的內存數據設 置好(如-Xmx設置最大堆),然後使用-XX:MaxGCPauseMillis參數(更關注最大停頓時間)或-XX:GCTimeRatio(更關注吞吐量)參數給虛擬機設立一個優化目標,那具體細節參數的調節工作就由虛擬機完成了。自適應調節策略也是Parallel Scavenge收集器區別於ParNew收集器的一個重要特性。
Serial/Serial Old收集器運行示意圖

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用標記-整理算法。這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機使用。如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的後備預案,在並發收集發生Concurrent Mode Failure時使用。
Parallel Scavenge/Parallel Old收集器運行示意圖

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多線程並發收集,基於標記-整理算法實現。
直到Parallel Old收集器出現後,「吞吐量優先」收集器終於有了比較名副其實的搭配組合,在注重 吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組 合。
Concurrent Mark Sweep收集器運行示意圖

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。通常都會用在較為關注服務的響應速度,希望系統停頓時間儘可能短,以給用戶帶來良好的交互體驗的應用。CMS收集器就非常符合這類應用的需求。它的收集過程分為四個步驟:
- 初始標記(CMS initial mark) ,標記下GC Roots能直接關聯到的對象,速度很快,但需要停頓用戶線程(根節點枚舉)。
- 並發標記(CMS concurrent mark) ,從GC Roots能直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程。
- 重新標記(CMS remark) ,為了修正並發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄(增量更新),這個階段需要停頓用戶線程,比初始標記時間稍長,但遠比並發標記短。
- 並發清除(CMS concurrent sweep),清理刪除掉標記階段判斷的已經死亡的對象,由於不需要移動存活對象,所以這個階段也是可以與用戶線程同時並發的。
CMS是一款優秀的收集器,它最主要的優點在名字上已經體現出來:並發收集、低停頓。但它也有三個明顯的缺點:
- 首先,CMS收集器對處理器資源非常敏感。事實上,面向並發設計的程序都對處理器資源比較敏感。在並發階段,它雖然不會導致用戶線程停頓,但卻會因為佔用了一部分線程(或者說處理器的計算能力)而導致應用程序變慢,降低總吞吐量。CMS默認啟動的回收線程數是(處理器核心數量 +3)/4。當處理器核心數量不足四個時,CMS對用戶程序的影響就可能變得很大。
- 其次,內存利用率相對低。
- CMS收集器無法處理「浮動垃圾」(FloatingGarbage),在CMS的並發標記和並發清理階 段,用戶線程是還在繼續運行的,程序在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分 垃圾對象是出現在標記過程結束以後,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集 時再清理掉。這一部分垃圾就稱為「浮動垃圾」。
- 因為在垃圾收集階段用戶線程還需要持續運行,那就還需要預留足夠內存空間提供給用戶線程使用,因此CM S收集器不能像其他收集器那樣等待 到老年代幾乎完全被填滿了再進行收集,必須預留一部分空間供並發收集時的程序運作使用。
在JDK 5的默認設置下,CMS收集器當老年代使用了68%的空間後就會被激活,這是一個偏保守的設置,如果在實際應用中老年代增長並不是太快,可以適當調高參數-XX:CMSInitiatingOccu-pancyFraction的值來提高CMS的觸發百分比,降低內存回收頻率,獲取更好的性能。到了JDK 6時,CMS收集器的啟動閾值就已經默認提升至92%。但這又會更容易面臨另一種風險:要是CMS運行期間預留的內存無法滿足程序分配新對象的需要,就會出現一次「並發失敗」(Concurrent Mode Failure),這時候虛擬機將不得不啟動後備預案:凍結用戶線程的執行,臨時啟用Serial Old收集器來重新進行老年代的垃圾收集, 但這樣停頓時間就很長了。所以參數-XX:CMSInitiatingOccupancyFraction設置得太高將會很容易導致 大量的並發失敗產生,性能反而降低,用戶應在生產環境中根據實際應用情況來權衡設置。
- 再者,CMS是一款基於「標記-清除」算法實現的收集器,這意味着收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很多剩餘空間,但就是無法找到足夠大的連續空間來分配當前對象,而不得不提前觸發一次Full GC的情況。CMS收集器提供了一個-XX:+UseCMS-CompactAtFullCollection開關參數(默認是開啟的,此參數從 JDK 9開始廢棄),用於在CMS收集器不得不進行Full GC時開啟內存碎片的合併整理過程,由於這個內存整理必須移動存活對象 ,( 在Shenandoah和ZGC出現前)是無法並發的。這樣空間碎片問題是解決了,但停頓時間又會變長,因此虛擬機設計者們還提供了另外一個參數-XX:CMSFullGCsBeforeCompaction(此參數從JDK 9開始廢棄),這個參數的作用是要求CMS收集器在執行過若干次(數量 由參數值決定)不整理空間的Full GC之後,下一次進入Full GC前會先進行碎片整理(默認值為0,表示每次進入Full GC時都進行碎片整理)。
注意:CMS是老年代的收集器,如果它處理不過來或者解決不了,就會啟用stop-the-wold GC- Serail Old來收集,退化了。應儘力優化程序,避免CMS退化成Serail Old。
總結CMS會觸發Full GC有兩種情況
1.promotion failed 晉陞失敗,多數是是由於老年代有足夠的空閑空間,但是由於碎片較多,這時如果新生代要轉移到老年帶的對象比較大,所以,必須儘可能提早觸發老年代的CMS回收來避免這個問題(promotion failed時老年代CMS還沒有機會進行回收,又放不下轉移到老年代的對象,因此會出現下一個問題concurrent mode failure,需要stop-the-wold GC- Serail Old)。當然老年代空閑空間不足也有可能,但因為MajorGC都會提前介入,所以promotion failed是因為老年代空閑空間不夠的情況應該是極少數。
2.concurrent mode failure 業務線程運行期間不斷將對象放入老年代,CMS運行期間老年代預留的內存無法滿足程序分配新對象的需要,就會出現。同時上一條晉陞失敗也會觸發。
G1收集器Region分區示意圖

Garbage First(簡稱G1)收集器是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向局部收集的設計思路和基於Region的內存布局形式。但直到JDK 8 Update 40的時候,G1提供並發的類卸載的支持,才補全了其計劃功能的最後一塊拼圖。
G1是一款主要面向服務端應用的垃圾收集器。一款能夠建立起「停頓時間模型」(Pause Prediction Model)的收集器,停頓時間模型的意思是能夠支持指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間大概率不超過N毫秒這樣的目標,那具體要怎麼做才能實現這個目標呢?首先要有一個思想上的改變,在G1收集器出現之前的所有其他收集器,包括CMS在內,垃圾收集的目標範圍要麼是整個新生代(Minor GC),要麼就是整個老年代(Major GC),再要麼就是整個Java堆(Full GC)。而G1它可以面向堆內存任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬於哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。
G1開創的基於Region的堆內存布局是它能夠實現這個目標的關鍵。雖然G1也仍是遵循分代收集理 論設計的,但其堆內存的布局與其他收集器有非常明顯的差異:G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的 Region採用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個 Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-XX:G1Heap RegionSize設定,取值範圍為1MB~32MB,且應為2的N次冪,默認是總內存的2048分之一。而對於那些超過了整個Region容量的超級大對象, 將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代 的一部分來進行看待。
G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,並跟蹤各個Region裏面的垃圾堆積的「價值」大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然後在後台維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是「Garbage First」名字的由來。 這種使用Region劃分內存空間,以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取儘可能高的收集效率。
G1化整為零的思路理解容易,但實現的關鍵細節需要妥善解決:
- Region裏面存在的跨Region引用對象如何解決?使用記憶集可以避免全堆作為GC Roots掃描,但在G1收集器上記憶集的實現要複雜很多,它的每個Region都維護有自己的記憶集,這些記憶集會記錄下別的Region指向自己的指針,並標記這些指針分別在哪些卡頁的範圍之內。G1的記憶集在存儲結構的本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,裏面存儲的元素是卡表的索引號。這種「雙向」的卡表結構(卡表是「我指向誰」,這種結構還記錄了「誰指向我」)比原來的卡表實現起來更複雜,同時由於Region數量比傳統收集器的分代數量明顯要多得多,因此G1收集器要比其他的傳統垃圾收集器有着更高的內存佔用負擔。根據經驗,G1至少要耗費大約相當於Java堆容量10%至20%的額外內存來維持收集器工作。CMS的卡表只有年老代指向年輕代,年老代回收時,直接粗暴的將整個年輕代加入GC ROOTS,雖然暴力,但卡表維護簡單佔用小,而且MajorGC 次數遠遠比minorGC少多了。
- 在並發標記階段如何保證收集線程與用戶線程互不干擾地運行?這裡首先要解決的是用戶線程改變對象引用關係時,必須保證其不能打破原本的對象圖結構,導致標記結果出現錯誤,CMS收集器採用增量更新算法實現,而G1收集器則是通過原始快照(SAT B)算法來實現的。此外,垃圾收集對用戶線程的影響還體現在回收過程中新創建對象的內存分配上,程序要繼續運行就肯定會持續有新對象被創建,G1為每一個Region設 計了兩個名為TAMS(Top at Mark Start)的指針,把Region中的一部分空間劃分出來用於並發回收過程中的新對象分配,並發回收時新分配的對象地址都必須要在這兩個指針位置以上。G1收集器默認在 這個地址以上的對象是被隱式標記過的,即默認它們是存活的(黑色的),不納入回收範圍。與CMS中 的「Concurrent Mode Failure」失敗會導致Full GC類似,如果內存回收的速度趕不上內存分配的速度, G1收集器也要被迫凍結用戶線程執行,導致Full GC而產生長時間「Stop The World」(Serail Old)。
- 怎樣建立起可靠的停頓預測模型?用戶通過-XX:MaxGCPauseM illis參數指定的停頓時間只意味着垃圾收集發生之前的期望值,但G1收集器要怎麼做才能滿足用戶的期望呢?G1收集器的停頓預測模型是以衰減均值(Decay ing Average)為理論基礎來實現的,在垃圾收集過程中,G1收集器會記錄每個Region的回收耗時、每個Region記憶集里的臟卡數量等各個可測量的步驟花費的成本,並分析得出*均值、標準偏差、置信度等統計信息。這裡強調的「衰減*均值」是指它會比普通的*均值更容易受到新數據的影響,*均值代表整體*均狀態,但衰減*均值更準確地代表「最*的」*均狀態。換句話說,Region的統計狀態越新越能決定其回收的價值。然後通過這些信息預測現在開始回收的話,由哪些Region組成回收集才可以在不超過期望停頓時間的約束下獲得最高的收益。
G1收集器的運作過程大致可劃分為以下四個步驟:
- 初始標記(Initial M arking):僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS指針的值,讓下一階段用戶線程並發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程(根節點枚舉),但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際並沒有額外的停頓。
- 並發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆 里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序並發執行。當對象圖掃描完成以 後,還要重新處理SATB記錄下的在並發時有引用變動的對象。
- 最終標記(Final Marking):對用戶線程做另一個短暫的暫停,用於處理並發階段結束後仍遺留下來的最後那少量的SATB記錄。
- 篩選回收(Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region 構成回收集,然後把決定回收的那一部分Region的存活對象複製到空的Region中,再清理掉整箇舊 Region的全部空間。這裡的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程並行 完成的。
G1收集器除了並發標記外,其餘階段也是要完全暫停用戶線程的,換言之,它並非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才能擔當起「全功能收集器」的重任與期望。用戶指定期望的停頓時間是G1收集器很強大的一個功能,設置不同的期望停頓時間,可使得G1在不同應用場景中取得關注吞吐量和關注延遲之間的最佳*衡。
從G1開始,最先進的垃圾收集器的設計導向都不約而同地變為追求能夠應付應用的內存分配速率 (Allocation Rate),而不追求一次把整個Java堆全部清理乾淨。這樣,應用在分配,同時收集器在收集,只要收集的速度能跟得上對象分配的速度,那一切就能運作得很完美。這種新的收集器設計思路從工程實現上看是從G1開始興起的,所以說G1是收集器技術發展的一個里程碑。
相比CMS,G1的優點有很多,暫且不論可以指定最大停頓時間、分Region的內存布局、按收益動態確定回收集這些創新性設計帶來的紅利,單從最傳統的算法理論上看,G1也更有發展潛力。與CMS 的「標記-清除」算法不同,G1從整體來看是基於「標記-整理」算法實現的收集器,但從局部(兩個Region 之間)上看又是基於「標記-複製」算法實現,無論如何,這兩種算法都意味着G1運作期間不會產生內存空間碎片,垃圾收集完成之後能提供規整的可用內存。這種特性有利於程序長時間運行,在程序為大對象分配內存時不容易因無法找到連續內存空間而提前觸發下一次收集。
G1的弱項也可以列舉出不少,如在用戶程序運行過程中,G1無論是為了垃圾收集產生的內存佔用(Footprint)還是程序運行時的額外執行負載 (Overload)都要比CMS要高。
- 內存佔用來說,雖然G1和CMS都使用卡表來處理跨代指針,但G1的卡表實現更為複雜,而且堆中每個Region,無論扮演的是新生代還是老年代角色,都必須有一份卡表,這導致G1的記憶集(和其他內存消耗)可能會佔整個堆容量的20%乃至更多的內存空間;相比起來CMS的卡表就相當簡單, 只有唯一一份,而且只需要處理老年代到新生代的引用,反過來則不需要,由於新生代的對象具有朝
生夕滅的不穩定性,引用變化頻繁,能省下這個區域的維護開銷是很划算的。
- 執行負載的角度上,同樣由於兩個收集器各自的細節實現特點導致了用戶程序運行時的負載會有不同,譬如它們都使用到寫屏障,CMS用寫後屏障來更新維護卡表;而G1除了使用寫後屏障來進行同樣的(由於G1的卡表結構複雜,其實是更煩瑣的)卡表維護操作外,為了實現原始快照搜索 (SATB)算法,還需要使用寫前屏障來跟蹤並發時的指針變化情況。相比起增量更新算法,原始快照搜索能夠減少並發標記和重新標記階段的消耗,避免CMS那樣在最終標記階段停頓時間過長的缺點, 但是在用戶程序運行過程中確實會產生由跟蹤引用變化帶來的額外負擔。由於G1對寫屏障的複雜操作要比CMS消耗更多的運算資源,所以CMS的寫屏障實現是直接的同步操作,而G1就不得不將其實現為類似於消息隊列的結構,把寫前屏障和寫後屏障中要做的事情都放到隊列里,然後再異步處理。
小結
G1和CMS兩款垃圾收集器單獨某方面的實現細節的定性分析,通常 我們說哪款收集器要更好、要好上多少,往往是針對具體場景才能做的定量比較。按照筆者的實踐經 驗,目前在小內存應用上CM S的表現大概率仍然要會優於G1,而在大內存應用上G1則大多能發揮其 優勢,這個優劣勢的Java堆容量*衡點通常在6GB至8GB之間。


