1. 程式人生 > 實用技巧 >HotSpot的垃圾回收器

HotSpot的垃圾回收器

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。這裡討論的收集器基於JDK 1.7 Update 14之後的 HotSpot 虛擬機器,這個虛擬機器包含的所有收集器如下圖所示

上圖展示了 7 種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機器所處的區域,則表示它是屬於新生代收集器還是老年代收集器。接下來將逐一介紹這些收集器的特性、基本原理和使用場景,並重點分析 CMS 和 G1 這兩款相對複雜的收集器,瞭解它們的部分運作細節。

Serial收集器(序列收集器)

Serial 收集器是最基本、發展歷史最悠久的收集器,曾經是虛擬機器新生代收集的唯一選擇。這是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個 CPU 或一條收集執行緒去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。

"Stop The World"這個名字也許聽起來很酷,但這項工作實際上是由虛擬機器在後臺自動發起和自動完成的,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,這對很多應用來說都是難以接受的。下圖示意了 Serial/Serial Old 收集器的執行過程。

實際上到現在為止,它依然是虛擬機器執行在 Client 模式下的預設新生代收集器。它也有著優於其他收集器的地方:簡單而高效(與其他收集器的單執行緒比),對於限定單個 CPU 的環境來說,Serial 收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。

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

ParNew收集器

ParNew 收集器其實就是 Serial 收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為包括 Serial 收集器可用的所有控制引數(例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等)、收集演算法、Stop The World、物件分配規則、回收策略等都與 Serial 收集器完全一樣,在實現上,這兩種收集器也共用了相當多的程式碼。ParNew 收集器的工作過程如下圖所示。

ParNew 收集器除了多執行緒收集之外,其他與 Serial 收集器相比並沒有太多創新之處,但它卻是許多執行在 Server 模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關但很重要的原因是,除了 Serial 收集器外,目前只有它能與 CMS 收集器(併發收集器,後面有介紹)配合工作。

ParNew 收集器在單 CPU 的環境中不會有比 Serial 收集器更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個 CPU 的環境中都不能百分之百地保證可以超越 Serial 收集器。

當然,隨著可以使用的 CPU 的數量的增加,它對於 GC 時系統資源的有效利用還是很有好處的。它預設開啟的收集執行緒數與 CPU 的數量相同,在 CPU 非常多(如 32 個)的環境下,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。

注意,從 ParNew 收集器開始,後面還會接觸到幾款併發和並行的收集器。這裡有必要先解釋兩個名詞:併發和並行。這兩個名詞都是併發程式設計中的概念,在談論垃圾收集器的上下文語境中,它們可以解釋如下。

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

Parallel Scavenge收集器

Parallel Scavenge 收集器是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的多執行緒收集器……看上去和 ParNew 都一樣,那它有什麼特別之處呢?

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

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

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

Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

MaxGCPauseMillis引數允許的值是一個大於 0 的毫秒數,收集器將盡可能地保證記憶體回收花費的時間不超過設定值。

不過大家不要認為如果把這個引數的值設定得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集 300MB 新生代肯定比收集 500MB 快吧,這也直接導致垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。

GCTimeRatio 引數的值應當是一個 0 到 100 的整數,也就是垃圾收集時間佔總時間的比率,相當於是吞吐量的倒數。如果把此引數設定為 19,那允許的最大 GC 時間就佔總時間的 5%(即 1/(1+19)),預設值為 99 ,就是允許最大 1%(即 1/(1+99))的垃圾收集時間。

由於與吞吐量關係密切,Parallel Scavenge 收集器也經常稱為“吞吐量優先”收集器。除上述兩個引數之外,Parallel Scavenge 收集器還有一個引數-XX:+UseAdaptiveSizePolicy值得關注。這是一個開關引數,當這個引數開啟之後,就不需要手工指定新生代的大小(-Xmn)、Eden 與 Survivor 區的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為 GC 自適應的調節策略(GC Ergonomics)。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器的主要意義也是在於給 Client 模式下的虛擬機器使用。如果在 Server 模式下,那麼它主要還有兩大用途:一種用途是在 JDK 1.5 以及之前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途就是作為 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。這兩點都將在後面的內容中詳細講解。Serial Old 收集器的工作過程如下圖所示。

Parallel Old收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器是在 JDK 1.6 中才開始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直處於比較尷尬的狀態。

原因是,如果新生代選擇了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge 收集器無法與 CMS 收集器配合工作)。

由於老年代 Serial Old 收集器在服務端應用效能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整體應用上獲得吞吐量最大化的效果,由於單執行緒的老年代收集中無法充分利用伺服器多 CPU 的處理能力,在老年代很大而且硬體比較高階的環境中,這種組合的吞吐量甚至還不一定有 ParNew 加 CMS 的組合“給力”。

直到 Parallel Old 收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作過程如下圖所示。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。

目前很大一部分的 Java 應用集中在網際網路站或者 B/S 系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS 收集器就非常符合這類應用的需求。

從名字(包含"Mark Sweep")上就可以看出,CMS 收集器是基於“標記—清除”演算法實現的,它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分為4個步驟,包括:

  1. 初始標記(CMS initial mark)
  2. 併發標記(CMS concurrent mark)
  3. 重新標記(CMS remark)
  4. 併發清除(CMS concurrent sweep)

