讀書筆記——《深入理解Java虛擬機》系列之垃圾收集器與GC日誌分析

上一篇博客中,博主和大家一起學了幾種常見的垃圾收集算法。我們也知道了分代收集法是目前虛擬機中常用的收集算法。

收集算法可以被看作內存回收問題的理論基礎,而不同的垃圾收集器就是內存回收的具體實現了。由於在Java 虛擬機規範中並沒有規定需要如何實現垃圾收集器,因此各個廠家或者不同版本的虛擬機所提供的垃圾收集器都有可能有很大的不同。

下圖是HotSpot虛擬機所提供的適用於不同年代的垃圾收集器,兩個收集器之間存在的連線代表着它們可以搭配使用:

6d8b0a6296b44dceb992b715e935e87c.jpg

在爲大家介紹各個收集器之前博主先爲大家介紹一下JVM兩種工作模式:

  • client模式:JVM在client模式下進行工作時,啓動應用程序較快,但是在內存管理,內存回收優化方面都不如server模式,因此它比較適合我們啓動運行一些較小規模的測試程序。
  • server模式:JVM在server模式下進行工作時,啓動應用程序較慢,但是隨着程序運行一段時間後,程序運行的性能將遠遠高於client模式,因此當我們需要提供一些穩定服務時,我們應該將JVM設置爲server模式。

1. Serial 收集器和Serial Old收集器

Serial垃圾收集器是一款最基本的單線程收集器,它不僅只有一條線程進行工作,而且當它工作時必須暫停其他所有的工作線程(stop the world)。因此這種垃圾收集器儘管簡單高效,但是隻適合用在client模式上(它也是虛擬機運行在client模式下默認的新生代垃圾收集器)。

Serial垃圾收集器是針對新生代內存回收時使用的垃圾收集器,使用算法是複製算法;而Serial Old收集器是針對老年代內存回收時使用的垃圾收集器,使用算法是標記-整理算法,這兩種垃圾收集器都需要在工作時暫停其它所有的用戶線程,如下圖所示:

91348b916df64d568e864acb25f662ac-serial.jpg

下面我首先給大家看一個簡單的例子來幫助大家理解Serial GC的工作原理:

  • 首先我們寫一個空程序:
public class SerialGC{
    public static void main(String[] args){

    }
}
  • 以下是運行這個程序的參數
java -Xmx20m -Xms20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails SerialGC

-Xmx 和-Xms 用來限制最大堆大小和最小堆大小,同時保證堆內存不會自動擴張
-Xmn 用來聲明堆內存中新生代的大小,在這裏是10m
-XX:+UseSerialGC 表示使用Serial垃圾收集器
-XX:+PrintGCDetails 表示輸出詳細的GC信息

  • 使用這些參數去運行我們上邊的程序,我們可以得到以下的信息:
Heap
 def new generation   total 9216K, used 1016K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  12% used [0x00000000fec00000, 0x00000000fecfe090, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 2597K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

我在這裏給大家簡單的解釋一下上面的輸出信息:

def new generation total 9216K, used 1016K ->新生代 總共9M(9*1024K=9216),爲什麼是9M呢?因爲我們通過-Xmn總共爲新生代分配了10M的空間,新生代中eden,from,to的默認比例是8:1:1(除非我們更改比例),因此任意時刻可用的新生代都只有90%,在這裏就是9M了。至於爲什麼,一個空的程序裏面會默認佔1M的堆內存,博主也還沒有想到,歡迎大家與我一起討論,我目前想的是程序中的字符串常量被放在了堆中,但是應該也不至於有1M纔對。。。

eden space 8192K, 12% used [0x00000000fec00000, 0x00000000fecfe090, 0x00000000ff400000) ->eden 8M
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000) ->from 1M
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000) ->to 1M

tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000) ->老年代10M
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)

Metaspace used 2597K, capacity 4486K, committed 4864K, reserved 1056768K ->Java 8廢棄永久代後,出現的元空間,它使用的計算機本地內存
class space used 275K, capacity 386K, committed 512K, reserved 1048576K

  • 現在我們修改一下剛纔的程序:
public class SerialGC{
    public static void main(String[] args){
        int m = 1024*1024;
        byte[] b = new byte[7*m];
    }
}
  • 再以剛纔的參數運行我們新的程序:
