深入理解java虛擬機器之垃圾收集器

rainple發表於2019-05-08

  前言

  如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同的版本的虛擬機器所提供的垃圾收集器都有可能會有很大的區別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。

  相關係列部落格:

  上圖中展示了不同年齡代的收集器,其中Serial、ParNew和Parallel Scavenge收集器作用於新生代,CMS、Parallel Old 和 Serial Old作用於老年代,G1在新生代和老年代都可以使用。不同的收集器之間如果有連線,則說明他們可以相互搭配使用。

  相關概念

  並行:指的是多條垃圾收集執行緒一起公共,但是此時使用者工作執行緒仍處於等待狀態。

  併發:指的是使用者執行緒和垃圾收集執行緒同時工作,也有可能是交替執行,使用者程式在繼續執行,而垃圾收集程式執行與另一個CPU上。

  吞吐量:吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量 = 執行使用者程式碼時間 /(執行使用者程式碼時間 + 垃圾收集時間)。虛擬機器總共執行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

 

  Serial收集器

  Serial收集器是一款序列執行的收集器,它是歷史最悠久,也是最基本的收集器,採用複製演算法實現的新生代收集器。在jdk1.3以前,Serial收集器是新生代唯一的選擇。它是一個單執行緒執行的收集器,工作時只會知用一個cpu或執行緒區執行,更重要的是Serial在工作期間必須停掉所有的使用者執行緒,直至垃圾收集完成,這一過程我們稱之為“stop the world”。這項工作是由虛擬機器自動執行和自動完成的,使用者在不知情的情況下停掉了所有的執行緒,這對於一個最求響應速度來說簡直是無法接受的。下圖展示了Serial收集器在工作時的執行流程:

  由於Serial收集器的工作模式是單執行緒的,自然就沒有了多執行緒環境下執行緒切換帶來的效能開銷,所以該收集器在單執行緒環境下更加簡單高效。

 

  ParNew 收集器

  Parnew是Serial收集器的多執行緒版本,也是新生代收集器。ParNew收集器和Serial收集器除了多執行緒工作外幾乎是相同的,包括所有控制引數、收集演算法、stop the world,物件分配規則,回收策略等都是一樣的。執行流程如下圖:

  雖然與Serial收集器相比僅僅多了多執行緒特性外,沒有其它的創新之處,但是它卻是許多Server模式下的虛擬機器新生代收集器的首選,原因在於目前為止只有Serial和ParNew兩個新生代收集器能夠與效能優異的CMS配合使用。關於CMS介紹將在下文展開描述。

 

  Parallel Scavenge 收集器

  Parallel Scavenge也是一款使用複製演算法的新生代收集器。該收集器與其它收集器不同的是,它關注的目標是達到一個可控制的吞吐量,而CMS等收集器的關注點則是儘可能地減少使用者執行緒地停頓時間,提高使用者體驗。Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。也因此,Parallel Scavenge 被成為“吞吐量優先”收集器。

  停頓時間越短就越適合與使用者互動較多地程式,這樣使用者體驗才更好。而高吞吐量則可以讓出更多的cpu資源給使用者執行緒,讓程式更快的完成運算任務,更適合後臺運算較多而不需要與使用者互動的程式。

  自適應調節策略是Parallel Scavenge收集器的特點,也是與ParNew收集器的區別。Parallel Scavenge通過開啟-XX:+UseAdaptiveSizePolicy的設定,就不需要手動地調節新生代(-Xmn)大小,Eden和Survivor區的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節引數,而是根據當前系統執行情況來確定這些引數,從而提高程式地吞吐量和縮短停頓時間,這一過程稱之為GC自適應的調節策略(GC Ergonomics)。

  另外值得注意的一點是,Parallel Scavenge無法已CMS配合使用,如果新生代選擇了Parallel Scavenge收集器,那麼老年代的收集器只能選用Serial Old或者Parallel Old來配置使用。

 

  Serial Old收集器

  Serial Old是Serial收集器的老年代版本,也是單執行緒工作的,使用的是“標記-整理”演算法。

  該收集器主要用於Client模式下的虛擬機器使用,如果在Server模式下可以與Parallel Scavenge收集器配合使用;作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。執行流程如下:

 

  Parallel Old收集器

  Parallel Old是Parallel Scavenge的老年代版本,也是一個並行收集器,使用“標記-整理”演算法。該收集器在jdk1.6後對外提供使用,Parallel Scavenge 和  Parallel Old配合使用的話,更加適合應用於高吞吐量和cpu敏感資源的場合。下面是這兩個收集器配合使用的執行流程:

 

  CMS收集器

  CMS(Concurrent Mark Sweep)是一個併發收集器,使用了“標記-清除”演算法來實現的。該收集器最求的更短的停頓時間,從而提升使用者體驗,因此也非常符合使用在網站、B/S系統的服務端的應用。

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

  • 初始標記:這個階段僅僅標記能夠和gc roots直接關聯的物件,速度很快,但是需要“stop the world”。
  • 併發標記:這個階段開始進行gc roots tracing標記,與使用者執行緒一起執行的,消耗時間很多。
  • 重新標記:這個階段是要是修正在併發標記期間由於使用者執行緒也在執行而產生標記變動的那部分物件的標記,比較耗費的時間比初始標記階段要長,但是遠比並發標記階段要短,這個過程也是需要“stop the world”的。
  • 併發清除:對無用物件進行回收操作。這個過程與使用者執行緒並行執行。

  由於標記和清除階段可以和使用者執行緒一起工作,因此幾乎可以把CMS收集器的工作是併發的:

  CMS是一款優秀的收集器,它的主要優點是低停頓,併發收集,因此也被成為併發低停頓收集器(Concurrent Low Pause Collector)。

  當然,CMS收集器也有一定的缺點,主要包括一下幾點:

  • CMS收集器使用“標記-清除”演算法實現,因此不可避免地有記憶體碎片地問題。當記憶體碎片過多時,在分配大物件地過程中即使有足夠的空間,但是找不到足夠地連續的空間來放該物件,那麼就有可能觸發一次full gc。
  • 無法處理浮動垃圾(Floating Garbage) 可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。這是因為在標記的過程中使用者執行緒也在執行著,那麼在這一過程中出現的垃圾無法立即回收,而是等下一次gc才能清理,我這部分的垃圾就叫做“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行,那也就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程式運作使用。
  • 對cpu資源非常敏感。其實,只要是面對併發的情況下都會有這個問題,在併發階段雖然不會中斷使用者執行緒,但是因為佔用了部分使用者的資源而導致程式變慢,總吞吐量降低。CMS蒐集器預設的執行緒數 = (cpu核數 + 3) / 4,當cpu數量大於4時,垃圾回收執行緒數不少於25%,隨著執行緒數的增加而下降,當cpu數量小於4時對執行緒的執行效率有顯著的影響。

  執行示意圖如下:

  

  G1收集器

  G1(Garbage-First)是一款面向服務端應用的垃圾收集器,JDK 7 Update4 後開始進入商用。HotSpot開發團隊賦予它的使命是未來可以替換掉JDK 1.5中釋出的CMS收集器。之前提供的收集器都是僅作用於新生代或者是老年代,但是G1收集器可以作用於新生代和老年代,因為使用G1收集器是java heap的記憶體結構有很大的不同,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但是他們已經沒有了物理上的隔閡了,它們都是region的一部分的集合。

  G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,與其他收集器相比,G1收集器具有以下特徵:

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

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

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

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

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

  執行流程如下圖:

  

  總結

  

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

 

  參考資料: 《深入理解Java虛擬機器-JVM高階特性與最佳實踐》 -周志明

相關文章