1. 程式人生 > 實用技巧 >15. JVM垃圾回收器詳解

15. JVM垃圾回收器詳解

1. 垃圾回收器的分類 和 GC效能指標

垃圾收集器沒有在規範中進行過多的規定,可以由不同的廠商、不同版本的JVM來實現。

由於JDK的版本處於高速迭代過程中,因此Java發展至今已經衍生了眾多的GC版本。

從不同角度分析垃圾收集器,可以將GC分為不同的型別。

1.1 分類

按執行緒數分(垃圾回收執行緒數),可以分為序列垃圾回收器和並行垃圾回收器。

  1. 序列回收指的是在同一時間段內只允許有一個CPU用於執行垃圾回收操作,此時工作執行緒被暫停,直至垃圾收集工作結束。
  2. 和序列回收相反,並行收集可以運用多個CPU同時執行垃圾回收,因此提升了應用的吞吐量,不過並行回收仍然與序列回收一樣,採用獨佔式,工作執行緒等待GC執行完

適用場景

  1. 在單CPU處理器或者較小的應用記憶體等硬體平臺不是特別優越的場合,序列回收器的效能表現可以超過並行回收器和併發回收器。所以,序列回收預設被應用在客戶端的Client模式下的JVM中
  2. 在併發能力比較強的CPU上(多核),並行回收器產生的停頓時間要短於序列回收器

按照工作模式分,可以分為併發式垃圾回收器和獨佔式垃圾回收器。

  1. 併發式垃圾回收器與應用程式執行緒交替工作,以儘可能減少應用程式的停頓時間。
  2. 獨佔式垃圾回收器(上面兩個都是)一旦執行,就停止應用程式中的所有使用者執行緒,直到垃圾回收過程完全結束。

按碎片處理方式分,可分為壓縮式垃圾回收器和非壓縮式垃圾回收器。

  1. 壓縮式垃圾回收器會在回收完成後,對存活物件進行壓縮整理,消除回收後的碎片,分配物件空間使用指標碰撞
  2. 非壓縮式的垃圾回收器不進行這步操作,分配物件空間使用空閒列表

按工作的記憶體區間分

又可分為年輕代垃圾回收器和老年代垃圾回收器。

1.2 GC 的效能指標

評估一個 GC 回收器的效能,主要看下面幾個點

  1. 吞吐量:執行使用者程式碼的時間佔總執行時間的比例(總執行時間 = 程式的執行時間 + 記憶體回收的時間)
  2. 垃圾收集開銷:吞吐量的補數,垃圾收集所用時間與總執行時間的比例。
  3. 暫停時間:執行垃圾收集時,程式的工作執行緒被暫停的時間。
  4. 收集頻率:相對於應用程式的執行,收集操作發生的頻率。
  5. 記憶體佔用
    :Java堆區所佔的記憶體大小。
  6. 快速:一個物件從誕生到被回收所經歷的時間。

其中,吞吐量、暫停時間、記憶體佔用這三者共同構成一個“不可能三角”。三者總體的表現會隨著技術進步而越來越好。一款優秀的收集器通常最多同時滿足其中的兩項。

這三項裡,暫停時間的重要性日益凸顯。因為隨著硬體發展,記憶體佔用多些越來越能容忍,硬體效能的提升也有助於降低收集器執行時對應用程式的影響,即提高了吞吐量。但是記憶體的擴大,對延遲反而帶來負面效果。

簡單來說,主要抓住兩點:

  • 吞吐量
  • 暫停時間

吞吐量(throughput)VS 暫停時間(pause time)

吞吐量:

  1. 吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間 /(執行使用者程式碼時間+垃圾收集時間)
  2. 比如:虛擬機器總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
  3. 這種情況下,應用程式能容忍較高的暫停時間,因此,高吞吐量的應用程式有更長的時間基準,快速響應是不必考慮的
  4. 吞吐量優先,意味著在GC 的總體時間較短

暫停時間:

  1. “暫停時間”是指一個時間段內應用程式執行緒暫停,讓GC執行緒執行的狀態,例如,GC期間100毫秒的暫停時間意味著在這100毫秒期間內沒有應用程式執行緒是活動的
  2. 暫停時間優先,意味著GC停頓時間比價分散,單次延遲低

對比示意圖:

  1. 高吞吐量較好因為這會讓應用程式的終端使用者感覺只有應用程式執行緒在做“生產性”工作。巨集觀上,吞吐量越高程式執行越快。

  2. 低暫停時間(低延遲)較好,因為從終端使用者的角度來看,不管是GC還是其他原因導致一個應用被掛起始終是不好的。這取決於應用程式的型別,有時候甚至短暫的200毫秒暫停都可能打斷終端使用者體驗。因此,具有較低的暫停時間是非常重要的,特別是對於一個互動式應用程式。

  3. 不幸的是”高吞吐量”和”低暫停時間”是一對相互競爭的目標(矛盾)。

    • 因為如果選擇以吞吐量優先,那麼必然需要降低記憶體回收的執行頻率,但是這樣會導致GC需要更長的暫停時間來執行記憶體回收(單次)。

    • 相反的,如果選擇以低延遲優先為原則,那麼為了降低每次執行記憶體回收時的暫停時間,也只能頻繁地執行記憶體回收,但這又引起了頻繁切換引起的效能開銷, 導致程式吞吐量的下降。

在設計(或使用)GC演算法時,我們必須確定我們的目標:一個GC演算法只可能針對兩個目標之一(即只專注於較大吞吐量或最小暫停時間),或嘗試找到一個二者的折中。

現在的標準:在最大吞吐量優先的情況下,降低停頓時間

2. 經典垃圾回收器

2.1 垃圾回收器發展史

有了虛擬機器,就一定需要收集垃圾的機制,這就是Garbage Collection(垃圾回收),對應的產品我們稱為Garbage Collector(垃圾回收器)。 先看一下隨著 java的發展,GC的進階歷程:

  1. 1999年隨JDK1.3.1一起來的是序列方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多執行緒版本
  2. 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟隨JDK1.4.2一起釋出·
  3. Parallel GC在JDK6之後成為HotSpot預設GC。
  4. 2012年,在JDK1.7u4版本中,G1可用。
  5. 2017年,JDK9中G1變成預設的垃圾收集器,以替代CMS。
  6. 2018年3月,JDK10中G1垃圾回收器的並行完整垃圾回收,實現並行性來改善最壞情況下的延遲。
  7. 2018年9月,JDK11釋出。引入Epsilon 垃圾回收器,又被稱為 "No-Op(無操作)“ 回收器。同時,引入ZGC:可伸縮的低延遲垃圾回收器(Experimental)
  8. 2019年3月,JDK12釋出。增強G1,自動返回未用堆記憶體給作業系統。同時,引入Shenandoah GC:低停頓時間的GC(Experimental)。
  9. 2019年9月,JDK13釋出。增強ZGC,自動返回未用堆記憶體給作業系統。
  10. 2020年3月,JDK14釋出。刪除CMS垃圾回收器。擴充套件ZGC在macOS和Windows上的應用

其中最經典最常用的垃圾回收器:

  1. 序列回收器:Serial、Serial old
  2. 並行回收器:ParNew、Parallel Scavenge、Parallel old
  3. 併發回收器:CMS、G1

2.2 垃圾回收器之間的關係

上面的七種垃圾回收器, 有各自自己負責收集的區域:

  1. 新生代收集器:Serial、ParNew、Parallel Scavenge;
  2. 老年代收集器:Serial old、Parallel old、CMS;
  3. 整堆收集器:G1;

下面示意圖中,上半部分代表年輕代, 下半部分代表老年代,, 並用線條表示他們的配合關係