Heap
 def new generation   total 9216K, used 8184K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  99% used [0x00000000fec00000, 0x00000000ff3fe0a0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 2598K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

正如我剛纔所說到的,我們的虛擬機新生代總內存只有9M,Eden區只有8M,我在程序中new了1個7M大小的數組對象,再加上我們上面提到的堆中原本就存在的1016K,總共是8184K佔了Eden區的99%。我們都知道Eden區是爲新生對象分配內存空間的區域,接下來我們就要再次修改我們的程序,再創建一個1M大小的數組對象,看看GC信息會有什麼變化:

public class SerialGC{
    public static void main(String[] args){
        int m = 1024*1024;
        byte[] b = new byte[7*m];
        byte[] b2 = new byte[1*m];
    }
}
  • 新的GC信息如下:
[GC (Allocation Failure) [DefNew: 8019K->536K(9216K), 0.0112137 secs] 8019K->7704K(19456K), 0.0112710 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Heap
 def new generation   total 9216K, used 1642K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  13% used [0x00000000fec00000, 0x00000000fed14930, 0x00000000ff400000)
  from space 1024K,  52% used [0x00000000ff500000, 0x00000000ff586060, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 7168K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  70% used [0x00000000ff600000, 0x00000000ffd00010, 0x00000000ffd00200, 0x0000000100000000)
 Metaspace       used 2599K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 275K, capacity 386K, committed 512K, reserved 1048576K

在新的GC信息中我們可以看出,我們修改後的程序在運行時在新生代發生了一次Minor GC,現在我就來爲大家解釋一下這些信息:
[GC (Allocation Failure) [DefNew: 8019K->536K(9216K), 0.0112137 secs] 8019K->7704K(19456K), 0.0112710 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
DefNew: 是垃圾收集器的名稱,我們這個例子裏是單線程(single-threaded), 採用標記複製(mark-copy)算法的, 使整個JVM暫停運行(stop-the-world)的年輕代(Young generation) 垃圾收集器(garbage collector)

8019K->536K(9216K), 0.0112137 secs:NewGenMemBeforeGC->NewGenMemAfterGC(TotalNewGenMem), NewGenPauseTime

  • NewGenMemBeforeGC 表示新生代在GC發生前所佔用的內存大小
  • NewGenMemAfterGC 表示新生代在GC發生後所佔用的內存大小
  • TotalNewGenMem 表示新生代內存的總大小
  • NewGenPauseTime 表示在新生代進行內存回收時JVM暫停處理的時間

8019K->7704K(19456K), 0.0112710 secs:HeapMemBeforeGC->HeapMemAfterGC(TotalHeapMem), HeapPauseTime

  • HeapMemBeforeGC 表示JVM Heap在發生GC前佔用的內存
  • HeapMemAfterGC 表示JVM Heap在發生GC後佔用的內存
  • TotalHeapMem 表示JVM Heap所佔有的總內存
  • HeapPauseTime 表示在GC過程中JVM暫停處理的總時間

[Times: user=0.01 sys=0.00, real=0.01 secs]

  • user – 此次垃圾回收, 垃圾收集線程消耗的所有CPU時間(Total CPU time).
  • sys – 操作系統調用(OS call) 以及等待系統事件的時間(waiting for system event)
  • real – 應用程序暫停的時間(Clock time). 由於串行垃圾收集器(Serial Garbage Collector)只會使用單個線程, 所以 real time 等於 user 以及 system time 的總和.

現在我們來關注一下GC信息中的具體數據,在之前程序中只新建了一個7M的數組時,新生代的Eden區內存已經佔了99%;當我們新建一個1M的數組時,由於Eden區內存不足以放下一個1M的數組,因此觸發了一次Minor GC,Eden區的對象應該被存儲到to區內,但是由於Eden區中的數組b(7M)是一個遠遠超過to區(1M)內存的大對象,因此數組b被直接放入到了老年代,這就是爲什麼新程序的GC信息中寫着:tenured generation total 10240K, used 7168K;在Eden區又有了空間之後,新的數組b2就被成功分配在Eden區了。

2. ParNew 收集器

ParNew收集器其實就是Serial收集器的多線程版本,它的收集算法,對象分配規則,回收策略等都與Serial收集器完全相同,是針對新生代內存回收的垃圾收集器。當新生代內存不夠用時,它也會暫停全部用戶線程,然後開啓若干條GC線程使用複製算法並行進行垃圾回收,它和Serial Old收集器配合使用的效果如下圖:

24d38305c4b84d3d9c563c53478ee8ce-ParNew.jpg

我們可以使用 -XX:+UseParNew來顯示指定使用ParNew收集器,它默認開啓的收集線程數與CPU的數量相同,可以使用-XX:ParallelGCThreads參數來限制垃圾收集線程的數量。

3. Parallel Scavenge 收集器和Parallel Old收集器

Parallel Scavenge 收集器與ParNew收集器十分的相似,同樣是針對新生代的垃圾回收器,同樣採用了複製算法,也是並行的多線程垃圾收集器。那麼它的特別之處在哪裏呢?

實際上,Parallel Scavenge 收集器的側重點在於精準地控制垃圾收集停頓時間和吞吐量(Throughput)。所謂吞吐量就是CPU運行用戶代碼的時間與CPU消耗的總時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),假設我們的Java虛擬機總共運行了100分鐘,垃圾收集用時1分鐘,吞吐量就是99%。

Parallel Scavenge 收集器提供了-XX:MaxGCPauseMillis參數來控制最大垃圾收集停頓時間和-XX:GCTimeRatio來控制吞吐量大小。除了這兩個參數之外,它還有一個開關參數-XX:+UseAdaptiveSizePolicy,當這個參數打開後,我們無需手動指定新生代大小(-Xmn),Eden與Survior區的比例(-XX:SurvivorRatio),晉升老年代對象大小(-XX:PretenureSizeThreashold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種自適應的調節策略也是Parallel Scavenge 收集器與ParNew收集器一個重要區別。

至於Parallel Old收集器就是Parallel Scavenge 收集器針對老年代內存回收的版本,它採用的是多線程和“標記-整理算法”。這兩個收集器配合使用的效果圖如下:

d9ca3b6c7c6d40b6bc98c067b1806cfd-parallel.jpg

4. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一款用於老年代內存回收的側重於獲取最短停頓時間的垃圾收集器,當我們的應用對響應時間有着比較嚴格的要求時,CMS收集器能夠提供較短停頓時間,從而給用戶帶來較好的體驗。

CMS收集器的收集過程包括了以下幾步:

  • 初始標記 (CMS initial mark)
  • 併發標記 (CMS concurrent mark)
  • 重新標記 (CMS remark)
  • 併發清除 (CMS concurrent sweep)

其中,初始標記和重新標記兩個步驟仍然需要stop the world。初始標記用於快速標記所有GC Roots能夠到達的對象;併發標記就是恢復用戶程序,同時併發地跟蹤GC Roots;重新標記則是爲了標記併發標記期間由於用戶程序繼續運行而導致的可達性發生變化的對象,這個階段會比初始標記階段的時間更加長一些,但是遠遠小於併發標記的時間;最後恢復用戶程序,併發地清除未標記的垃圾對象。收集過程如下圖所示:

2686f59d1efa4beebbc12db867544de2-CMS.jpg

但是CMS收集器也有以下幾個缺點:

  1. 由於GC線程與應用程序併發執行時會搶佔CPU資源,因此會造成整體的吞吐量下降。也就是說,從吞吐量的指標上來說,CMS蒐集器是要弱於parallel scavenge蒐集器的。
  2. CMS收集器無法處理浮動垃圾(由於在併發清除過程中用戶的線程還在運行,伴隨着程序運行而產生的垃圾對象就叫做浮動垃圾)。
  3. CMS收集器由於採取了“標記-清除”算法因此不可避免地會產生內存碎片。爲了解決這個問題CMS收集器增加了碎片自動整理功能。

5. G1收集器

G1(Garbage First)收集器是目前收集器技術發展的最前沿結果之一。不同於之前的各種收集器針對於整個新生代或者是老年代,使用G1收集器時,Java堆內存的佈局被分爲了多個大小相等的獨立區域(Region),雖然還保留着新生代和老年代的概念,但是新生代與老年代不再是物理隔離的了,它們都是一部分Region的集合。

G1收集器的工作原理就是跟蹤每個Region裏面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需的時間),在後臺維護一個優先列表,每次根據允許的收集時間,有限回收價值最大的Region(這也就是Garbage First名稱的由來)。這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內可以獲得儘可能高的收集效率。

它的收集過程如下:

  • 初始標記 (initial marking)
  • 併發標記 (concurrent marking)
  • 最終標記 (final marking)
  • 篩選回收 (live data counting and evacuation)

初始標記階段僅僅是標記GC Roots可達的對象,並修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。併發標記階段時從GC Roots開始對堆中對象進行可達性分析,找出存活對象;而最終標記階段則是爲了修正在併發標記階段因用戶程序繼續運作而導致標記產生變化的標記記錄;最後在篩選回收階段首先對各個Region的回收價值和成本進行排序,然後根據用戶所期望的GC停頓時間來指定回收計劃,最終回收對象。運行過程如下圖所示:

1ed58853219a4148b237b777d024c27a-G1.jpg

6. 常見的組合GC

  • Serial 與 Serial Old組合: 新生代使用Serial收集器,老年代使用Serial Old收集器。這個組合是client模式下的默認垃圾蒐集器組合,我們可以通過參數-XX:+UseSerialGC顯示開啓。由於兩個收集器都是串行收集內存,因此比較適合小型應用程序或平常我們開發,調試的程序。
  • Parallel Scavenge 與 Parallel Old組合:新生代使用Parallel Scavenge收集器,老年代使用Parallel Old收集器。這個組合採用了多線程並行的垃圾回收機制,因此比較適合一些對吞吐量有一定要求的程序,同時由於時多線程工作,這個組合對CPU核數的要求也比較高。可以通過-XX:+UseParallelGC參數顯示開啓。
  • ParNew,CMS和Serial Old組合:新生代使用ParNew收集器,老年代使用CMS收集器當出現ConcurrentMode Failure 或 PromotionFailed時會採用Serial Old收集器。這個組合比較適合對響應時間有着比較強需求,需要提供較好的用戶體驗的後臺程序,最典型的就是Java Web程序。可以通過參數-XX:+UseConcMarkSweepGC顯示開啓。

7.總結

本篇博客爲大家介紹了目前市面上比較常見的幾個垃圾收集器以及它們採取的垃圾收集算法,同時也簡單的介紹了幾個大家可能用到的相關虛擬機參數。希望大家通過我給出GC日誌的例子中,學會閱讀GC詳細信息,從而在問題發生時能夠更加快速地定位問題,解決問題。不知不覺,這篇博客就寫的比較長了,我們下篇博客再見~。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章