其中,初始標記、重新標記這兩個步驟仍然需要"Stop The World"。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快,併發標記階段就是進行 GC RootsTracing 的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以,從總體上來說,CMS 收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

CMS 是一款優秀的收集器,它的主要優點在名字上已經體現出來了:併發收集、低停頓,但是 CMS 還遠達不到完美的程度,它有以下 3 個明顯的缺點:

第一、導致吞吐量降低。CMS 收集器對 CPU 資源非常敏感。其實,面向併發設計的程式都對 CPU 資源比較敏感。在併發階段,它雖然不會導致使用者執行緒停頓,但是會因為佔用了一部分執行緒(或者說CPU資源)而導致應用程式變慢,總吞吐量會降低。

CMS 預設啟動的回收執行緒數是(CPU數量+3)/4,也就是當 CPU 在4個以上時,併發回收時垃圾收集執行緒不少於 25% 的 CPU 資源,並且隨著 CPU 數量的增加而下降。但是當 CPU 不足 4 個(譬如2個)時,CMS 對使用者程式的影響就可能變得很大,如果本來 CPU 負載就比較大,還分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低了 50%,其實也讓人無法接受。

第二、CMS 收集器無法處理浮動垃圾(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而導致另一次 Full GC(新生代和老年代同時回收) 的產生。由於 CMS 併發清理階段使用者執行緒還在執行著,伴隨程式執行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS 無法在當次收集中處理掉它們,只好留待下一次 GC 時再清理掉。這一部分垃圾就稱為“浮動垃圾”。

也是由於在垃圾收集階段使用者執行緒還需要執行,那也就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此 CMS 收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程式運作使用。

在 JDK 1.5 的預設設定下,CMS 收集器當老年代使用了 68% 的空間後就會被啟用,這是一個偏保守的設定,如果在應用中老年代增長不是太快,可以適當調高參數-XX:CMSInitiatingOccupancyFraction的值來提高觸發百分比,以便降低記憶體回收次數從而獲取更好的效能,在 JDK 1.6 中,CMS 收集器的啟動閾值已經提升至 92% 。

要是 CMS 執行期間預留的記憶體無法滿足程式需要,就會出現一次"Concurrent Mode Failure"失敗,這時虛擬機器將啟動後備預案:臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說引數-XX:CM SInitiatingOccupancyFraction設定得太高很容易導致大量"Concurrent Mode Failure"失敗,效能反而降低。

第三、產生空間碎片。CMS 是一款基於“標記—清除”演算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大物件分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前物件,不得不提前觸發一次 Full GC 。

為了解決這個問題,CMS 收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關引數(預設就是開啟的),用於在CMS收集器頂不住要進行 FullGC 時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機器設計者還提供了另外一個引數-XX:CMSFullGCsBeforeCompaction,這個引數是用於設定執行多少次不壓縮的 Full GC 後,跟著來一次帶壓縮的(預設值為0,表示每次進入Full GC時都進行碎片整理)。

G1收集器

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

並行與併發:G1 能充分利用多 CPU、多核環境下的硬體優勢,使用多個CPU(CPU或者CPU核心)來縮短 Stop-The-World 停頓的時間,部分其他收集器原本需要停頓 Java 執行緒執行的 GC 動作,G1 收集器仍然可以通過併發的方式讓 Java 程式繼續執行。

分代收集:與其他收集器一樣,分代概念在 G1 中依然得以保留。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠採用不同的方式去處理新建立的物件和已經存活了一段時間、熬過多次 GC 的舊物件以獲取更好的收集效果。

空間整合:與 CMS 的“標記—清理”演算法不同,G1 從整體來看是基於“標記—整理”演算法實現的收集器,從區域性(兩個 Region 之間)上來看是基於“複製”演算法實現的,但無論如何,這兩種演算法都意味著 G1 運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次 GC 。

可預測的停頓:這是 G1 相對於 CMS 的另一大優勢,降低停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時 Java(RTSJ)的垃圾收集器的特徵了。

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

G1 收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1 在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region(這也就是Garbage-First名稱的來由),保證了 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)
  2. 併發標記(Concurrent Marking)
  3. 最終標記(Final Marking)
  4. 篩選回收(Live Data Counting and Evacuation)

G1 的前幾個步驟的運作過程和 CMS 有很多相似之處。

初始標記階段僅僅只是標記一下 GC Roots 能直接關聯到的物件,並且修改 TAMS(Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時,能在正確可用的 Region 中建立新物件,這階段需要停頓執行緒,但耗時很短。

併發標記階段是從 GC Root 開始對堆中物件進行可達性分析,找出存活的物件,這階段耗時較長,但可與使用者程式併發執行。

而最終標記階段則是為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程 Remembered Set Logs 裡面,最終標記階段需要把 Remembered Set Logs 的資料合併到 Remembered Set 中,這階段需要停頓執行緒,但是可並行執行。

最後在篩選回收階段首先對各個 Region 的回收價值和成本進行排序,根據使用者所期望的 GC 停頓時間來制定回收計劃,從Sun公司透露出來的資訊來看,這個階段其實也可以做到與使用者程式一起併發執行,但是因為只回收一部分 Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅提高收集效率。通過下圖可以比較清楚地看到G1收集器的運作步驟中併發和需要停頓的階段。