兩個收集器間有連線,表明它們可以搭配使用:

  • Serial/Serial old
  • Serial/CMS
  • ParNew/Serial Old
  • ParNew/CMS
  • Parallel Scavenge/Serial Old
  • Parallel Scavenge
  • Parallel Old、G1;

其中Serial Old作為CMS出現"Concurrent Mode Failure"失敗的後備預案。

移除說明:

  1. 由於維護和相容性測試的成本,在JDK 8時將Serial+CMS、ParNew+Serial Old這兩個組合宣告為廢棄過時(JEP173),並在JDK9中完全取消了這些組合的支援(JEP214),即:移除。(紅色虛線表示)
  2. JDK14中:棄用Parallel Scavenge和Serial Old GC組合(JEP366) (綠色虛線)
  3. JDK14中:刪除CMS垃圾回收器(JEP363) (青色虛線)

為什麼jvm中 實現了這麼多的GC

雖然我們會對各個收集器進行比較,但並非為了挑選一個最好的收集器出來。沒有一種放之四海皆準、任何場景下都適用的完美收集器存在,更加沒有萬能的收集器。所以我們選擇的只是對具體應用最合適的收集器。

Java的使用場景很多,移動端,伺服器等。所以就需要針對不同的場景,提供不同的垃圾收集器,提高垃圾收集的效能。

2.3 檢視正在使用的垃圾回收器

方式一:

jvm指令,-XX:+PrintCommandLineFlags 該指令 將列印jvm相關引數,包含使用的垃圾回收器

在jdk8 下,檢視:

-XX:InitialHeapSize=266620736 -XX:MaxHeapSize=4265931776 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC 

程式列印輸出如上,,最後一個指令-XX:+UseParallelGC 表示在JDK8 下,使用ParallelGC回收器 ,ParallelGC 預設和 Parallel Old 繫結使用

方式二:

使用 jps+jinfo 命令, 先打印出本機執行的java 程序.在通過程序id 檢視相關引數

3. 各個垃圾回收器具體說明

3.1 年輕代回收器: Serial 回收器(序列回收)

  1. Serial收集器是最基本、歷史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的選擇。
  2. Serial收集器是作為HotSpot中Client模式下的預設新生代垃圾收集器。
  3. Serial收集器採用複製演算法、序列回收和"Stop-the-World"機制的方式執行記憶體回收。
  4. 除了年輕代之外,Serial收集器還提供用於執行老年代垃圾收集的Serial Old收集器。Serial old收集器同樣也採用了序列回收和"Stop the World"機制,只不過記憶體回收演算法使用的是標記-壓縮演算法
  5. Serial Old是執行在Client模式下預設的老年代的垃圾回收器,但是Serial Old在Server模式下主要有兩個用途:
    • 與新生代的Parallel Scavenge配合使用
    • 作為老年代CMS收集器的後備垃圾收集方案

這個收集器是一個單執行緒的收集器,“單執行緒”的意義:

  1. 它只會使用一個CPU或者說是一條收集執行緒去完成垃圾收集工作
  2. 更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束(Stop The World)

工作示意圖:

Serial 回收器的優勢 :

  1. 優勢:簡單而高效(單CPU單核下),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。
  2. 執行在Client模式下的虛擬機器是個不錯的選擇。
  3. 在使用者的桌面應用場景中,可用記憶體一般不大(幾十MB至一兩百MB),可以在較短時間內完成垃圾收集(幾十ms至一百多ms),只要不頻繁發生,使用序列回收器是可以接受的。

如何切換到 Serial GC

在HotSpot虛擬機器中,使用-XX:+UseSerialGC引數可以指定年輕代和老年代都使用序列收集器。 及切換到

Serial GC / Serial Old GC

總結

  1. 這種垃圾收集器瞭解即可,現在已經不用序列的了。只在限定單核CPU才可以用。現在幾乎都不是單核的了。
  2. 對於互動較強的應用而言,這種垃圾收集器是不能接受的。一般在Java Web應用程式中是不會採用序列垃圾收集器的。

3.2 年輕代回收器: ParNew 回收器(並行回收)

  1. 如果說Serial GC是年輕代中的單執行緒垃圾收集器,那麼ParNew收集器則是Serial收集器的多執行緒版本。
  2. Par是Parallel的縮寫,New:只能處理新生代
  3. ParNew 收集器除了採用並行回收的方式執行記憶體回收外,兩款垃圾收集器之間幾乎沒有任何區別(其底層也共享了大量的程式碼)。ParNew收集器在年輕代中同樣也是採用複製演算法、"Stop-the-World"機制。
  4. ParNew 是很多JVM執行在Server模式下新生代的預設垃圾收集器。

在不考慮版本的情況下, ParNew 可以和 Serial Old, CMS, 這兩個回收老年代的回收器配合使用(在最新的版本中,貌似和 ParNew 搭檔的路線都被幹掉了),

下面是 ParNew / Serial Old 搭配的示意圖:

為什麼使用上面這個組合:

  1. 對於新生代,回收次數頻繁,使用並行方式高效。
  2. 對於老年代,回收次數少,使用序列方式節省資源。(CPU並行需要切換執行緒,序列可以省去切換執行緒的資源)

ParNew 回收器與 Serial 回收器效率比較

由於ParNew收集器基於並行回收,那麼是否可以斷定ParNew收集器的回收效率在任何場景下都會比Serial收集器更高效?並不能

  1. ParNew收集器執行在多CPU的環境下,由於可以充分利用多CPU、多核心等物理硬體資源優勢,可以更快速地完成垃圾收集,提升程式的吞吐量。
  2. 但是在單個CPU的環境下,ParNew收集器不比Serial收集器更高效。雖然Serial收集器是基於序列回收,但是由於CPU不需要頻繁地做任務切換,因此可以有效避免多執行緒互動過程中產生的一些額外開銷。

設定 ParNew 垃圾回收器

  1. 在程式中,開發人員可以通過選項-XX:+UseParNewGC手動指定使用ParNew收集器執行記憶體回收任務。它表示年輕代使用並行收集器,不影響老年代。
  2. -XX:ParallelGCThreads限制ParNew執行緒數量,預設開啟和CPU資料相同的執行緒數。

3.3 年輕代回收器: Parallel 回收器(並行回收,自適應調節)

HotSpot的年輕代中除了擁有ParNew收集器是基於並行回收的以外,Parallel Scavenge收集器同樣也採用了複製演算法、並行回收和"Stop the World"機制。

那麼Parallel收集器的出現是否多此一舉呢?

  • 和ParNew收集器不同,Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput),它也被稱為吞吐量優先的垃圾收集器。
  • 自適應調節策略也是Parallel Scavenge與ParNew一個重要區別。

高吞吐量則可以高效率地利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。因此,常見在伺服器環境中使用。例如,那些執行批量處理、訂單處理、工資支付、科學計算的應用程式。

對於 老年代的搭配. Parallel收集器在JDK1.6時提供了用於執行老年代垃圾收集的Parallel Old收集器,用來代替老年代的Serial Old收集器。

Parallel Old收集器採用了標記-壓縮演算法,但同樣也是基於並行回收和"Stop-the-World"機制。

Parallel / Parallel Old 工作示意圖:

  1. 在程式吞吐量優先的應用場景中,Parallel收集器和Parallel Old收集器的組合,在server模式下的記憶體回收效能很不錯。
  2. 在Java8中,預設是此垃圾收集器。

Parallel GC 的引數設定

  • -XX:+UseParallelGC 手動指定年輕代使用Parallel並行收集器執行記憶體回收任務。
  • -XX:+UseParallelOldGC:手動指定老年代都是使用並行回收收集器。

