java垃圾收集器
目錄
簡介
Serial 收集器
ParNew 收集器
并行(Parallel)
并發(Concurrent)
Parallel Scavenge 收集器
Serial Old 收集器
Parallel Old 收集器
CMS收集器
G1收集器
G1簡介
G1細節
G1 Minor GC流程
G1 Mixed GC流程
G1特點
G1與CMS的區別與選擇
ZGC收集器
垃圾處理器總結
簡介
如果說收集算法是內存回收的方法論,垃圾收集器就是內存回收的具體實現。Java 虛擬機規范中對垃圾收集器應該如何實現并沒有任何規定,因此不同的廠商、不同版本 的虛擬機所提供的垃圾收集器都可能會有很大的差別,并且一般都會提供參數供用戶根 據自己的應用特點和要求組合出各個年代所使用的收集器。這里討論的收集器基于Sun HotSpot虛擬機1.6版Update 22,這個虛擬機包含的所有收集器,如圖
注意:這個關系不是一成不變的,由于維護和兼容性測試的成本,在JDK 8時將Serial+CMS- ParNew+Serial Old這兩個組合聲明為廢棄(JEP 173) ,并在JDK 9中完全取消了這些組合的支持(JEP214)。
展示了 7種作用于不同分代的收集器(包括JDK 1.6_Updatel4后引入的 Early Access版G1收集器),如果兩個收集器之間存在連線,就說明它們可以搭配使用。
在介紹這些收集器各自的特性之前,我們先來明確一個觀點:雖然我們是在對各個 收集器進行比較,但并非為了挑選一個最好的收集器出來。因為直到現在為止還沒有最 好的收集器出現,更加沒有萬能的收集器,所以我們選擇的只是對具體應用場景最合適的收 集器。這點不需要多加解釋就能證明:如果有一種放之四海皆準、任何場景下都適用的 完美收集器存在,那HotSpot虛擬機就沒必要實現那么多不同的收集器了。
Serial 收集器
Serial收集器是最基本、歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是虛擬機 新生代收集的唯一選擇。大家看名字就知道,這個收集器是一個單線程的收集器,但它的“單線程”的意義并不僅僅是說明它只會使用一個CPU或一條收集線程去完成垃圾收 集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程(Sim將這件 事情稱之為“Stop The World”),直到它收集結束。“Stop The World”這個名字也許聽 起來很酷,但這項工作實際上是由虛擬機在后臺自動發起和自動完成的,在用戶不可見 的情況下把用戶的正常工作的線程全部停掉,這對很多應用來說都是難以接受的。你想 想,要是你的電腦每運行一個小時就會暫停響應5分鐘,你會有什么樣的心情?圖3?6 示意了 Serial / Serial Old收集器的運行過程。
對于“Stop The World”帶給用戶的惡劣體驗,虛擬機的設計者們表示完全理解,但 也表示非常委屈「'你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上 或房間外待著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完嗎? ”這確實是 一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬于一個性質的,但實 際上肯定還要比打掃房間復雜得多啊!
新生代采用標記-復制算法,老年代采用標記-整理算法。
從JDK1.3開始, HotSpot虛擬機開發團隊 為消除或減少工作線程因內存回收而導致停頓的努力一直在進行著,從Serial收集器 到Parallel收集器,再到Concurrent Mark Sweep (CMS)現在還未正式發布的Garbage First (G1)收集器,我們看到了一個個越來越優秀(也越來越復雜)的收集器的出現, 用戶線程的停頓時間在不斷縮短,但是仍然沒有辦法完全消除(這里暫不包括RTSJ中 的收集器)。尋找更優秀的垃圾收集器的工作仍在繼續!
寫到這里,筆者似乎已經把Serial收集器描述成一個老而無用,食之無味棄之可惜 的雞肋了,但實際上到現在為止,它依然是虛擬機運行在Client模式下的默認新生代收 集器。它也有著優于其他收集器的地方:簡單而高效(與其他收集器的單線程比),對 于限定單個CPU的環境來說,Serial收集器由于沒有線程交互的開銷,專心做垃圾收 集自然可以獲得最高的單線程收集效率。在用戶的桌面應用場景中,分配給虛擬機管理 的內存一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的 內存,桌面應用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫 秒以內,只要不是頻繁發生,這點停頓是可以接受的。所以,Serial收集器對于運行在 Client模式下的虛擬機來說是一個很好的選擇。
ParNew 收集器
ParNew收集器其實就是Serial收集器的多線程版本,除了使用多條線程進行垃圾 收集之外,其余行為包括Serial收集器可用的所有控制參數
(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold, -XX:HandlePromotionFailure 等)、收集算法、Stop The World.對象分配規則、回收策略等都與Serial收集器完全一樣,實現上這兩種收集器也 共用了相當多的代碼。ParNew收集器的工作過程如圖
新生代采用標記-復制算法,老年代采用標記-整理算法。
ParNew收集器除了支持多線程并行收集之外,其他與Seril收集器相比并沒有太多創新之處,但它卻是不少運行在服務端模式下的Hotspot虛擬機,尤其是JDK 7之前的遺留系統中首選的新生代收集器,其中有一個與功能、性能無關但其實很重要的原因是:除了Serial收器外,目前只有它能與CMS收集器配合工作。
在JDK 5發布時, HotSpot推出了一款在強交互應用中幾乎可稱為具有劃時代意義的垃圾收集器-CMS收集器。這款收集器是HotSpot虛擬機中第一款真正意義上支持并發的垃圾收集器,它首次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。
遺憾的是, CMS作為老年代的收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器ParallelScavenge配合工作[1],所以在JDK5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Serial收集器中的一個。ParNew收集器是激活CMS后(使用-Xx: +UseConcMarkSweepGC選項)的默認新生代收集器,也可以使用-Xx: +/-UseParNewGC選項來強制指定或者禁用它。
可以說直到CMS的出現才鞏固了ParNew的地位,但成也蕭何敗也蕭何,隨著垃圾收集器技術的不斷改進,更先進的G1收集器帶著CMS繼承者和替代者的光環登場。G1是一個面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK 9開始, ParNew加CMS收集器的組合就不再是官方推薦的服務端模式下的收集器解決方案了。官方希望它能完全被G1所取代,甚至還取消了ParNew加Serial old以及Serial加CMS這兩組收集器組合的支持(其實原本也很少人這樣使用) ,并直接取消了xx: +UseParNewGC參數,這意味著ParNew和CMS從此只能互相搭配使用,再也沒有其他收集器能夠和它們配合了。
讀者也可以理解為從此以后, ParNew合并入CMS,成為它專門處理新生代的組成部分。ParNew可以說是HotSpot虛擬機中第一款退出歷史舞臺的垃圾收集器。
ParNew收集器在單核心處理器的環境中絕對不會有比Serial收集器更好的效果,甚至由于存在線程交互的開銷,該收集器在通過超線程(Hyper-Threading)技術實現的偽雙核處理器環境中都不能百分之百保證超越Serial收集器。當然,隨著可以被使用的處理器核心數量的增加, ParNew對于垃圾收集時系統資源的高效利用還是很有好處的。它默認開啟的收集線程數與處理器核心數量相同,在處理器核心非常多(譬如32個,現在CPU都是多核加超線程設計,服務器達到或超過32個邏輯核心的情況非常普遍)的環境中,可以使用-xx: ParallelGCThreads參數來限制垃圾收集的線程數。
注意 從ParNew收集器開始,后面還將會接觸到幾款并發和并行的收集器。在大家可能 產生疑惑之前,有必要先解釋兩個名詞:并發和并行。這兩個名詞都是并發編程中的概 念,在談論垃圾收集器的上下文語境中,他們可以解釋為:
并行(Parallel)
指多條垃圾收集線程并行工作,但此時用戶線程仍然處于等待狀態。其實指垃圾收集線程,內部多線程工作。?
并發(Concurrent)
指用戶線程與垃圾收集線程同時執行(但不一定是并行的,可能 會交替執行),用戶程序繼續運行,而垃圾收集程序運行于另一個CPU上。指垃圾收集線程與用戶線程,兩者多線程工作。
Parallel Scavenge 收集器
Parallel Scavenge收集器也是一個新生代收集器,它也是使用復制算法的收集器, 又是并行的多線程收集器……看上去和ParNew都一樣,那它有什么特別之處呢?
-XX:+UseParallelGC使用 Parallel 收集器+ 老年代串行-XX:+UseParallelOldGC使用 Parallel 收集器+ 老年代并行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)
如果 讀者對于收集器運作原理不太了解,手工優化存在困難的時候,使用Parallel Scavenge 收集器配合自適應調節策略,把內存管理的調優任務交給虛擬機去完成將是一個很 不錯的選擇。只需要把基本的內存數據設置好(如-Xmx設置最大堆),然后使用 MaxGCPauseMillis參數(更關注最大停頓時間)或GCTimeRatio參數(更關注吞吐量) 給虛擬機設立一個優化目標,那具體細節參數的調節工作就由虛擬機完成了。自適應調 節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。
新生代采用標記-復制算法,老年代采用標記-整理算法。
這是 JDK1.8 默認收集器
使用 java -XX:+PrintCommandLineFlags -version 命令查看
-XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC java version "1.8.0_211" Java(TM) SE Runtime Environment (build 1.8.0_211-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)JDK1.8 默認使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 參數,則默認指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 來禁用該功能?
Serial Old 收集器
Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標 記-整理”算法。
這個收集器的主要意義也是被Client模式下的虛擬機使用。
如果 在Server模式下,它主要還有兩大用途:一個是在JDK 1.5及之前的版本中與Parallel Scavenge收集器搭配使用。(需要說明一下, Parallel Scavenge收集器架構中本身有PS MarkSweep收集器來進行老年代收集,并非直接調用Serial old收集器,但是這個PS MarkSweep收集器與Serial old的實現幾乎是一樣的,所以在官方的許多資料中都是直接以Serial old代替PS MarkSweep進行講解,這里筆者也采用這種方式。)
另外一個就是作為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力n CMS的組合“給力”。
直到Parallel Old收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的應 用組合,在注重吞吐量及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加 Parallel Old收集器。Parallel Old收集器的工作過程如圖
CMS收集器
CMS (Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收 集器。目前很大一部分的Java應用都集中在互聯網站或B/S系統的服務端上,這類應用 尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收 集器就非常符合這類應用的需求。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虛擬機第一款真正意義上的并發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。
從名字(包含“MarkSweep”)上就可以看出CMS收集器是基于“標記-清除”算 法實現的,它的運作過程相對于前面幾種收集器來說要更復雜一些,整個過程分為5個 步驟,包括:
- 初始標記(CMS initial mark)
 - 并發標記(CMS concurrent mark)
 - 并發預清理
 - 重新標記(CMS remark)
 - 并發清除(CMS concurrent sweep)
 
其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。(第一個,第三個步驟暫停,第二個,第四個并發)
初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,以及「年輕代」指向「老年代」的對象???????,速度很快,暫停。
并發標記階段就是進行GC Roots Tracing的過程,同時開啟 GC 和用戶線程,用一個閉包結構去記錄可達對象。但在這個階段結束,這個閉包結構并不能保證包含當前所有的可達對象。因為用戶線程可能會不斷的更新引用域,所以 GC 線程無法保證可達性分析的實時性。所以這個算法里會跟蹤記錄這些發生引用更新的地方,并發。
「并發預處理」這個階段主要想干的事情:希望能減少下一個階段「重新標記」所消耗的時間
因為下一個階段「重新標記」是需要Stop The World的
「并發標記」這個階段由于用戶線程是沒有被掛起的,所以對象是有可能發生變化的
可能有些對象,從新生代晉升到了老年代。可能有些對象,直接分配到了老年代(大對象)。可能老年代或者新生代的對象引用發生了變化…
那這個問題,怎么解決呢?
針對老年代的對象,其實還是可以借助類card table的存儲(將老年代對象發生變化所對應的卡頁標記為dirty)
所以「并發預處理」這個階段會掃描可能由于「并發標記」時導致老年代發生變化的對象,會再掃描一遍標記為dirty的卡頁
對于新生代的對象,我們還是得遍歷新生代來看看在「并發標記」過程中有沒有對象引用了老年代..
不過JVM里給我們提供了很多「參數」,有可能在這個過程中會觸發一次 minor GC(觸發了minor GC 是意味著就可以更少地遍歷新生代的對象)
?
?而重新標記階段則是為了修正并發標記期間,因用戶程序繼續運 作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始 標記階段稍長一些,但遠比并發標記的時間短,暫停。(注意hotspot算法細節里的增量更新)
這個過程的停頓時間其實很大程度上取決于上面「并發預處理」階段(可以發現,這是一個追趕的過程:一邊在標記存活對象,一邊用戶線程在執行產生垃圾)
并發清除階段是使用標記-清除的方法清理垃圾,注意不是標記-整理!這個過程是并發的。
這個過程,還是有可能用戶線程在不斷產生垃圾,但只能留到下一次GC 進行處理了,產生的這些垃圾被叫做“浮動垃圾”
由于整個過程中耗時最長的并發標記和并發清除過程中,收集器線程都可以與用戶 線程一起工作,所以總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發地 執行的。通過圖3-10可以比較清楚地看到CMS收集器的運作步驟中并發和需要停頓的 時間。
CMS是一款優秀的收集器,它的最主要優點在名字上已經體現出來了:并發收集、 低停頓,Sun的一些官方文檔里面也稱之為并發低停頓收集器(Concurrent Low Pause Collector)。但是CMS還遠達不到完美的程度,它有以下四個顯著的缺點:
- 對 CPU 資源敏感
 - 無法處理浮動垃圾,可能會導致serial old
 - 它使用的回收算法-“標記-清除”算法會導致收集結束時會有大量空間碎片產生
 - 需要預留的空間,給用戶線程使用
 
CMS收集器對CPU資源非常敏感。其實,面向并發設計的程序都對CPU資源比 較敏感。在并發階段,它雖然不會導致用戶線程停頓,但是會因為占用了一部分 線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。CMS默認啟 動的回收線程數是(CPU數量+3)/4,也就是當CPU在4個以上時,并發回收 時垃圾收集線程最多占用不超過25%的CPU資源。但是當CPU不足4個時(譬 如2個),那么CMS對用戶程序的影響就可能變得很大,如果CPU負載本來就 比較大的時候,還分出一半的運算能力去執行收集器線程,就可能導致用戶程序 的執行速度忽然降低了 50%,這也很讓人受不了。為了解決這種情況,虛擬機提 供了一種稱為“增量式并發收集器” (Incremental Concurrent Mark Sweep / i-CMS) 的CMS收集器變種,所做的事情和單CPU年代PC機操作系統使用搶占式來模 擬多任務機制的思想一樣,就是在并發標記和并發清理的時候讓GC線程、用戶 線程交替運行,盡量減少GC線程的獨占資源的時間,這樣整個垃圾收集的過程 會更長,但對用戶程序的影響就會顯得少一些,速度下降也就沒有那么明顯,但 是目前版本中,i?CMS已經被聲明為“deprecated",即不再提倡用戶使用。
CMS收集器無法處理浮動垃圾(Floating Garbage),可能出現“Concurrent Mode Failure"失敗而導致另一次Full GC的產生。由于CMS并發清理階段用戶線程還 在運行著,伴隨程序的運行自然還會有新的垃圾不斷產生,這一部分垃圾出現在 標記過程之后,CMS無法在本次收集中處理掉它們,只好留待下一次GC時再將 其清理掉。這一部分垃圾就稱為“浮動垃圾”。
也是由于在垃圾收集階段用戶線 程還需要運行,即還需要預留足夠的內存空間給用戶線程使用,因此CMS收集 器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一 部分空間提供并發收集時的程序運作使用。在默認設置下,CMS收集器在老年代 使用了 68%的空間后就會被激活,這是一個偏保守的設置,如果在應用中老年代 增長不是太快,可以適當調高參數-XX:CMSInitiatingOccupancyFraction的值來 提髙觸發百分比,以便降低內存回收次數以獲取更好的性能。
要是CMS運行期 間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失 敗,這時候虛擬機將啟動后備預案:臨時啟用Serial Old收集器來重新進行老年 代的垃圾收集,這樣停頓時間就很長了。所以說參數-XX:CMSInitiatingOccupan cyFraction設置得太高將會很容易導致大量uConcurrent Mode Failure”失敗,性 能反而降低。
還有最后一個缺點,在本節的開頭曾提到, CMS是一款基于"標記-清除”算法實現的收集器,如果讀者對前面這部分介紹還有印象的話,就可能想到這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很多剩余空間,但就是無法找到足夠大的連續空間來分配當前對象,而不得不提前觸發一次Full GC的情況。
為了解決這個問題,CMS收集器提供了一個-xx: +UseCMS-CompactAtFullCcollection開關參數(默認是開啟的,此參數從- JDK 9開始廢棄) ,用于在CMS收集器不得不進行FullGC時開啟內存碎片的合并整理過程,由于這個內存整理必須移動存活對象, (在Shenandoah和ZGc出現前)是無法并發的。這樣空間碎片問題是解決了,但停頓時間又會變長
因此虛擬機設計者們還提供了另外一個參數-xx: CMSFullGCsBeforeCompaction (此參數從JDK 9開始廢棄) ,這個參數的作用是要求CMs收集器在執行過若干次(數量由參數值決定)不整理空間的Full GC之后,下一次進入Full GC前會先進行碎片整理(默認值為0,表示每次進入Full GC時都進行碎片整理)
G1收集器
G1簡介
G1 (Garbage-First) 是一款面向服務器的垃圾收集器,主要針對配備多顆處理器及大容量內存的機器. 以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量性能特征.
HotSpot開發團隊最初賦予它的期望是(在比較長期的)未來可以替換掉JDK5中發布的CMS收集器。現在這個期望目標已經實現過半了, JDK9發布之日, G1宣告取代Parallel Scavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器,而CMs則淪落至被聲明為不推薦使用(Deprecate)的收集器。如果對JDK 9及以上版本的HotSpot虛擬機使用參數-XX: +UseConcMarkSweepGC來開啟CMS收集器的話,用戶會收到一個警告信息,提示CMS未來將會被廢棄
作為CMS收集器的替代者和繼承人,設計者們希望做出一款能夠建立起“停頓時間模型” (PausePrediction Model)的收集器,停頓時間模型的意思是能夠支持指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間大概率不超過N毫秒這樣的目標,這幾乎已經是實時Java (RTSJ)的中軟實時垃圾收集器特征了。
那具體要怎么做才能實現這個目標呢?首先要有一個思想上的改變,在G1收集器出現之前的所有其他收集器,包括CMS在內,垃圾收集的目標范圍要么是整個新生代(Minor GC) ,要么就是整個老年代(Major GC) ,再要么就是整個Java堆(Full GC)。而G1跳出了這個樊籠,它可以面向堆內存任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬于哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。
G1 收集器采用一種不同的方式來管理堆內存.
堆內存被劃分為多個大小相等的 heap 區,每個heap區都是邏輯上連續的一段內存(virtual memory). 其中一部分區域被當成收集器相同的角色(eden, survivor, old), 但每個角色的區域個數都不是固定的。這在內存使用上提供了更多的靈活性。
Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-xx: G1HeapRegionSize設·定,取值范圍為1MB~32MB,且應為2的N次冪。而對于那些超過了整個Region容量的超級大對象,將會被存放在N個連續的Humongous Region之中, G1的大多數行為都把Humongous Regon作為老年代的一部分來進行看待,如圖所示。
G1細節
1 將Java堆分成多個獨立Region后, Region里面存在的跨Region引用對象如何解決?
解決的思路我們已經知道 :使用記憶集避免全堆作為GC?Roots掃描,但在G1收集器上記億集的應用其實要復雜很多,它的每個Region都維護有自己的記憶集,這些記憶集會記錄下別的Region指向自己的指針,并標記這些指針分別在哪些卡頁的范圍之內。
G1的記憶集在存儲結構的本質上是一種哈希表, Key是別的Region的起始地址, Value是一個集合,里面存儲的元素是卡表的索引號。這種“雙向”的卡表結構(卡表是“我指向誰”,這種結構還記錄了“誰指向我”)比原來的卡表實現起來更復雜
同時由于Region數量比傳統收集器的分代數量明顯要多得多,因此G1收集器要比其他的傳統垃圾收集器有著更高的內存占用負擔。根據經驗, G1至少要耗費大約相當于Java堆容量10%至20%的額外內存來維持收集器工作。
2 在并發標記階段如何保證收集線程與用戶線程互不干擾地運行?
這里首先要解決的是用戶線程改變對象引用關系時,必須保證其不能打破原本的對象圖結構,導致標記結果出現錯誤,該問題的解決辦法筆者已經講解過 : CMS收集器采用增量更新算法實現,而G1收集器則是通過原始快照(SATB)算法來實現的。
此外,垃圾收集對用戶線程的影響還體現在回收過程中新創建對象的內存分配上,程序要繼續運行就肯定會持續有新對象被創建, G1為每一個Region設計了兩個名為TAMS (Top at Mark Start)的指針,把Region中的一部分空間劃分出來用于并發回收過程中的新對象分配,并發回收時新分配的對象地址都必須要在這兩個指針位置以上。
G1收集器默認在這個地址以上的對象是被隱式標記過的,即默認它們是存活的,不納入回收范圍。與CMS中的"Concurrent Mode Failure"失敗會導致Full GC類似,如果內存回收的速度趕不上內存分配的速度,G1收集器也要被迫凍結用戶線程執行,導致Full GC而產生長時間"Stop The World"。
3 怎樣建立起可靠的停頓預測模型?
用戶通過-xx: MaxGCPauseMillis參數指定的停頓時間只意味著垃圾收集發生之前的期望值,但G1收集器要怎么做才能滿足用戶的期望呢?
G1收集器的停頓預測模型是以衰減均值(Decaying Average)為理論基礎來實現的,在垃圾收集過程中, G1收集器會記錄每個Region的回收耗時、每個Regon記憶集里的臟卡數量等各個可測量的步驟花費的成本,并分析得出平均值、標準偏差、置信度等統計信息。
這里強調的"衰減平均值”是指它會比普通的平均值更容易受到新數據的影響,平均值代表整體平均狀態,但衰減平均值更準確地代表“最近的"平均狀態。換句話說, Region的統計狀態越新越能決定其回收的價值。然后通過這些信息預測現在開始回收的話,由哪些Region組成回收集才可以在不超過期望停頓時間的約束下獲得最高的收益。
G1 Minor GC流程
G1的Minor GC其實觸發時機跟前面提到過的垃圾收集器都是一樣的
等到Eden區滿了之后,會觸發Minor GC。Minor GC同樣也是會發生Stop The World的
要補充說明的是:在G1的世界里,新生代和老年代所占堆的空間是沒那么固定的(會動態根據「最大停頓時間」進行調整)
這塊要知道會給我們提供參數進行配置就好了
所以,動態地改變年輕代Region的個數可以「控制」Minor GC的開銷
Minor GC我認為可以簡單分為為三個步驟:根掃描、更新&&處理 RSet、復制對象
第一步應該很好理解,因為這跟之前CMS是類似的,可以理解為初始標記的過程
第二步涉及到「Rset」的概念
從上一次我們聊CMS回收過程的時候,同樣講到了Minor GC,它是通過「卡表」(cart table)來避免全表掃描老年代的對象
因為Minor GC 是回收年輕代的對象,但如果老年代有對象引用著年輕代,那這些被老年代引用的對象也不能回收掉
同樣的,在G1也有這種問題(畢竟是Minor GC)。CMS是卡表,而G1解決「跨代引用」的問題的存儲一般叫做RSet
只要記住,RSet這種存儲在每個Region都會有,它記錄著「其他Region引用了當前Region的對象關系」
對于年輕代的Region,它的RSet 只保存了來自老年代的引用(因為年輕代的沒必要存儲啊,自己都要做Minor GC了)
而對于老年代的 Region 來說,它的 RSet 也只會保存老年代對它的引用(在G1垃圾收集器,老年代回收之前,都會先對年輕代進行回收,所以沒必要保存年輕代的引用)
那第二步看完RSet的概念,應該也好理解了吧?
無非就是處理RSet的信息并且掃描,將老年代對象持有年輕代對象的相關引用都加入到GC Roots下,避免被回收掉
到了第三步也挺好理解的:把掃描之后存活的對象往「空的Survivor區」或者「老年代」存放,其他的Eden區進行清除
在G1還有另一個名詞,叫做CSet。
它的全稱是 Collection Set,保存了一次GC中「將執行垃圾回收」的Region。CSet中的所有存活對象都會被轉移到別的可用Region上
在Minor GC 的最后,會處理下軟引用、弱引用、JNI Weak等引用,結束收集
?
G1 Mixed GC流程
如果我們不去計算用戶線程運行過程中的動作(如使用寫屏障維護記憶集的操作) , G1收集器的運作過程大致可劃分為以下四個步驟:
?
初始標記(Initial Marking) :僅僅只是標記一下GC Roots能直接關聯到的對象,并且修改TAMS指針的值,讓下一階段用戶線程并發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓。
這個過程是「共用」了Minor GC的 Stop The World(Mixed GC 一定會發生 Minor GC),復用了「掃描GC Roots」的操作。并且在這個過程中,老年代和新生代都會掃。
并發標記(Concurrent Marking) :從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序并發執行。當對象圖掃描完成以后,還要重新處理SATB記錄下的在并發時有引用變動的對象。
最終標記(Final Marking) :對用戶線程做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄。如果在開始時,G1就認為它是活的,那就在此次GC中不會對它回收,即便可能在「并發階段」上對象已經變為了垃圾。
所以,G1也有可能會存在「浮動垃圾」的問題。但是總的來說,對于G1而言,問題不大(畢竟它不是追求一次把所有的垃圾都清除掉,而是注重 Stop The World時間)
標記階段完成后,G1就可以知道哪些heap區的empty空間最大。
G1 收集器在后臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的 Region(這也就是它的名字 Garbage-First 的由來) 。這種使用 Region 劃分內存空間以及有優先級的區域回收方式,保證了 G1 收集器在有限時間內可以盡可能高的收集效率(把內存化整為零)。
G1使用暫停預測模型(pause prediction model)來達到用戶定義的目標暫停時間,并根據目標暫停時間來選擇此次進行垃圾回收的heap區域數量.
需要強調的是, G1并不是一款實時垃圾收集器(real-time collector). 能以極高的概率在設定的目標暫停時間內完成,但不保證絕對在這個時間內完成。 基于以前收集的各種監控數據, G1會根據用戶指定的目標時間來預估能回收多少個heap區. 因此,收集器有一個相當精確的heap區耗時計算模型,并根據該模型來確定在給定時間內去回收哪些heap區
篩選回收(Live Data Counting and Evacuation) :負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活對象復制到空的Region中,再清理掉整個舊Region的全部空間。這里的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程并行完成的。
一般來說,Mixed GC會選定所有的年輕代Region,部分「回收價值高」的老年代Region(回收價值高其實就是垃圾多)進行采集
被G1標記為適合回收的heap區將使用轉移(evacuation)的方式進行垃圾回收. G1將一個或多個heap區域中的對象拷貝到其他的單個區域中,并在此過程中壓縮和釋放內存,基于標記-整理。
那G1會什么時候發生full GC?
如果在Mixed GC中無法跟上用戶線程分配內存的速度,導致老年代填滿無法繼續進行Mixed GC,就又會降級到serial old GC來收集整個GC heap
不過這個場景相較于CMS還是很少的,畢竟G1沒有CMS內存碎片這種問題
從上述階段的描述可以看出, G1收集器除了并發標記外,其余階段也是要完全暫停用戶線程的,換言之,它并非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得盡可能高的吞吐量,所以才能擔當起“全功能收集器”的重任與期望
毫無疑問,可以由用戶指定期望的停頓時間是G1收集器很強大的一個功能,設置不同的期望停頓時間,可使得G1在不同應用場景中取得關注吞吐量和關注延遲之間的最佳平衡。不過,這里設置的“期望值”必須是符合實際的,不能異想天開,畢竟G1是要凍結用戶線程來復制對象的,這個停頓時間再怎么低也得有個限度。它默認的停頓目標為兩百毫秒,一般來說,回收階段占到幾十到一百甚至接近兩百毫秒都很正常,但如果我們把停頓時間調得非常低,譬如設置為二十毫秒,很可能出現的結果就是由于停頓目標時間太短,導致每次選出來的回收集只占堆內存很小的一部分,收集器收集的速度逐漸跟不上分配器分配的速度,導致垃圾慢慢堆積。很可能一開始收集器還能從空閑的堆內存中獲得一些喘息的時間,但應用運行時間一長就不行了,最終占滿堆引發Full GC反而降低性能,所以通常把期望停頓時間設置為一兩百毫秒或者兩三百毫秒會是比較合理的。
從G1開始,最先進的垃圾收集器的設計導向都不約而同地變為追求能夠應付應用的內存分配速率Allocation Rate) ,而不追求一次把整個Java堆全部清理干凈。這樣,應用在分配,同時收集器在收集,只要收集的速度能跟得上對象分配的速度,那一切就能運作得很完美。這種新的收集器設計思路從工程實現上看是從G1開始興起的,所以說G1是收集器技術發展的一個里程碑。
G1特點
它具備一下特點:
- 并行與并發:G1 能充分利用 CPU、多核環境下的硬件優勢,使用多個 CPU(CPU 或者 CPU 核心)來縮短 Stop-The-World 停頓時間。部分其他收集器原本需要停頓 Java 線程執行的 GC 動作,G1 收集器仍然可以通過并發的方式讓 java 程序繼續執行。
 - 內存分區:將內存劃分為一個個相等大小的內存分區,回收時則以分區為單位進行回收,存活的對象復制到另一個空閑分區中。由于都是以相等大小的分區為單位進行操作,因此G1天然就是一種壓縮方案(局部壓縮);
 - 分代收集:雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但是還是保留了分代的概念。G1只有邏輯上的分代概念,或者說每個分區都可能隨G1的運行在不同代之間前后切換;
 - 無空間碎片:與 CMS 的“標記-清理”算法不同,G1 從整體來看是基于“標記-整理”算法實現的收集器;從局部上來看是基于“標記-復制”算法實現的。
 - 不一定內存耗盡才GC:G1的設計原則是"首先收集盡可能多的垃圾(Garbage First)"。因此,G1并不會等內存耗盡(串行、并行)或者快耗盡(CMS)的時候開始垃圾收集,而是在內部采用了啟發式算法,在老年代找出具有高收集收益的分區進行收集。同時G1可以根據用戶設置的暫停時間目標自動調整年輕代和總堆大小,暫停目標越短年輕代空間越小、總空間就越大;
 - 可預測的停頓:這是 G1 相對于 CMS 的另一個大優勢,降低停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為 M 毫秒的時間片段內。
 - 允許部分收集:G1的收集都是STW(低停頓)的,但年輕代和老年代的收集界限比較模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年輕代分區(年輕代收集),也可能在收集年輕代的同時,包含部分老年代分區(混合收集),這樣即使堆內存很大時,也可以限制收集范圍,從而降低停頓。
 
