一文吃透JVM分代回收機制(以SerialGC為例)

Mr羽墨青衫發表於2019-03-13

Java GC發展至今,已經推出了好幾代收集器,包括Serial、ParNew、Parallel、CMS、G1以及Java11中最新的ZGC。每一代GC都對前一代存在的問題做出了很大的改善。

今天介紹一個古董收集器-Serial序列GC。

雖然此收集器的使用場景已經不多,但本文通過這個收集器,說明了如何分配每一塊堆記憶體的大小,並根據GC日誌,詳細說明了Serial GC在新生代和老年代的GC過程。

Serial GC的名字能很好地概括他的特點:序列。它與應用執行緒的執行是序列的,也就是說,執行應用執行緒的時候,不會執行GC,執行GC的時候,不能執行應用執行緒。

所以,整個Java程式執行起來就行下面的樣子:

SerialGC1

Serial GC使用的是分代演算法,在新生代上,Serial使用複製演算法進行收集,在老年代上,Serial使用標記-壓縮演算法進行收集。

分代演算法、複製演算法、標記-壓縮請移步:

Java虛擬機器-GC垃圾回收演算法-標記清除法、複製演算法、標記壓縮法、分代演算法

1 Serial存在的問題

如上圖所示,在需要執行GC時,GC執行緒會阻塞所有使用者執行緒(Stop-The-world,簡稱STW),等他執行完,才會恢復使用者執行緒。

這對我們的應用程式來說,每次GC是都會造成不同程度的卡頓,對使用者是極為不友好的。

2 使用場景

個人觀點:

首先,根據其特點,回收演算法簡單,所以回收效率高。

其次,它是單執行緒收集的,不存在GC執行緒之間的切換。由於Java的執行緒切換是需要系統核心來排程的,在單執行緒下,可以很大程度的減少排程帶來的系統開銷。

所以,也許在單核CPU機器上,且業務場景為只對公司內部使用且可以忍受STW帶來的卡頓的情況下,有一些用武之地。

3 實戰

環境:

  • CPU:i7 4核
  • 記憶體:16G
  • JDK version:8

3.1 先來看一下預設情況下,使用的哪個GC

新增下面JVM引數並執行程式碼,觀察GC日誌

/**
 * JVM引數:
 * -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
 */
public static void main(String[] args) {
    System.out.println("Hello SerialGC");
}
複製程式碼

程式輸出如下

Hello SerialGC
// 下面是GC日誌
Heap
PSYoungGen      total 76288K, used 6554K [0x000000076b180000, 0x0000000770680000, 0x00000007c0000000)
  eden space 65536K, 10% used [0x000000076b180000,0x000000076b7e6930,0x000000076f180000)
  from space 10752K, 0% used [0x000000076fc00000,0x000000076fc00000,0x0000000770680000)
  to   space 10752K, 0% used [0x000000076f180000,0x000000076f180000,0x000000076fc00000)
ParOldGen       total 175104K, used 0K [0x00000006c1400000, 0x00000006cbf00000, 0x000000076b180000)
  object space 175104K, 0% used [0x00000006c1400000,0x00000006c1400000,0x00000006cbf00000)
Metaspace       used 3458K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 381K, capacity 388K, committed 512K, reserved 1048576K
複製程式碼
  • PSYoungGen:表示年輕代使用的是ParallelGC
  • ParOldGen:表示老年代使用的是ParallelGC
  • Metaspace:後設資料區使用情況

可見,在多核情況下,JVM預設選用了支援多執行緒併發的ParallelGC。

3.2 Serial GC是執行在Client模式下的預設收集器?

周志明老師的書中提到過,SerialGC仍然是-client模式下預設的收集器。

下面來實驗一下,剛才的JVM啟動引數加上-client引數

/**
* JVM引數:
* -client -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
*/
public static void main(String[] args) {
    System.out.println("Hello SerialGC");
}
複製程式碼

執行結果如下:

Hello SerialGC in client mode
Heap
PSYoungGen      total 76288K, used 6554K [0x000000076b180000, 0x0000000770680000, 0x00000007c0000000)
  eden space 65536K, 10% used [0x000000076b180000,0x000000076b7e6930,0x000000076f180000)
  from space 10752K, 0% used [0x000000076fc00000,0x000000076fc00000,0x0000000770680000)
  to   space 10752K, 0% used [0x000000076f180000,0x000000076f180000,0x000000076fc00000)
ParOldGen       total 175104K, used 0K [0x00000006c1400000, 0x00000006cbf00000, 0x000000076b180000)
  object space 175104K, 0% used [0x00000006c1400000,0x00000006c1400000,0x00000006cbf00000)
Metaspace       used 3513K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K
複製程式碼

可見依然是ParallelGC。