注意: 上面兩個引數分別適用於新生代和老年代。預設jdk8是開啟的。預設開啟一個,另一個也會被開啟。(互相啟用)

  • -XX:ParallelGCThreads: 手動設定 並行收集器的執行緒數。一般地,最好與CPU數量相等,以避免過多的執行緒數影響垃圾收集效能。

    • 在預設情況下,當CPU數量小於等於8個,ParallelGCThreads的值等於CPU數量。
    • 當CPU數量大於8個,ParallelGCThreads的值等於3+[5*CPU_Count]/8] ( 無需佔用太多的CPU,讓給其他程式執行)
  • -XX:MaxGCPauseMillis 設定垃圾收集器最大停頓時間(即整個STW的時間)。單位是毫秒。

  • 為了儘可能地把停頓時間控制在設定的 XX:MaxGCPauseMillis 以內,收集器在工作時會調整Java堆大小或者其他一些引數。

  • 對於使用者來講,停頓時間越短體驗越好。但是在伺服器端,我們注重高併發,整體的吞吐量。所以伺服器端適合Parallel,進行控制。

注意: 該引數使用需謹慎 , jvm會盡量的去滿足設定的時間(不一定準確實現), 而動態設定堆空間的大小,比如調小這個時間,jvm也會盡量調小堆空間記憶體,以滿足單次GC 時對時間的要求,但這將導致 GC 的頻率變高, 吞吐量也就下去了

  • -XX:GCTimeRatio垃圾收集時間佔總時間的比例,即等於 1 / (N+1) ,用於調節吞吐量的大小。

    • 取值範圍(0, 100)。預設值99,也就是垃圾回收時間佔比不超過1。
    • 與前一個-XX:MaxGCPauseMillis引數有一定矛盾性
    • STW暫停時間越長,Radio引數就容易超過設定的比例。
  • -XX:+UseAdaptiveSizePolicy 設定Parallel Scavenge收集器具有自適應調節策略,也是上面兩個引數調節有效的核心,而上面兩個引數在預設情況下是吞吐量優先的,預設開啟,

    • 在這種模式下,年輕代的大小、Eden和Survivor的比例、晉升老年代的物件年齡等引數會被自動調整,已達到在堆大小、吞吐量和停頓時間之間的平衡點。
    • 在手動調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的吞吐量(GCTimeRatio)和停頓時間(MaxGCPauseMillis),讓虛擬機器自己完成調優工作。

這裡還記得在說, 年輕代的預設比例是,說是 8:1:1 ,但是實際要差一點,就是因為這個引數預設開始調節導致的

3.4 老年代回收器: CMS 回收器(併發回收)

  1. 在JDK1.5時期,Hotspot推出了一款在強互動應用中幾乎可認為有劃時代意義的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,這款收集器是HotSpot虛擬機器中第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒同時工作。
  2. CMS收集器的關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間。停頓時間越短(低延遲)就越適合與使用者互動的程式,良好的響應速度能提升使用者體驗。(將單次GC細分,其中某些環節使用者執行緒和GC可以同時執行)
  3. 目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器就非常符合這類應用的需求。
  4. CMS的垃圾收集演算法採用標記-清除演算法,並且也會"Stop-the-World"
  5. 不幸的是,CMS作為老年代的收集器,卻無法與JDK1.4.0中已經存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Serial收集器中的一個(比較過時的兩個)。
  6. 在G1出現之前,CMS使用還是非常廣泛的。一直到今天,仍然有很多系統使用CMS GC。

CMS 工作原理示意圖

從上圖可以看出,在一次GC 中,分成多個部分, 其中有兩個環節可以同時執行, 所以整體上來看, GC的整個過程為併發執行

CMS整個過程比之前的收集器要複雜,整個過程分為4個主要階段,即初始標記階段、併發標記階段、重新標記階段和併發清除階段。(涉及STW的階段主要是:初始標記 和 重新標記)

  1. 初始標記(Initial-Mark)階段:在這個階段中,程式中所有的工作執行緒都將會因為“Stop-the-World”機制而出現短暫的暫停(因為這個階段需要枚舉出GC Roots),這個階段的主要任務僅僅只是標記出GC Roots能直接關聯到的物件(與GC Roots直接關聯的第一層)。一旦標記完成之後就會恢復之前被暫停的所有應用執行緒。由於直接關聯物件比較小,所以這裡的速度非常快。
  2. 併發標記(Concurrent-Mark)階段:從GC Roots的直接關聯物件開始遍歷整個物件圖的過程,這個過程耗時較長但是不需要停頓使用者執行緒,可以與垃圾收集執行緒一起併發執行。(因為GC Roots已經確定)
  3. 重新標記(Remark)階段:由於在併發標記階段中,程式的工作執行緒會和垃圾收集執行緒同時執行或者交叉執行,因此為了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,並且也會導致“Stop-the-World”的發生,但也遠比並發標記階段的時間短。(修正因為併發標記導致的變動,需要停止使用者執行緒,要不然豈不是再來一次)
  4. 併發清除(Concurrent-Sweep)階段:此階段清理刪除掉標記階段判斷的已經死亡的物件,釋放記憶體空間。由於不需要移動存活物件(使用的標記-清除演算法),所以這個階段也是可以與使用者執行緒同時併發的

CMS 的特點

  1. 儘管CMS收集器採用的是併發回收(非獨佔式),但是在其初始化標記和再次標記這兩個階段中仍然需要執行“Stop-the-World”機制暫停程式中的工作執行緒,不過暫停時間並不會太長,因此可以說明目前所有的垃圾收集器都做不到完全不需要“Stop-the-World”,只是儘可能地縮短暫停時間。
  2. 由於最耗費時間的併發標記與併發清除階段都不需要暫停工作,所以整體的回收是低停頓的。另外,由於在垃圾收集階段使用者執行緒沒有中斷,所以在CMS回收過程中,還應該確保應用程式使用者執行緒有足夠的記憶體可用。(在標記 清除時,使用者執行緒也在執行,若等滿了再回收,將來不及回收)
  3. 因此,CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,而是當堆記憶體使用率達到某一閾值時,便開始進行回收,以確保應用程式在CMS工作過程中依然有足夠的空間支援應用程式執行。
  4. 要是CMS執行期間預留的記憶體無法滿足程式需要,就會出現一次“Concurrent Mode Failure”
    失敗,這時虛擬機器將啟動後備預案:臨時啟用Serial old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。(真的在標記 清除階段使用者執行緒產生垃圾過快,導致還沒GC完 就 滿了,將啟動備選方案)
  5. CMS收集器的垃圾收集演算法採用的是標記清除演算法,這意味著每次執行完記憶體回收後,由於被執行記憶體回收的無用物件所佔用的記憶體空間極有可能是不連續的一些記憶體塊,不可避免地將會產生一些記憶體碎片。那麼CMS在為新物件分配記憶體空間時,將無法使用指標碰撞(Bump the Pointer)技術,而只能夠選擇空閒列表(Free List)執行記憶體分配。

為什麼 CMS 不採用標記-壓縮演算法呢?

答案其實很簡答,因為當併發清除的時候,用Compact整理記憶體的話,原來的使用者執行緒使用的記憶體還怎麼用呢?

要保證使用者執行緒能繼續執行,前提的它執行的資源不受影響,記憶體不能動,引用的物件地址也不能動。Mark Compact更適合“stop the world”這種場景下使用

CMS 的優點與缺點

優點

  1. 併發收集
  2. 低延遲