G1與CMS的區別與選擇
G1收集器是垃圾收集器理論進一步發展的產物,它與前面的CMS收集器相比有兩 個顯著的改進:
1?G1收集器是基于“標記-整理”算法實現的收集器,也就是說它 不會產生空間碎片,這對于長時間運行的應用系統來說非常重要。
2?它可以非常精確 地控制停頓,既能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收 集上的時間不得超過N毫秒,這幾乎已經是實時Java (RTSJ)的垃圾收集器的特征了。
也有缺點
用戶程序運行過程中, G1無論是為了垃圾收集產生的內存占用(Footprint)還是程序運行時的額外執行負載(Overload)都要比CMS要高。
1 內存占用來說,雖然G1和CMS都使用卡表來處理跨代指針,但G1的卡表實現更為復雜,而且堆中每個Region,無論扮演的是新生代還是老年代角色,都必須有一份卡表,這導致G1的記憶集(和其他內存消耗)可能會占整個堆容量的20%乃至更多的內存空間;
相比起來CMS的卡表就相當簡單,只有唯一一份,而且只需要處理老年代到新生代的引用,反過來則不需要,由于新生代的對象具有朝生夕滅的不穩定性,引用變化頻繁,能省下這個區域的維護開銷是很劃算的
2 在執行負載的角度上,同樣由于兩個收集器各自的細節實現特點導致了用戶程序運行時的負載會有不同,譬如它們都使用到寫屏障, CMS用寫后屏障來更新維護卡表;
而G1除了使用寫后屏障來進行同樣的(由于G1的卡表結構復雜,其實是更煩瑣的)卡表維護操作外,為了實現原始快照搜索(SATB)算法,還需要使用寫前屏障來跟蹤并發時的指針變化情況。
相比起增量更新算法,原始快照搜索能夠減少并發標記和重新標記階段的消耗,避免CMS那樣在最終標記階段停頓時間過長的缺點,但是在用戶程序運行過程中確實會產生由跟蹤引用變化帶來的額外負擔。
由于G1對寫屏障的復雜操作要比CMS消耗更多的運算資源,所以CMS的寫屏障實現是直接的同步操作,而G1就不得不將其實現為類似于消息隊列的結構,把寫前屏障和寫后屏障中要做的事情都放到隊列里,然后再異步處理。
以上的優缺點對比僅僅是針對G1和CMS兩款垃圾收集器單獨某方面的實現細節的定性分析,通常我們說哪款收集器要更好、要好上多少,往往是針對具體場景才能做的定量比較。按照筆者的實踐經驗,目前在小內存應用上CMS的表現大概率仍然要會優于G1,而在大內存應用上G1則大多能發揮其優勢,這個優劣勢的Java堆容量平衡點通常在6GB至8GB之間,當然,以上這些也僅是經驗之談,不同應用需要量體裁衣地實際測試才能得出最合適的結論,隨著HotSpot的開發者對G1的不斷優化,也會讓對比結果繼續向G1傾斜。
ZGC收集器
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延遲垃圾回收器,它的設計目標包括:
停頓時間不超過10ms;
停頓時間不會隨著堆的大小,或者活躍對象的大小而增加;
支持8MB~4TB級別的堆(未來支持16TB)。
從設計目標來看,我們知道ZGC適用于大內存低延遲服務的內存管理和回收。
與CMS中的ParNew和G1類似,ZGC也采用標記-復制算法,不過ZGC對該算法做了重大改進:ZGC在標記、轉移和重定位階段幾乎都是并發的,這是ZGC實現停頓時間小于10ms目標的最關鍵原因。
ZGC垃圾回收周期如下圖所示:
ZGC只有三個STW階段:初始標記,再標記,初始轉移。其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入并發標記階段。即,ZGC幾乎所有暫停都只依賴于GC Roots集合大小,停頓時間不會隨著堆的大小或者活躍對象的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活對象的大小增加而增加。
ZGC關鍵技術
ZGC通過著色指針和讀屏障技術,解決了轉移過程中準確訪問對象的問題,實現了并發轉移。大致原理描述如下:并發轉移中“并發”意味著GC線程在轉移對象的過程中,應用線程也在不停地訪問對象。假設對象發生轉移,但對象地址未及時更新,那么應用線程可能訪問到舊地址,從而造成錯誤。而在ZGC中,應用線程訪問對象將觸發“讀屏障”,如果發現對象被移動了,那么“讀屏障”會把讀出來的指針更新到對象的新地址上,這樣應用線程始終訪問的都是對象的新地址。那么,JVM是如何判斷對象被移動過呢?就是利用對象引用的地址,即著色指針。下面介紹著色指針和讀屏障技術細節。
著色指針
著色指針是一種將信息存儲在指針中的技術。
ZGC僅支持64位系統,它把64位虛擬地址空間劃分為多個子空間,如下圖所示:
其中,[0~4TB) 對應Java堆,[4TB ~ 8TB) 稱為M0地址空間,[8TB ~ 12TB) 稱為M1地址空間,[12TB ~ 16TB) 預留未使用,[16TB ~ 20TB) 稱為Remapped空間。
當應用程序創建對象時,首先在堆空間申請一個虛擬地址,但該虛擬地址并不會映射到真正的物理地址。ZGC同時會為該對象在M0、M1和Remapped地址空間分別申請一個虛擬地址,且這三個虛擬地址對應同一個物理地址,但這三個空間在同一時間有且只有一個空間有效。ZGC之所以設置三個虛擬地址空間,是因為它使用“空間換時間”思想,去降低GC停頓時間。“空間換時間”中的空間是虛擬空間,而不是真正的物理空間。后續章節將詳細介紹這三個空間的切換過程。
與上述地址空間劃分相對應,ZGC實際僅使用64位地址空間的第0~41位,而第42~45位存儲元數據,第47~63位固定為0。
?
ZGC將對象存活信息存儲在42~45位中,這與傳統的垃圾回收并將對象存活信息放在對象頭中完全不同。
讀屏障
讀屏障是JVM向應用代碼插入一小段代碼的技術。當應用線程從堆中讀取對象引用時,就會執行這段代碼。需要注意的是,僅“從堆中讀取對象引用”才會觸發這段代碼。
讀屏障示例:
Object o = obj.FieldA ? // 從堆中讀取引用,需要加入屏障
 <Load barrier>
 Object p = o ?// 無需加入屏障,因為不是從堆中讀取引用
 o.dosomething() // 無需加入屏障,因為不是從堆中讀取引用
 int i = ?obj.FieldB ?//無需加入屏障,因為不是對象引用