這個原因應該是由於,在JDK1.8下,-client和-server引數預設都是失效的,所以指定-client也無濟於事。

其實筆者也在相同的環境下嘗試了JDK6和JDK7,也同樣不是SerialGC,所以猜想可能是老版本的單核CPU情況下,JVM會預設選擇SerialGC,但這一點筆者尚未查證。

PS:-client和-server

-client和-server引數在之前版本的JDK中是用來選擇JVM執行過程中使用的編譯器的。對啟動效能有要求的程式,可使用-client,對應的編譯器為編譯效率較快C1,對峰值效能有要求的程式,可使用-server,對應生成程式碼執行效率較快的C2(參考了鄭雨迪老師在極客時間推出的課程)。

Java8會預設使用分層編譯的機制,會自動選擇在何時使用哪個編譯器,所以client和server引數在預設情況下失效。相對之前的JDK版本,JDK8的這種機制很大程度地提升了程式碼的編譯執行效率。

3.3 Serial GC實戰 - JVM引數

本小節說明了如何配置堆記憶體中每一塊記憶體的大小。

首先我們要明確需要指定哪幾塊記憶體。因為Serial GC是分代收集,所以要確認新生代和老年代的大小,其中,新生代又需要確認Eden區和Survivor區的大小。

  • 定義整個堆記憶體的大小
// -Xmx:最大堆記憶體,-Xms:最小堆記憶體,這裡設定為一樣的,表示堆記憶體固定200M
-Xmx200M -Xms200M
複製程式碼
  • 定義新生代和老年代的大小
// NewRatio表示老年代和新生代的比例,3表示3:1
// 即把整個堆記憶體分為4份,老年代佔3份,新生代1份
// 目前堆記憶體為200M,NewRatio=3時,新生代=50M,老年代=150M
-XX:NewRatio=3
複製程式碼
  • 定義Eden區和Survivor區的大小
// SurvivorRatio表示Eden區和兩個Survivor區的比例,3表示3:2(注意是兩個Survivor區)
// 即把新生代分為5份,Eden佔3份,Survivor區佔2份
// 目前新生代為50M,Survivor=3時,Eden=30M,Survivor=20M(from=10M, to=10M)
-XX:SurvivorRatio=3
複製程式碼
  • 配置GC日誌列印引數
// -XX:+UseSerialGC:顯示指定使用Serial GC
// -XX:+PrintGCDetails:列印GC詳細日誌
// -XX:+PrintGCTimeStamps:列印GC發生的時間
-XX:+UseSerialGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
複製程式碼
  • 實踐
    SerialGC2

依然用上面的Hello SerialGC程式,執行結果如下

Hello SerialGC
Heap
// def new generation表明新生代使用SerialGC,total:40M,已使用:4302K
// total少了10M?這是因為新生代使用複製演算法,From區和to區實際上每次只能使用1個,所以是eden的30M + from或to的10M = 40M
def new generation   total 40960K, used 4302K [0x00000000f3800000, 0x00000000f6a00000, 0x00000000f6a00000)
  // eden區30M
  eden space 30720K,  14% used [0x00000000f3800000, 0x00000000f3c33b78, 0x00000000f5600000)
  // from區10M
  from space 10240K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f6000000)
  // to區10M
  to   space 10240K,   0% used [0x00000000f6000000, 0x00000000f6000000, 0x00000000f6a00000)
// 老年代使用 SerialGC ,總大小150M,已使用0K
tenured generation   total 153600K, used 0K [0x00000000f6a00000, 0x0000000100000000, 0x0000000100000000)
   the space 153600K,   0% used [0x00000000f6a00000, 0x00000000f6a00000, 0x00000000f6a00200, 0x0000000100000000)
// 後設資料區大小,暫不關注
Metaspace       used 3450K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 380K, capacity 388K, committed 512K, reserved 1048576K
複製程式碼

關於複製演算法,請移步:

複製演算法

3.4 Serial GC實戰 - 通過GC日誌理解新生代老年代的GC過程

此實驗在上述JVM引數配置條件下執行。

下面通過一個例項程式,來觀察一下

public class SerialGCDemo {

    /**
     * 堆記憶體:-Xmx200M -Xms200M
     * 新生代:-XX:NewRatio=3 -XX:SurvivorRatio=3
     * GC引數:-XX:+UseSerialGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
     * 堆空間:200M,新生代:50M,老年代:150M,新生代eden區:30M,新生代from區:10M,新生代to區:10M
     * -Xmx200M -Xms200M -XX:NewRatio=3 -XX:SurvivorRatio=3 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
     * @param args
     */
    public static void main(String[] args) {
        byte[][] useMemory = new byte[1000][];
        Random random = new Random();
        for (int i = 0; i < useMemory.length; i++) {
            useMemory[i] = new byte[1024 * 1024 * 10]; // 建立10M的物件
            // 20%的概率將建立出來的物件變為可回收物件
            if (random.nextInt(100) < 20) {
                System.out.println("created byte[] and set to null: " + i);
                useMemory[i] = null;
            } else {
                System.out.println("created byte[]: " + i);
            }
        }
    }
}
複製程式碼