缺點

  1. 會產生記憶體碎片,導致併發清除後,使用者執行緒可用的空間不足。在無法分配大物件的情況下,不得不提前觸發Full GC。
  2. CMS收集器對CPU資源非常敏感。在併發階段,它雖然不會導致使用者停頓,但是會因為佔用了一部分執行緒而導致應用程式變慢,總吞吐量會降低。(雖然不停止使用者執行緒,但是還是會使使用者執行緒變慢)
  3. CMS收集器無法處理浮動垃圾。可能出現“Concurrent Mode Failure"失敗而導致另一次Full GC的產生。在併發標記階段由於程式的工作執行緒和垃圾收集執行緒是同時執行或者交叉執行的,那麼在併發標記階段如果產生新的垃圾物件,CMS將無法對這些垃圾物件進行標記,最終會導致這些新產生的垃圾物件沒有被及時回收,從而只能在下一次執行GC時釋放這些之前未被回收的記憶體空間。(在併發標記階段中使用者執行緒也在執行,對於此時又產生的垃圾將無法清理,只能等到下一次)

因為使用CMS GC 可能會埋下隱患,

在GC 中,因為使用者執行緒仍然執行,而導致的記憶體不足,從而導致Serial Old 備選執行,Serial old 是序列執行並且有很嚴重的STW,效率比較慢,所以將導致程式卡頓

還有因為 碎片過多,導致大物件放不下而使Full GC 提前觸發,也將導致程式停頓,所以 JDK 在後續的更新中 漸漸消除了 CMS GC 使用 G1 代替

CMS引數

-XX:+UseConcMarkSweepGC:手動指定使用CMS收集器執行記憶體回收任務

開啟該引數後會自動將-XX:+UseParNewGC開啟。即:ParNew(Young區)+CMS(Old區)+Serial Old(Old區備選方案)的組合。

-XX:+CMSInitiatingOccupanyFraction:設定堆記憶體使用率的閾值,一旦達到該閾值,便開始進行回收。

  1. JDK5及以前版本的預設值為68,即當老年代的空間使用率達到68%時,會執行一次CMS回收。JDK6及以上版本預設值為92%
  2. 如果記憶體增長緩慢,則可以設定一個稍大的值,大的閥值可以有效降低CMS的觸發頻率,減少老年代回收的次數可以較為明顯地改善應用程式效能。
  3. 反之,如果應用程式記憶體使用率增長很快,則應該降低這個閾值,以避免頻繁觸發老年代序列收集器。因此通過該選項便可以有效降低Full GC的執行次數。

-XX:+UseCMSCompactAtFullCollection:用於指定在執行完Full GC後對記憶體空間進行壓縮整理,以此避免記憶體碎片的產生。不過由於記憶體壓縮整理過程無法併發執行,所帶來的問題就是停頓時間變得更長了。 (CMS GC 會因為碎片過多,無法存放大物件而執行Full GC)

-XX:CMSFullGCsBeforeCompaction:如果上面的引數開啟, 這個指令就是設定在執行多少次Full GC後對記憶體空間進行壓縮整理。 如果設定為0,則每次Full GC 時都會進行壓縮整理

-XX:ParallelCMSThreads:設定CMS的執行緒數量。

  1. CMS預設啟動的執行緒數是 (ParallelGCThreads + 3) / 4,ParallelGCThreads是年輕代並行收集器的執行緒數,也就是與之配合的ParNew GC 並行收集器的執行緒數,絕大多數情況都是 CPU 最大支援的執行緒數
  2. 當CPU資源比較緊張時,受到CMS收集器執行緒的影響,應用程式的效能在垃圾回收階段可能會非常糟糕。

JDK 後續版本中 CMS 的變化

JDK9新特性:CMS被標記為Deprecate了(JEP291)

  • 如果對JDK9及以上版本的HotSpot虛擬機器使用引數-XX:+UseConcMarkSweepGC來開啟CMS收集器的話,使用者會收到一個警告資訊,提示CMS未來將會被廢棄。

JDK14新特性:刪除CMS垃圾回收器(JEP363)移除了CMS垃圾收集器,

  • 如果在JDK14中使用XX:+UseConcMarkSweepGC的話,JVM不會報錯,只是給出一個warning資訊,但是不會exit。JVM會自動回退以預設GC方式啟動JVM

3.5 小結

上面我們已經介紹了 年輕代,老年代的各個比較經典也比較古老的幾個垃圾回收器, 對於不同的需要,我們應該怎麼搭配呢

  1. 如果你想要最小化地使用記憶體和並行開銷,請選Serial GC/Serial Old GC;單執行緒序列執行,
  2. 如果你想要最大化應用程式的吞吐量,請選Parallel GC/ Parallel Old GC;並行回收,自適應調節,吞吐量優先
  3. 如果你想要最小化GC的中斷或停頓時間,請選CMS GC/ParNew GC。併發回收,使用者執行緒GC執行緒交替執行,低延遲

4. G1 回收器:區域化分代式

4.1 G1 回收器的概述

為什麼前面已經有了那麼多的GC ,並且在相應的場景下,都有比較好的配合使用, 還要釋出Garbage First(G1)GC?

因為時代在變化, 應用程式所應對的業務越來越龐大、複雜,使用者越來越多,沒有GC就不能保證應用程式正常進行,而經常造成STW的GC又跟不上實際的需求,所以才會不斷地嘗試對GC進行優化。 與此同時,為了適應現在不斷擴大的記憶體和不斷增加的處理器數量,進一步降低暫停時間(pause time),同時兼顧良好的吞吐量。(現在硬體條件這麼好,不去使用不浪費了嗎)

G1(Garbage-First)垃圾回收器是在Java7 update4之後引入的一個新的垃圾回收器,是當今收集器技術發展的最前沿成果之一。官方給G1設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才擔當起“全功能收集器”的重任與期望。

G1 名字的含義

  1. 因為G1是一個並行回收器,它把堆記憶體分割為很多不相關的區域(Region)(物理上不連續的)。使用若干個不同的Region就可以拼接成完整的Eden、倖存者0區,倖存者1區,老年代等。
  2. G1 GC有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的綜合考量),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region。
  3. 由於這種方式的側重點在於回收垃圾最大量的區間(Region),所以我們給G1一個名字:垃圾優先(Garbage First)。
  4. G1(Garbage-First)是一款面向服務端應用的垃圾收集器,主要針對配備多核CPU及大容量記憶體的機器,以極高概率滿足GC停頓時間的同時,還兼具高吞吐量的效能特徵。
  5. 在JDK1.7版本正式啟用,移除了Experimental的標識,是JDK9以後的預設垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old組合。被Oracle官方稱為“全功能的垃圾收集器”。
  6. 與此同時,CMS已經在JDK9中被標記為廢棄(deprecated)。G1 在JDK8中還不是預設的垃圾回收器,需要使用-XX:+UseG1GC來啟用。

4.2 G1 回收器的優勢和缺點

與其他GC收集器相比,G1使用了全新的分割槽演算法,其特點如下所示:

高吞吐與低延遲兼具 :

  • 高吞吐:G1在回收期間,可以有多個GC執行緒同時工作,有效利用多核計算能力。此時使用者執行緒STW
  • 低延遲:G1擁有與應用程式交替執行的能力,部分工作可以和應用程式同時執行,因此,一般來說,不會在整個回收階段發生完全阻塞應用程式的情況

分代收集:

  • 從分代上看,G1依然屬於分代型垃圾回收器,它會區分年輕代和老年代,針對不同的代,仍然使用不同的回收策略. 年輕代依然有Eden區和Survivor區。但從堆的結構上看,它不要求整個Eden區、年輕代或者老年代都是連續的,也不再堅持固定大小和固定數量。
  • 將堆空間分為若干個區域(Region),這些區域中包含了邏輯上的年輕代和老年代
  • 和之前的各類回收器不同,因為它將堆空間分割為不同的區域, 每個區域也都可能時而為老年代,時而為新生代,所以可以說它同時兼顧年輕代和老年代。對比其他回收器,或者工作在年輕代,或者工作在老年代;

G1的分代,已經不是下面這樣的了

而是使用如下一片區域: 這是 G1 的核心