ZGC中讀屏障的代碼作用:在對象標記和轉移過程中,用于確定對象的引用地址是否滿足條件,并作出相應動作。
?ZGC并發處理演示
接下來詳細介紹ZGC一次垃圾回收周期中地址視圖的切換過程:
初始化:ZGC初始化之后,整個內存空間的地址視圖被設置為Remapped。程序正常運行,在內存中分配對象,滿足一定條件后垃圾回收啟動,此時進入標記階段。
 并發標記階段:第一次進入標記階段時視圖為M0,如果對象被GC標記線程或者應用線程訪問過,那么就將對象的地址視圖從Remapped調整為M0。所以,在標記階段結束之后,對象的地址要么是M0視圖,要么是Remapped。如果對象的地址是M0視圖,那么說明對象是活躍的;如果對象的地址是Remapped視圖,說明對象是不活躍的。
 并發轉移階段:標記結束后就進入轉移階段,此時地址視圖再次被設置為Remapped。如果對象被GC轉移線程或者應用線程訪問過,那么就將對象的地址視圖從M0調整為Remapped。
 其實,在標記階段存在兩個地址視圖M0和M1,上面的過程顯示只用了一個地址視圖。之所以設計成兩個,是為了區別前一次標記和當前標記。也即,第二次進入并發標記階段后,地址視圖調整為M1,而非M0。
