1. 程式人生 > >深入理解JVM——7種垃圾收集器

深入理解JVM——7種垃圾收集器

轉自:https://crowhawk.github.io/2017/08/15/jvm_3/

如果說收集演算法是記憶體回收的方法論,那麼垃圾收

集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、版本的虛擬機器所提供的垃圾收集器都可能會有很大差別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。接下來討論的收集器基於JDK1.7 Update 14 之後的HotSpot虛擬機器(在此版本中正式提供了商用的G1收集器,之前G1仍處於實驗狀態),該虛擬機器包含的所有收集器如下圖所示:

上圖展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機器所處的區域,則表示它是屬於新生代收集器還是老年代收集器。Hotspot實現瞭如此多的收集器,正是因為目前並無完美的收集器出現,只是選擇對具體應用最適合的收集器。

相關概念

並行和併發

  • 並行(Parallel):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態。
  • 併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行),使用者程式在繼續執行。而垃圾收集程式執行在另一個CPU上。

吞吐量(Throughput)

吞吐量就是CPU用於執行使用者程式碼的時間CPU總消耗時間的比值,即

吞吐量 = 執行使用者程式碼時間 /(執行使用者程式碼時間 + 垃圾收集時間)。

假設虛擬機器總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

Minor GC 和 Full GC

  • 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。具體原理見上一篇文章。
  • 老年代GC(Major GC / Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

新生代收集器

Serial收集器

Serial(序列)收集器是最基本、發展歷史最悠久的收集器,它是採用複製演算法

新生代收集器,曾經(JDK 1.3.1之前)是虛擬機器新生代收集的唯一選擇。它是一個單執行緒收集器,只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停其他所有的工作執行緒,直至Serial收集器收集結束為止(“Stop The World”)。這項工作是由虛擬機器在後臺自動發起和自動完成的,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,這對很多應用來說是難以接收的。

下圖展示了Serial 收集器(老年代採用Serial Old收集器)的執行過程:

為了消除或減少工作執行緒因記憶體回收而導致的停頓,HotSpot虛擬機器開發團隊在JDK 1.3之後的Java發展歷程中研發出了各種其他的優秀收集器,這些將在稍後介紹。但是這些收集器的誕生並不意味著Serial收集器已經“老而無用”,實際上到現在為止,它依然是HotSpot虛擬機器執行在Client模式下的預設的新生代收集器。它也有著優於其他收集器的地方:簡單而高效(與其他收集器的單執行緒相比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得更高的單執行緒收集效率。

在使用者的桌面應用場景中,分配給虛擬機器管理的記憶體一般不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的記憶體,桌面應用基本不會再大了),停頓時間完全可以控制在幾十毫秒最多一百毫秒以內,只要不頻繁發生,這點停頓時間可以接收。所以,Serial收集器對於執行在Client模式下的虛擬機器來說是一個很好的選擇。

ParNew 收集器

ParNew收集器就是Serial收集器的多執行緒版本,它也是一個新生代收集器。除了使用多執行緒進行垃圾收集外,其餘行為包括Serial收集器可用的所有控制引數、收集演算法(複製演算法)、Stop The World、物件分配規則、回收策略等與Serial收集器完全相同,兩者共用了相當多的程式碼。

ParNew收集器的工作過程如下圖(老年代採用Serial Old收集器):

ParNew收集器除了使用多執行緒收集外,其他與Serial收集器相比並無太多創新之處,但它卻是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作,CMS收集器是JDK 1.5推出的一個具有劃時代意義的收集器,具體內容將在稍後進行介紹。

ParNew 收集器在單CPU的環境中絕對不會有比Serial收集器有更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個CPU的環境中都不能百分之百地保證可以超越。在多CPU環境下,隨著CPU的數量增加,它對於GC時系統資源的有效利用是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多的情況下可使用-XX:ParallerGCThreads引數設定。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一個並行多執行緒新生代收集器,它也使用複製演算法。Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標是達到一個可控制的吞吐量(Throughput)

停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗。而高吞吐量則可以高效率地利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務

Parallel Scavenge收集器除了會顯而易見地提供可以精確控制吞吐量的引數,還提供了一個引數-XX:+UseAdaptiveSizePolicy,這是一個開關引數,開啟引數後,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor區的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種方式稱為GC自適應的調節策略(GC Ergonomics)。自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。

另外值得注意的一點是,Parallel Scavenge收集器無法與CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代選擇Parallel Scavenge收集器,老年代只有Serial Old收集器能與之配合使用。

老年代收集器

Serial Old收集器

Serial Old 是 Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”(Mark-Compact)演算法。

此收集器的主要意義也是在於給Client模式下的虛擬機器使用。如果在Server模式下,它還有兩大用途:

  • 在JDK1.5 以及之前版本(Parallel Old誕生以前)中與Parallel Scavenge收集器搭配使用。
  • 作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。

它的工作流程與Serial收集器相同,這裡再次給出Serial/Serial Old配合使用的工作流程圖:

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多執行緒“標記-整理”演算法。前面已經提到過,這個收集器是在JDK 1.6中才開始提供的,在此之前,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old以外別無選擇,所以在Parallel Old誕生以後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作流程與Parallel Scavenge相同,這裡給出Parallel Scavenge/Parallel Old收集器配合使用的流程圖:

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,它非常符合那些集中在網際網路站或者B/S系統的服務端上的Java應用,這些應用都非常重視服務的響應速度。從名字上(“Mark Sweep”)就可以看出它是基於“標記-清除”演算法實現的。

CMS收集器工作的整個流程分為以下4個步驟:

  • 初始標記(CMS initial mark):僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快,需要“Stop The World”。
  • 併發標記(CMS concurrent mark):進行GC Roots Tracing的過程,在整個過程中耗時最長。
  • 重新標記(CMS remark):為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。此階段也需要“Stop The World”。
  • 併發清除(CMS concurrent sweep)

由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以,從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。通過下圖可以比較清楚地看到CMS收集器的運作步驟中併發和需要停頓的時間:

優點

CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:併發收集低停頓,因此CMS收集器也被稱為併發低停頓收集器(Concurrent Low Pause Collector)

缺點

  • 對CPU資源非常敏感 其實,面向併發設計的程式都對CPU資源比較敏感。在併發階段,它雖然不會導致使用者執行緒停頓,但會因為佔用了一部分執行緒(或者說CPU資源)而導致應用程式變慢,總吞吐量會降低。CMS預設啟動的回收執行緒數是(CPU數量+3)/4,也就是當CPU在4個以上時,併發回收時垃圾收集執行緒不少於25%的CPU資源,並且隨著CPU數量的增加而下降。但是當CPU不足4個時(比如2個),CMS對使用者程式的影響就可能變得很大,如果本來CPU負載就比較大,還要分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低了50%,其實也讓人無法接受。
  • 無法處理浮動垃圾(Floating Garbage) 可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。由於CMS併發清理階段使用者執行緒還在執行著,伴隨程式執行自然就還會有新的垃圾不斷產生。這一部分垃圾出現在標記過程之後,CMS無法再當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就被稱為“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行,那也就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程式運作使用。
  • 標記-清除演算法導致的空間碎片 CMS是一款基於“標記-清除”演算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大物件分配帶來很大麻煩,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件。

G1收集器

G1(Garbage-First)收集器是當今收集器技術發展最前沿的成果之一,它是一款面向服務端應用的垃圾收集器,HotSpot開發團隊賦予它的使命是(在比較長期的)未來可以替換掉JDK 1.5中釋出的CMS收集器。與其他GC收集器相比,G1具備如下特點:

  • 並行與併發 G1 能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU來縮短“Stop The World”停頓時間,部分其他收集器原本需要停頓Java執行緒執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。
  • 分代收集 與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同方式去處理新建立的物件和已存活一段時間、熬過多次GC的舊物件來獲取更好的收集效果。
  • 空間整合 G1從整體來看是基於“標記-整理”演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現的。這意味著G1執行期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。此特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC。
  • 可預測的停頓 這是G1相對CMS的一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了降低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在GC上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

橫跨整個堆記憶體

在G1之前的其他收集器進行收集的範圍都是整個新生代或者老生代,而G1不再是這樣。G1在使用時,Java堆的記憶體佈局與其他收集器有很大區別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,而都是一部分Region(不需要連續)的集合

建立可預測的時間模型

G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。

避免全堆掃描——Remembered Set

G1把Java堆分為多個Region,就是“化整為零”。但是Region不可能是孤立的,一個物件分配在某個Region中,可以與整個Java堆任意的物件發生引用關係。在做可達性分析確定物件是否存活的時候,需要掃描整個Java堆才能保證準確性,這顯然是對GC效率的極大傷害。

為了避免全堆掃描的發生,虛擬機器為G1中每個Region維護了一個與之對應的Remembered Set。虛擬機發現程式在對Reference型別的資料進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的物件是否處於不同的Region之中(在分代的例子中就是檢查是否老年代中的物件引用了新生代中的物件),如果是,便通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set之中。當進行記憶體回收時,在GC根節點的列舉範圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。


如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分為以下幾個步驟:

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

通過下圖可以比較清楚地看到G1收集器的運作步驟中併發和需要停頓的階段(Safepoint處):

總結

收集器 序列、並行or併發 新生代/老年代 演算法 目標 適用場景
Serial 序列 新生代 複製演算法 響應速度優先 單CPU環境下的Client模式
Serial Old 序列 老年代 標記-整理 響應速度優先 單CPU環境下的Client模式、CMS的後備預案
ParNew 並行 新生代 複製演算法 響應速度優先 多CPU環境時在Server模式下與CMS配合
Parallel Scavenge 並行 新生代 複製演算法 吞吐量優先 在後臺運算而不需要太多互動的任務
Parallel Old 並行 老年代 標記-整理 吞吐量優先 在後臺運算而不需要太多互動的任務
CMS 併發 老年代 標記-清除 響應速度優先 集中在網際網路站或B/S系統服務端上的Java應用
G1 併發 both 標記-整理+複製演算法 響應速度優先 面向服務端應用,將來替換CMS

本文通過詳細介紹HotSpot虛擬機器的7種垃圾收集器回答了上一篇文章開頭提出的三個問題中的第三個——“如何回收”,在下一篇文章中,我們將回答最後一個未被解答的問題——“什麼時候回收”。