G1的空間整合壓縮

  1. CMS使用的是 “標記-清除”演算法會產生記憶體碎片,再依靠Full GC後的壓縮排行一次碎片整理
  2. G1將記憶體劃分為一個個的region。記憶體的回收是以region作為基本單位的。Region之間是複製演算法,所以整體上實際可看作是標記-壓縮(Mark-Compact)演算法,兩種演算法都可以避免記憶體碎片。
  3. 這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC。尤其是當Java堆非常大的時候,G1的優勢更加明顯。

可預測的停頓時間模型:

這是G1相對於CMS的另一大優勢,G1除了追求低延遲外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

  1. 由於分割槽的原因,G1可以只選取部分割槽域進行記憶體回收,這樣縮小了回收的範圍,因此對於全域性停頓情況的發生也能得到較好的控制。
  2. G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的綜合考量),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region。保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。
  3. 相比於CMS GC,G1未必能做到CMS在最好情況下的延時停頓,但是最差情況要好很多。

G1 回收器的缺點:

  1. 相較於CMS,G1還不具備全方位、壓倒性優勢。比如在使用者程式執行過程中,G1無論是為了垃圾收集產生的記憶體佔用(Footprint)還是程式執行時的額外執行負載(overload)都要比CMS要高。
  2. 從總體來說,在小記憶體應用上CMS的表現大概率會優於G1,而G1在大記憶體應用上則發揮其優勢。平衡點在6-8GB之間。

4.3 G1 回收器的常用引數

-XX:+UseG1GC:手動指定使用G1垃圾收集器執行記憶體回收任務,JDK9 之後預設就是

-XX:G1HeapRegionSize:設定每個Region的大小。值是2的冪,範圍是1MB到32MB之間(也就是可以設定為1,2,4,8,16,32),預設是根據最小的Java堆大小劃分出約2048個區域。也就是堆記憶體的1/2000。例如堆記憶體為2G,則預設為1, 2048/2048

-XX:MaxGCPauseMillis:設定期望達到的最大GC停頓時間指標JVM會盡力實現,但不保證達到。預設值是200ms

-XX:+ParallelGCThread:設定G1回收器STW並行階段時工作執行緒數的值。最多設定為8

-XX:ConcGCThreads:設定併發標記的執行緒數。將n設定為並行垃圾回收執行緒數(ParallelGcThreads)的1/4左右。

-XX:InitiatingHeapOccupancyPercent:設定觸發併發GC週期的Java堆佔用率閾值。超過此值,就觸發GC。預設值是45。

注意:

G1的設計原則就是為了簡化JVM效能調優,開發人員只需要簡單的三步即可完成調優:

  1. 第一步:開啟G1垃圾收集器
  2. 第二步:設定堆的最大記憶體
  3. 第三步:設定最大的停頓時間

G1中提供了三種垃圾回收模式:YoungGC、Mixed GC和Full GC 在不同的條件下被觸發。

4.4 G1 的適用場景

  1. 面向服務端應用,針對具有大記憶體、多處理器的機器。(在普通大小的堆裡表現並不驚喜)
  2. 最主要的應用是需要低GC延遲,並具有大堆的應用程式提供解決方案;
  3. 如:在堆大小約6GB或更大時,可預測的暫停時間可以低於0.5秒;(G1通過每次只清理一部分而不是全部的Region的增量式清理來保證每次GC停頓時間不會過長)。
  4. 用來替換掉JDK1.5中的CMS收集器;在下面的情況時,使用G1可能比CMS好:
    • 超過50%的Java堆被活動資料佔用;
    • 物件分配頻率或年代提升頻率變化很大;
    • GC停頓時間過長(長於0.5至1秒)
  5. HotSpot垃圾收集器裡,除了G1以外,其他的垃圾收集器均使用內建的JVM GC執行緒執行GC的多執行緒操作,而G1 GC可以採用應用執行緒承擔後臺執行的GC工作,即當JVM的GC執行緒處理速度慢時,系統會呼叫應用程式執行緒幫助加速垃圾回收過程。

4.5 分割槽 Region

  1. 使用G1收集器時,它將整個Java堆劃分成約2048個大小相同的獨立Region塊,每個Region塊大小根據堆空間的實際大小而定,整體被控制在1MB到32MB之間,且為2的N次冪,即1MB,2MB,4MB,8MB,16MB,32MB。可以通過XX:G1HeapRegionSize設定。所有的Region大小相同,且在JVM生命週期內不會被改變。
  2. 雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。通過Region的動態分配方式實現邏輯上的連續。
  3. 一個Region有可能屬於Eden,Survivor或者Old/Tenured記憶體區域。(當一個Region被回收乾淨時,會放入空閒列表維護,若下次取用時,它代表的角色隨意)但是一個Region在同一時刻只可能屬於一個角色。圖中的E表示該Region屬於Eden記憶體區域,S表示屬於Survivor記憶體區域,O表示屬於Old記憶體區域。圖中空白的表示未使用的記憶體空間。
  4. G1垃圾收集器還增加了一種新的記憶體區域,叫做Humongous記憶體區域,如圖中的H塊。主要用於儲存大物件,如果超過1.5個Region,就放到H。若一個物件在一個Region放不下時,若大小沒有超過1.5倍的Region,則可以使用兩個並列的Eden或Old區等去儲存,若超過1.5倍,則需要使用 G1中專有的 humongous 區儲存大物件,若一個humongous 也存不下,則可以使用多個humongous並列儲存

設定 H 的原因

  1. 對於堆中的大物件,預設直接會被分配到老年代,但是如果它是一個短期存在的大物件就會對垃圾收集器造成負面影響。
  2. 為了解決這個問題,G1劃分了一個Humongous區,它用來專門存放大物件。
  3. 如果一個H區裝不下一個大物件,那麼G1會尋找連續的H區來儲存。為了能找到連續的H區,有時候不得不啟動Full GC。G1的大多數行為都把H區作為老年代的一部分來看待。

Regio的內部使用的記憶體分配演算法

  1. 每個Region都是通過指標碰撞來分配空間
  2. 每個Region都有TLAB(執行緒獨佔記憶體),提高物件分配的效率

4.6 G1 回收器的主要環節

G1 GC的垃圾回收過程主要包括如下三個環節:

  • 年輕代GC(Young GC)
  • 老年代併發標記過程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • 如果需要,單執行緒、獨佔式、高強度的Full GC還是繼續存在的。它針對GC的評估失敗提供了一種失敗保護機制,即強力回收.

Young GC --> Young GC+Concurrent Marking --> Mixed GC順序,進行垃圾回收

大致的回收流程

  1. 應用程式分配記憶體,當年輕代的Eden區用盡時開始年輕代回收過程;G1的年輕代收集階段是一個並行的獨佔式收集器(STW)。
  2. 在年輕代回收期,G1 GC暫停所有應用程式執行緒,啟動多執行緒執行年輕代回收。然後從年輕代區間移動存活物件到Survivor區間或者老年區間,也有可能是兩個區間都會涉及。
  3. 當堆記憶體使用達到一定值(預設45%)時,開始老年代併發標記過程。標記完成馬上開始混合回收過程。
  4. 對於一個混合回收期,G1 GC從老年區間移動存活物件到空閒區間,這些空閒區間也就成為了老年代的一部分。
  5. 老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整個老年代被回收,一次只需要掃描/回收一小部分老年代的Region就可以了。同時,這個老年代Region是和年輕代一起被回收的。

舉個例子:一個Web伺服器,Java程序最大堆記憶體為4G,每分鐘響應1500個請求,每45秒鐘會新分配大約2G的記憶體。G1會每45秒鐘進行一次年輕代回收, 每31個小時整個堆的使用率會達到45%(當老年代的空間也不足時),會開始老年代併發標記過程,標記完成後開始四到五次的混合回收。

4.7 Remember Set 記憶集