整體日誌輸入如下:

created byte[]: 0
created byte[]: 1
0.236: [GC (Allocation Failure) 0.236: [DefNew: 24807K->870K(40960K), 0.0132148 secs] 24807K->21350K(194560K), 0.0132618 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
created byte[]: 2
created byte[] and set to null: 3
0.252: [GC (Allocation Failure) 0.252: [DefNew: 21941K->717K(40960K), 0.0060942 secs] 42421K->31437K(194560K), 0.0061231 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
created byte[]: 4
created byte[]: 5
0.259: [GC (Allocation Failure) 0.259: [DefNew: 22408K->717K(40960K), 0.0114560 secs] 53128K->51917K(194560K), 0.0114856 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]
created byte[]: 6
created byte[]: 7
0.285: [GC (Allocation Failure) 0.285: [DefNew: 21788K->717K(40960K), 0.0122524 secs] 72988K->72397K(194560K), 0.0122868 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
created byte[]: 8
created byte[]: 9
0.299: [GC (Allocation Failure) 0.299: [DefNew: 21790K->717K(40960K), 0.0115042 secs] 93470K->92877K(194560K), 0.0115397 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]
created byte[]: 10
created byte[]: 11
0.312: [GC (Allocation Failure) 0.312: [DefNew: 21791K->717K(40960K), 0.0120174 secs] 113952K->113357K(194560K), 0.0120525 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
created byte[]: 12
created byte[]: 13
0.328: [GC (Allocation Failure) 0.328: [DefNew: 21792K->717K(40960K), 0.0162437 secs] 134432K->133837K(194560K), 0.0162844 secs] [Times: user=0.00 sys=0.01, real=0.02 secs]
created byte[]: 14
created byte[]: 15
0.347: [GC (Allocation Failure) 0.347: [DefNew: 21793K->21793K(40960K), 0.0000201 secs]0.347: [Tenured: 133120K->143360K(153600K), 0.0103885 secs] 154913K->154316K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0104608 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Exception in thread "main" created byte[]: 16
0.361: [Full GC (Allocation Failure) 0.361: [Tenured: 143360K->143360K(153600K), 0.0028089 secs] 165153K->164556K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0028543 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
0.364: [Full GC (Allocation Failure) 0.364: [Tenured: 143360K->143360K(153600K), 0.0050038 secs] 164556K->164538K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0050390 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Disconnected from the target VM, address: '127.0.0.1:57881', transport: 'socket'
java.lang.OutOfMemoryError: Java heap space
Heap
    at com.example.demo.gcdemo.SerialGCDemo.main(SerialGCDemo.java:28)
def new generation   total 40960K, used 22281K [0x00000000f3800000, 0x00000000f6a00000, 0x00000000f6a00000)
  eden space 30720K,  72% used [0x00000000f3800000, 0x00000000f4dc27c0, 0x00000000f5600000)
  from space 10240K,   0% used [0x00000000f6000000, 0x00000000f6000000, 0x00000000f6a00000)
  to   space 10240K,   0% used [0x00000000f5600000, 0x00000000f5600000, 0x00000000f6000000)
tenured generation   total 153600K, used 143360K [0x00000000f6a00000, 0x0000000100000000, 0x0000000100000000)
   the space 153600K,  93% used [0x00000000f6a00000, 0x00000000ff6000e0, 0x00000000ff600200, 0x0000000100000000)
Metaspace       used 3381K, capacity 4568K, committed 4864K, reserved 1056768K
  class space    used 364K, capacity 392K, committed 512K, reserved 1048576K
複製程式碼

日誌說明:

0.236: [GC (Allocation Failure) 0.236: [DefNew: 24807K->870K(40960K), 0.0132148 secs] 24807K->21350K(194560K), 0.0132618 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
複製程式碼
  • 0.236:GC發生的時間(秒),從程式啟動開始計算
  • [GC:GC型別,另外還有Full GC,GC和Full GC都會造成STW。
  • (Allocation Failure):GC原因,申請記憶體失敗
  • [DefNew:說明新生代用Serail GC回收,即default new generation之意。
  • 24087K -> 870K(40960K):GC前該區域記憶體已使用容量 -> GC後該區域記憶體已使用容量(該區域記憶體總容量)
  • 0.0132148 secs:該記憶體區域GC所佔用的時間(秒)
  • 24807K->21350K(194560K):GC前堆記憶體已使用容量 -> GC後堆記憶體已使用容量(堆記憶體總容量:190M,這裡要減去from或to的10M)
  • 0.0132618 secs:本次回收整體佔用時間(秒)
  • [Times: user=0.02 sys=0.00, real=0.01 secs]:佔用時間具體資料。user:使用者態消耗的CPU時間,sys:核心態消耗的CPU時間,real:從操作開始到操作結束所經歷的牆鍾時間。
0.361: [Full GC (Allocation Failure) 0.361: [Tenured: 143360K->143360K(153600K), 0.0028089 secs] 165153K->164556K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0028543 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
複製程式碼

這裡只說明一下與上面有區別的地方

  • [Full GC:GC型別,會造成STW
  • [Tenured:老年代回收
  • 143360K->143360K(153600K):老年代GC前已使用記憶體容量 -> 老年代GC後已使用記憶體容量(老年代總容量)
  • 165153K->164556K(194560K):堆記憶體GC前已使用記憶體容量 -> 堆記憶體GC後已使用記憶體容量(堆記憶體總容量)
  • Metaspace:後設資料區記憶體回收情況

下面分步驟詳細看一下從程式開始到結束,對記憶體的變化過程

整個記憶體初始狀態如下:

SerialGC3

created byte[]: 0
created byte[]: 1
0.236: [GC (Allocation Failure) 0.236: [DefNew: 24807K->870K(40960K), 0.0132148 secs] 24807K->21350K(194560K), 0.0132618 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
複製程式碼

建立了兩個10M的物件(記為ID:0,ID:1),並且沒有設定成可回收物件,由於Eden區目前最起碼還有一個Random物件,所以在給第三個物件申請記憶體時,發現Eden區記憶體不足,觸發了GC。

SerialGC4

新生代在GC後變為870K,說明Random物件被複制到from區,而兩個10M的物件都直接晉升到了老年代。

SerialGC5

created byte[]: 2
created byte[] and set to null: 3
0.252: [GC (Allocation Failure) 0.252: [DefNew: 21941K->717K(40960K), 0.0060942 secs] 42421K->31437K(194560K), 0.0061231 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
複製程式碼

建立了ID:2和ID:3物件,並把ID:3設定為可回收物件

SerialGC6

GC會將Eden區的物件和from區的物件嘗試複製到to區,ID:3物件直接回收(通過堆空間的容量變化可以看出:42421K->31437K),ID:2物件在to區中放不下,晉升老年代

SerialGC7

一直到建立ID:12,ID:13,都與上述過程類似,並且沒有產生過垃圾物件,但建立完ID:13物件後,老年代的已使用記憶體達到了130M+,如下:

created byte[]: 12
created byte[]: 13
0.328: [GC (Allocation Failure) 0.328: [DefNew: 21792K->717K(40960K), 0.0162437 secs] 134432K->133837K(194560K), 0.0162844 secs] [Times: user=0.00 sys=0.01, real=0.02 secs]
複製程式碼

再建立ID:14,ID:15物件後,又需要新生代GC

created byte[]: 14
created byte[]: 15
0.347: [GC (Allocation Failure) 0.347: [DefNew: 21793K->21793K(40960K), 0.0000201 secs]0.347: [Tenured: 133120K->143360K(153600K), 0.0103885 secs] 154913K->154316K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0104608 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
複製程式碼

GC前如下所示

SerialGC8

在新生代GC時,要把ID:14,ID:15的物件複製到老年代,但此時老年代已經不足以容納這兩個物件,此時會觸發老年代的GC。

即日誌中的Tenured部分。但發現沒有任何物件可以回收,然後嘗試複製了Eden區的一個物件到老年代

SerialGC9

然後繼續建立物件,會繼續嘗試Full GC,Full GC無果,最終發生記憶體溢位。

Exception in thread "main" created byte[]: 16
0.361: [Full GC (Allocation Failure) 0.361: [Tenured: 143360K->143360K(153600K), 0.0028089 secs] 165153K->164556K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0028543 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
0.364: [Full GC (Allocation Failure) 0.364: [Tenured: 143360K->143360K(153600K), 0.0050038 secs] 164556K->164538K(194560K), [Metaspace: 3350K->3350K(1056768K)], 0.0050390 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Disconnected from the target VM, address: '127.0.0.1:57881', transport: 'socket'
java.lang.OutOfMemoryError: Java heap space
Heap
    at com.example.demo.gcdemo.SerialGCDemo.main(SerialGCDemo.java:28)
複製程式碼

4 總結

首先介紹了Serial的特點以及存在的問題,SerialGC是序列收集器,在收集時會產生STW,停頓時間較長導致使用者體驗差。

然後通過實戰,介紹瞭如何指定JVM的每一塊堆記憶體。

最後通過一個案例,詳細描述了SerialGC的整個過程以及記憶體變化。


歡迎關注我的微信公眾號

公眾號

相關文章