著色指針和讀屏障技術不僅應用在并發轉移階段,還應用在并發標記階段:將對象設置為已標記,傳統的垃圾回收器需要進行一次內存訪問,并將對象存活信息放在對象頭中;而在ZGC中,只需要設置指針地址的第42~45位即可,并且因為是寄存器訪問,所以速度比訪問內存更快。
垃圾處理器總結
Serial,單線程,Client默認新生代處理器,新生代,復制算法,可與CMS(jdk9之后不行),Serial Old搭配。
ParNew,多線程,Serial的多線程版,新生代,復制算法,可與CMS,Serial Old(jdk9之后不行)搭配。
Parallel Scavenge,多線程,注重停頓時間和吞吐量,新生代,復制算法,可與Parallel Old,Serial Old搭配。
Serial Old,單線程,Client默認老年代處理器,Serial的老年版本,CMS的后備方案,老年代,標記整理,可與Serial,ParNew,Parallel Scavenge搭配。
Parallel Old,多線程,注重停頓時間和吞吐量,Parallel Scavenge的老年版本,老年代,標記整理,可與Parallel Scavenge搭配。
CMS,多線程,初始標記(單),并發標記(并發),重新標記(多線程,但與用戶線程不并發),并發清除(并發,失敗使用Serial Old),老年代,標記清除,可與Serial,ParNew搭配。
G1,多線程,內存分區(各分區大小相同,同一分區邏輯角色相同,都是新生代等,回收以分區為單位,復制到另一個分區),首先回收垃圾最多的分區,低停頓,使用暫停預測模型。新老年代,整體標記整理,局部復制,獨自工作
分工,G1獨自工作。Serial,ParNew兩個新生代,CMS,Serial Old兩個老年代,可以兩兩搭配。Parallel Scavenge搭配Parallel Old和Serial Old。
總結
                            
                        - 上一篇: NetLimiter 4.0.15.0
 - 下一篇: C++ 状态模式