根據前面所說的 G1 的回收特性, 其實存在一些問題.例如

  • 一個物件被不同區域引用的問題 ,一個Region不可能是孤立的,一個Region中的物件可能被其他任意Region中物件引用,判斷物件存活時,是否需要掃描整個Java堆才能保證準確? (在其他的分代收集器,也存在這樣的問題, 而G1更突出,因為G1主要針對大堆)
  • 回收新生代也不得不同時掃描老年代(列舉GC Roots,要檢查新生代之外,包括老年代)?這樣的話會降低Minor GC的效率

解決方法:

  1. 無論G1還是其他分代收集器,JVM都是使用Remembered Set來避免全域性掃描;
  2. 每個Region都有一個對應的Remembered Set,每個 RSet 都記錄著本 Region 中物件被其他區域引用的記錄,若此時需要回收此Region,則不用掃描全部其他Region,只需檢視RSet 中的記錄即可
  3. 每次Reference型別資料寫操作時,都會產生一個Write Barrier(寫屏障)暫時中斷操作;然後檢查將要寫入的物件是否和該物件引用的物件(Reference型別)資料在不同的Region(其他收集器:檢查老年代物件是否引用了新生代物件);
  4. 如果不同,通過CardTable把相關引用資訊記錄到引用指向物件(被指向的物件)的所在Region對應的Remembered Set中;(如果相同 則無需此操作,因為都在一個區域了,掃描本區域時,就已經掃描出來了)
  5. 當進行垃圾收集時,在GC根節點的列舉範圍加入Remembered Set;就可以保證不進行全域性掃描,也不會有遺漏。

總結

  1. 在回收 Region 時,為了不進行全堆的掃描,引入了 Remembered Set
  2. Remembered Set 記錄了當前 Region 中的物件被哪個物件引用了
  3. 這樣在進行 Region 複製時,就不要掃描整個堆,只需要去 Remembered Set 裡面找到引用了當前 Region 的物件
  4. Region 複製完畢後,修改 Remembered Set 中物件的引用即可

4.8 G1 回收每個階段的具體說明

前面說到,G1 回收共可分成三個階段,即 年輕代回收, 併發標記階段,混合回收,下面進行具體說明

年輕代回收

  1. JVM啟動時,G1先準備好Eden區,程式在執行過程中不斷建立物件到Eden區,當Eden空間耗盡時,G1會啟動一次年輕代垃圾回收過程。
  2. YGC時,首先G1停止應用程式的執行(Stop-The-World),G1建立回收集(Collection Set),回收集是指需要被回收的記憶體分段的集合,年輕代回收過程的回收集包含年輕代Eden區和Survivor區所有的記憶體分段(年輕代是所有的Region)。

如下 示意圖, 和普通的GC 類似,再回收年輕代時, 將會把Eden和 Survivor from區中判斷存活的物件存放拷貝到 Survivor to區, 即圖中的空白區域

整個回收年輕代,共可分成如下幾個部分:

  1. 第一階段,掃描根 (GC Roots)
    • 根是指static變數指向的物件或正在執行的方法呼叫鏈條上的區域性變數等包括根引用連同RSet記錄的外部引用作為掃描存活物件的入口。
  2. 第二階段,更新RSet
    • 處理dirty card queue中的card,更新RSet。前面說到, 在分配物件到一個Region時,會將此物件引用的物件所在的Region 所對應的 RSet 中新增 引用相關資訊, 但為了分配記憶體時的效率,這個操作不是同步的, 而是將記錄加入到dirty card queue中, 只要在回收此Region 時,需要列舉 GC Roots時,才會將此佇列中的資訊同步到 對應的 RSet中, 再進行判斷
    • 此階段完成後,RSet可以準確的反映老年代對所在的記憶體分段中物件的引用。
  3. 第三階段,處理RSet
    • 識別被老年代物件指向的Eden中的物件(年輕代外的地方,回收年輕代時,堆中只有老年代配當GC Roots),這些被指向的Eden中的物件被認為是存活的物件。
  4. 第四階段,複製物件。
    • 此階段,物件樹被遍歷,Eden區記憶體段中存活的物件會被複制到Survivor區中空的記憶體分段,Survivor區記憶體段中存活的物件會根據年齡不同判斷
    • 如果年齡未達閾值,年齡會加1,達到閥值會被會被複制到Old區中空的記憶體分段。
    • 如果Survivor空間不夠,Eden空間的部分資料會直接晉升到老年代空間。
  5. 第五階段,處理引用
    • 處理Soft,Weak,Phantom,Final,JNI Weak 等引用。最終Eden空間的資料為空,GC停止工作,而目標記憶體中的物件都是連續儲存的,沒有碎片,所以複製過程可以達到記憶體整理的效果,減少碎片。

併發標記

在回收老年代的 混合回收前,會進行併發標記階段,共可分成如下幾步

  1. 初始標記階段:
    • 標記從根節點直接可達的物件。這個階段是STW的,並且會觸發一次年輕代GC。
    • 正是由於該階段時STW的,所以我們只掃描根節點可達的物件,以節省時間。
  2. 根區域掃描(Root Region Scanning):
    • G1 GC掃描Survivor區直接可達的老年代區域物件,並標記被引用的物件。
    • 這一過程必須在Young GC之前完成,因為Young GC會使用複製演算法對Survivor區進行GC。
  3. 併發標記(Concurrent Marking):
    1. 在整個堆中進行併發標記(和應用程式併發執行),此過程可能被Young GC中斷。
    2. 在併發標記階段,若發現區域物件中的所有物件都是垃圾,那這個區域會被立即回收。
    3. 同時,併發標記過程中,會計算每個區域的物件活性(區域中存活物件的比例)。
  4. 再次標記(Remark):
    • 由於應用程式持續進行,需要修正上一次的標記結果。是STW的。
    • G1中採用了比CMS更快的初始快照演算法:Snapshot-At-The-Beginning(SATB)。
  5. 獨佔清理(cleanup,STW):
    • 計算各個區域的存活物件和GC回收比例,並進行排序,識別可以混合回收的區域。
    • 為下階 鋪墊。是STW的。這個階段並不會實際上去做垃圾的收集
  6. 併發清理階段:
    • 識別並清理完全空閒的區域。

混合回收

當越來越多的物件晉升到老年代Old Region時,為了避免堆記憶體被耗盡,虛擬機器會觸發一個混合的垃圾收集器,即Mixed GC,該演算法並不是一個Old GC,除了回收整個Young Region,還會回收一部分的Old Region。

這裡需要注意:是一部分老年代,而不是全部老年代。可以選擇價值較高的Old Region進行收集,從而可以對垃圾回收的耗時時間進行控制。也要注意的是Mixed GC並不是Full GC。

混合回收的細節

  1. 併發標記結束以後,老年代中百分百為垃圾的記憶體分段被回收了,部分為垃圾的記憶體分段被計算了出來。
  2. 預設情況下,這些老年代的記憶體分段會分8次(可以通過-XX:G1MixedGCCountTarget設定,8個執行緒)被回收
  3. 混合回收的回收集(Collection Set)包括八分之一的老年代記憶體分段,Eden區記憶體分段,Survivor區記憶體分段。混合回收的演算法和年輕代回收的演算法完全一樣,只是回收集多了老年代的記憶體分段。具體過程請參考上面的年輕代回收過程。
  4. 由於老年代中的記憶體分段預設分8次回收,G1會優先回收垃圾多的記憶體分段。垃圾佔記憶體分段比例越高的,越會被先回收。並且有一個閾值會決定記憶體分段是否被回收。
  5. XX:G1MixedGCLiveThresholdPercent,預設為65%,意思是垃圾佔記憶體分段比例要達到65%才會被回收。如果垃圾佔比太低,意味著存活的物件佔比高,在複製的時候會花費更多的時間。
  6. 混合回收並不一定要進行8次。有一個閾值-XX:G1HeapWastePercent,預設值為10%,意思是允許整個堆記憶體中有10%的空間被浪費,意味著如果發現可以回收的垃圾佔堆記憶體的比例低於10%,則不再進行混合回收。因為GC會花費很多的時間但是回收到的記憶體卻很少。

G1 回收器可能的過程四 Full GC

G1的初衷就是要避免Full GC的出現。但是如果上述方式不能正常工作,G1會停止應用程式的執行(Stop-The-World),使用單執行緒的記憶體回收演算法進行垃圾回收,效能會非常差,應用程式停頓時間會很長。

要避免Full GC的發生,一旦發生Full GC,需要對JVM引數進行調整。什麼時候會發生Ful1GC呢?比如堆記憶體太小,當G1在複製存活物件的時候沒有空的記憶體分段可用,則會回退到Full GC,這種情況可以通過增大記憶體解決。

導致G1 Full GC的原因可能有兩個:

  1. 沒有足夠的空間來存放晉升的物件;
  2. 併發處理過程完成之前空間耗盡。

4.9 補充說明

從Oracle官方透露出來的資訊可獲知,回收階段(Evacuation)其實本也有想過設計成與使用者程式一起併發執行,但這件事情做起來比較複雜,考慮到G1只是回一部分Region,停頓時間是使用者可控制的,所以並不迫切去實現,而選擇把這個特性放到了G1之後出現的低延遲垃圾收集器(即ZGC)中。

另外,還考慮到G1不是僅僅面向低延遲,停頓使用者執行緒能夠最大幅度提高垃圾收集效率,為了保證吞吐量所以才選擇了完全暫停使用者執行緒的實現方案。

G1 回收器的優化建議

年輕代大小

  • 避免使用-Xmn-XX:NewRatio等相關選項顯式設定年輕代大小,讓G1自己調節
  • 固定年輕代的大小會覆蓋暫停時間目標

暫停時間目標不要太過嚴苛

  • G1 GC的吞吐量目標是90%的應用程式時間和10%的垃圾回收時間
  • 評估G1 GC的吞吐量時,暫停時間目標不要太嚴苛。目標太過嚴苛表示你願意承受更多的垃圾回收開銷,而這些會直接影響到吞吐量。頻繁回收

5. 垃圾回收器的總結

截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特點,在具體使用的時候,需要根據具體的情況選用不同的垃圾收集器。

GC發展階段:Seria l=> Parallel(並行)=> CMS(併發)=> G1 => ZGC

不同廠商、不同版本的虛擬機器實現差距比較大。HotSpot虛擬機器在JDK7/8後所有收集器及組合如下圖(更新至JDK14)

Java垃圾收集器的配置對於JVM優化來說是一個很重要的選擇,選擇合適的垃圾收集器可以讓JVM的效能有一個很大的提升。怎麼選擇垃圾收集器?

  1. 優先調整堆的大小讓JVM自適應完成。
  2. 如果記憶體小於100M,使用序列收集器
  3. 如果是單核、單機程式,並且沒有停頓時間的要求,序列收集器
  4. 如果是多CPU、需要高吞吐量、允許停頓時間超過1秒,選擇並行或者JVM自己選擇
  5. 如果是多CPU、追求低停頓時間,需快速響應(比如延遲不能超過1秒,如網際網路應用),使用併發收集器
  6. 官方推薦G1,效能高。現在網際網路的專案,基本都是使用G1。

最後需要明確一個觀點:

  1. 沒有最好的收集器,更沒有萬能的收集演算法
  2. 調優永遠是針對特定場景、特定需求,不存在一勞永逸的收集器

6. GC 日誌分析

通過閱讀GC日誌,我們可以瞭解Java虛擬機器記憶體分配與回收策略。

記憶體分配與垃圾回收的引數列表

  1. -XX:+PrintGC :輸出GC日誌。類似:-verbose:gc
  2. -XX:+PrintGCDetails :輸出GC的詳細日誌
  3. -XX:+PrintGCTimestamps :輸出GC的時間戳(以基準時間的形式)
  4. -XX:+PrintGCDatestamps :輸出GC的時間戳(以日期的形式,如2013-05-04T21: 53: 59.234 +0800)
  5. -XX:+PrintHeapAtGC :在進行GC的前後打印出堆的資訊
  6. -Xloggc:…/logs/gc.log :日誌檔案的輸出路徑

6.1 常用引數

verbose:gc

JVM 引數:-verbose:gc

列印日誌:

[GC (Allocation Failure)  15270K->14114K(58880K), 0.0698402 secs]
[GC (Allocation Failure)  29416K->29372K(58880K), 0.0081180 secs]
[Full GC (Ergonomics)  29372K->29248K(58880K), 0.0096768 secs]
[Full GC (Ergonomics)  44546K->44151K(58880K), 0.0075181 secs]

引數解析:

GC, Full GC : GC 的型別, GC只在堆上進行,Full GC 包括方法區

Allocation Failure : GC 發生的原因

15270K->14114K : 堆在GC 前的大小 和GC 後的大小

58880K : 現在堆的空間大小

0.0698402 secs : GC 持續的時間.

PrintGCDetails

JVM 引數: -XX:+PrintGCDetails

列印日誌:

[GC (Allocation Failure) [PSYoungGen: 15270K->2548K(17920K)] 15270K->14146K(58880K), 0.0509164 secs] [Times: user=0.00 sys=0.00, real=0.05 secs] 
[GC (Allocation Failure) [PSYoungGen: 17850K->2516K(17920K)] 29448K->29316K(58880K), 0.0077901 secs] [Times: user=0.00 sys=0.03, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 2516K->0K(17920K)] [ParOldGen: 26800K->29248K(40960K)] 29316K->29248K(58880K), [Metaspace: 3440K->3440K(1056768K)], 0.0082566 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 15298K->3400K(17920K)] [ParOldGen: 29248K->40750K(40960K)] 44546K->44151K(58880K), [Metaspace: 3440K->3440K(1056768K)], 0.0066315 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

引數解析:

GC, Full GC : GC 的型別

Allocation Failure : GC 發生的原因

PSYoungGen : 使用了Parallel 垃圾回收器的新生代GC 前後大小的變化

ParOldGen: 使用了 Parallel Old 垃圾回收器 回收老年代前後空間的變化

Metaspace : 元資料區GC 前後大小的變化,

0.0509164 secs : GC話費的時間

Times:

  • user : 指的是垃圾回收器花費的所有CPU時間,
  • sys: 花費在等待系統呼叫或系統事件的時間
  • real: GC從開始到結束的時間,包括其他程序佔用時間片的實際時間

圖例:

下圖是一次GC列印的日誌對應的資訊

下圖是 一次Full GC 列印的日誌對應的資訊

PrintGCTimestamps 和 PrintGCDatestamps

JVM引數: -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps

列印日誌:

2020-11-29T21:42:44.039+0800: 7.018: [GC (Allocation Failure) [PSYoungGen: 15270K->2548K(17920K)] 15270K->14114K(58880K), 0.0407744 secs] [Times: user=0.00 sys=0.00, real=0.04 secs] 
2020-11-29T21:42:51.803+0800: 14.746: [GC (Allocation Failure) [PSYoungGen: 17850K->2536K(17920K)] 29416K->29340K(58880K), 0.0048071 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
2020-11-29T21:42:51.808+0800: 14.751: [Full GC (Ergonomics) [PSYoungGen: 2536K->0K(17920K)] [ParOldGen: 26804K->29248K(40960K)] 29340K->29248K(58880K), [Metaspace: 3440K->3440K(1056768K)], 0.0080173 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
2020-11-29T21:42:59.420+0800: 22.363: [Full GC (Ergonomics) [PSYoungGen: 15298K->3400K(17920K)] [ParOldGen: 29248K->40750K(40960K)] 44546K->44151K(58880K), [Metaspace: 3440K->3440K(1056768K)], 0.0065238 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

就是在GC 前面加上了 日期時間 和 距離虛擬機器啟動時的時間

儲存日誌檔案

JVM引數: -XLoggc:./Logs/gc.Log

./ 表示當前目錄,在 IDEA中程式執行的當前目錄是工程的根目錄,而不是模組的根目錄

補充說明:

  1. “[GC"和”[Full GC"說明了這次垃圾收集的停頓型別,如果有"Full"則說明GC發生了"Stop The World"
  2. 使用Serial收集器在新生代的名字是Default New Generation,因此顯示的是"[DefNew"
  3. 使用ParNew收集器在新生代的名字會變成"[ParNew",意思是"Parallel New Generation"
  4. 使用Parallel scavenge收集器在新生代的名字是”[PSYoungGen"
  5. 老年代的收集和新生代道理一樣,名字也是收集器決定的
  6. 使用G1收集器的話,會顯示為"garbage-first heap"
  7. Allocation Failure表明本次引起GC的原因是因為在年輕代中沒有足夠的空間能夠儲存新的資料了。
  8. [ PSYoungGen: 5986K->696K(8704K) ] 5986K->704K (9216K)
    • 中括號內:GC回收前年輕代大小,回收後大小,(年輕代總大小)
    • 括號外:GC回收前年輕代和老年代大小,回收後大小,(年輕代和老年代總大小)
  9. user代表使用者態回收耗時,sys核心態回收耗時,real實際耗時。由於多核執行緒切換的原因,時間總和可能會超過real時間

6.2 堆空間的使用情況

程式碼:

public class GCLogTest1 {
    private static final int _1MB = 1024 * 1024;

    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];
    }

    public static void main(String[] agrs) {
        testAllocation();
    }
}

JVM引數: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC

其中 -XX:+PrintGCDetails 除了列印 GC 日誌資訊外,還將列印 Heap的記憶體佔用情況,

JDK 7:

在jdk7 中,當發現最後分配4M 的資料放不下 Eden區時,將觸發年輕代GC, 然後將三個 2M 的陣列放到了 老年代,

所以如圖所示, 年輕代佔用 4867k,4M記憶體,老年代佔用6144K ,6M記憶體

示意圖:

JKD8:

如圖所示,在JDK 8 中,很明顯 最後分配的 4M大物件直接放到了老年代

6.3 日誌分析工具

在實際生產環境中,GC日誌資訊可能非常龐大,可以使用命令匯出到檔案中, 再用一些工具去分析這些GC日誌,常用的日誌分析工具有:

GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等

這裡主要推薦 GCViewer、GCEasy,

GCViewer: 網上下載jar包 雙擊開啟即可

GCeasy: 線上分析 https://gceasy.io/

7. 垃圾回收器的新發展

回首過去

  1. GC仍然處於飛速發展之中,目前的預設選項G1 GC在不斷的進行改進,很多我們原來認為的缺點,例如序列的Full GC、Card Table掃描的低效等,都已經被大幅改進,例如,JDK10以後,Fu11GC已經是並行執行,在很多場景下,其表現還略優於ParallelGC的並行Ful1GC實現。
  2. 即使是SerialGC,雖然比較古老,但是簡單的設計和實現未必就是過時的,它本身的開銷,不管是GC相關資料結構的開銷,還是執行緒的開銷,都是非常小的,所以隨著雲端計算的興起,在serverless等新的應用場景下,Serial Gc找到了新的舞臺。
  3. 比較不幸的是CMS GC,因為其演算法的理論缺陷等原因,雖然現在還有非常大的使用者群體,但在JDK9中已經被標記為廢棄,並在JDK14版本中移除

展望未來

  1. Epsilon:A No-Op GarbageCollector(Epsilon垃圾回收器,"No-Op(無操作)"回收器),只執行分配,不執行回收,http://openidk.iava.net/ieps/318
  2. ZGC:A Scalable Low-Latency Garbage Collector(Experimental)(ZGC:可伸縮的低延遲垃圾回收器,處於實驗性階段)
  3. 現在G1回收器已成為預設回收器好幾年了。我們還看到了引入了兩個新的收集器:ZGC(JDK11出現)和Shenandoah(Open JDK12),其特點:主打低停頓時間

7.1 Shenandoah GC

Open JDK12的Shenandoash GC:低停頓時間的GC(實驗性)

  1. Shenandoah無疑是眾多GC中最孤獨的一個。是第一款不由Oracle公司團隊領導開發的Hotspot垃圾收集器。不可避免的受到官方的排擠。比如號稱openJDK和OracleJDK沒有區別的Oracle公司仍拒絕在OracleJDK12中支援Shenandoah。
  2. Shenandoah垃圾回收器最初由RedHat進行的一項垃圾收集器研究專案Pauseless GC的實現,旨在針對JVM上的記憶體回收實現低停頓的需求。在2014年貢獻給OpenJDK。
  3. Red Hat研發Shenandoah團隊對外宣稱,Shenandoah垃圾回收器的暫停時間與堆大小無關,這意味著無論將堆設定為200MB還是200GB,99.9%的目標都可以把垃圾收集的停頓時間限制在十毫秒以內。不過實際使用效能將取決於實際工作堆的大小和工作負載。

這是RedHat在2016年發表的論文資料,測試內容是使用ES對200GB的維基百科資料進行索引。從結果看:

  1. 停頓時間比其他幾款收集器確實有了質的飛躍,但也未實現最大停頓時間控制在十毫秒以內的目標。
  2. 而吞吐量方面出現了明顯的下降,總執行時間是所有測試收集器裡最長的。

總結

  1. Shenandoah GC的弱項:高執行負擔下的吞吐量下降。
  2. Shenandoah GC的強項:低延遲時間。

7.2 次時代 ZGC

  1. 官方文件:https://docs.oracle.com/en/java/javase/12/gctuning/
  2. ZGC與Shenandoah目標高度相似,在儘可能對吞吐量影響不大的前提下,實現在任意堆記憶體大小下都可以把垃圾收集的停頗時間限制在十毫秒以內的低延遲。
  3. 《深入理解Java虛擬機器》一書中這樣定義ZGC:ZGC收集器是一款基於Region記憶體佈局的,(暫時)不設分代的,使用了讀屏障、染色指標和記憶體多重對映等技術來實現可併發的標記-壓縮演算法的,以低延遲為首要目標的一款垃圾收集器。
  4. ZGC的工作過程可以分為4個階段:併發標記 - 併發預備重分配 - 併發重分配 - 併發重對映 等。
  5. ZGC幾乎在所有地方併發執行的,除了初始標記的是STW的。所以停頓時間幾乎就耗費在初始標記上,這部分的實際時間是非常少的。

下面是官方對比其他GC的資料

吞吐量

暫停時間

未來將在服務端、大記憶體、低延遲應用的首選垃圾收集器。

JDK14 新特性

  1. JDK14之前,ZGC僅Linux才支援。
  2. 儘管許多使用ZGC的使用者都使用類Linux的環境,但在Windows和macOS上,人們也需要ZGC進行開發部署和測試。許多桌面應用也可以從ZGC中受益。因此,ZGC特性被移植到了Windows和macOS上。
  3. 現在mac或Windows上也能使用ZGC了,示例如下:
-XX:+UnlockExperimentalVMOptions-XX:+UseZGC

7.3 其他廠商的GC

例如AliGC:

AliGC是阿里巴巴JVM團隊基於G1演算法,面向大堆(LargeHeap)應用場景。指定場景下的對比:

還有比較有名的低延遲GC : Zing, 有興趣的可以自己瞭解一下