GC算法:實作
上面我們介紹了GC算法中的核心概念,接下來我們看一下JVM裡的具體實作。首先必須了解的一個重要的事實是:對于大部分的JVM來說,兩種不同的GC算法是必須的,一個是清理Young Generation的算法,另一種是清理Old Generation的算法。
在JVM裡有各種各樣的這種内置算法,如果你沒有特别指定GC算法,則會使用一個預設的、适應目前平台(platform-specific)的算法。接下來我們會解釋每種算法的工作原理。
下面的清單提供了一個快速的預覽,關于哪些算法可能被結合使用。不過需要注意的是,它僅适用于Java 8,對應Java 8 之前的版本,可能稍有不同。

- Serial GC for both the Young and Old generation
- Parallel GC for both the Young and Old generation
- Parallel New for Young + Concurrent Mark and Sweep (CMS) for the Old Generation
- G1 in case of which the generation are not separated between the Young and Old
Serial GC
這種垃圾回收器在Young Generation使用mark-copy,在Old Generation使用mark-sweep-compact。正如它的名字一樣,這兩種收集器均是單線程的收集器,無法與目前的任務并行工作。這兩種收集器均會觸發stop-the-world pauses,暫時停止所有應用線程。
這種GC算法無法使用目前主流硬體上多核CPU的優點,不管有多少可用的CPU核數,JVM在GC階段僅會使用一個核。可以通過指定以下配置應用此機制:
java -XX:+UseSerialGC com.company.testclass
這個選項僅推薦給:
- JVM中僅有幾百MB的堆大小
- 運作的環境是單核CPU
對于大部分的服務端部署來說,很少會使用這種模式,因為大部分服務端的應用一般部署在多核平台,并不适合Serial GC的使用場景,會造成伺服器資源的浪費。接下來我們看一下如果使用Serial GC 的話,那GC 收集器的日志會是什麼形式。首先我們在JVM下開啟GC日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
日志的輸出類似以下内容:
2015-05-26T14:45:37.987-0200: 151.126: [GC (Allocation Failure) 151.126: [DefNew: 629119K->69888K(629120K), 0.0584157 secs] 1619346K->1273247K(2027264K), 0.0585007 secs] [Times: user=0.06 sys=0.00, real=0.06 secs]
2015-05-26T14:45:59.690-0200: 172.829: [GC (Allocation Failure) 172.829: [DefNew: 629120K->629120K(629120K), 0.0000372 secs]172.829: [Tenured: 1203359K->755802K(1398144K), 0.1855567 secs] 1832479K->755802K(2027264K), [Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs] [Times: user=0.18 sys=0.00, real=0.18 secs]
這小部分GC日志可以提供我們很多資訊,JVM内部當時發生了什麼。具體地說,這部分日志片段反應了兩輪GC,一個是清理Young Generation,另一個是清理整個堆。我們首先分析第一個在Young Generation發生的GC。
Minor GC
下面的日志片段包括了GC清理Young Generation時的一些資訊:
2015-05-26T14:45:37.987-02001:151.1262:[GC3(Allocation Failure4) 151.126: [DefNew5:629119K->69888K6(629120K)7, 0.0584157 secs]1619346K->1273247K8(2027264K)9,0.0585007 secs10][Times: user=0.06 sys=0.00, real=0.06 secs]11
1. 2015-05-26T14:45:37.987-0200 : GC事件發生的時間
2. 151.126 : 相對于JVM的啟動時間,GC事件發生的時間,以秒為機關
3. GC: GC類型的标志,用于差別是 Minor GC 還是 Full GC。這次顯示的是一次 Minor GC
4. Allocation Failure :GC發生的原因。在這個日志中,表示的是是由于Young Generation 裡的任何區域均無法滿足一個(對某個資料結構的)空間配置設定
5. DefNew :使用的 GC 收集器名稱。這個縮略名表示的是單線程的、mark-copy、stop-the-world 垃圾回收器,用于清理Young Generation
6. 629119K->69888K :Young Generation的使用情況,分為 GC 前以及 GC 後
7. (629120K) :Young Generation 的總大小
8. 1619346K->1273247K :堆記憶體使用的總大小,分為GC前與GC後
9. (2027264K) :堆記憶體總可用大小
10. 0.0585007 secs :GC事件的持續總長時間,以秒為機關
11. [Times: user=0.06 sys=0.00, real=0.06 secs] :GC事件的持續時間,從三種不同的類别衡量:
A. user:在GC 階段,GC 線程消耗的整個CPU時間
B. sys:OS 調用消耗的時間,或是等待系統事件的時間
C. real:應用停止的時間。因為 Serial GC 一直使用的是單線程,是以這裡 real time 等于 user 與 system 時間的總和
從上面的片段,我們可以精确地了解到在 GC 事件時,JVM内部的記憶體消耗情況。在這次回收前,heap 使用了總共 1,619,346K 大小的記憶體,其中 Young Generation 一共占了 629,120K 記憶體。基于此,我們可以計算出 Old Generation 使用量為 990,227K記憶體。
另一方面,我們也可以看到,在回收之後,Young Generation 的使用量降了 559,231K,但是整個heap 的使用量僅降了346,099K,由此可以推測出,有 213,132K 的對象從 Young Generation 被提升到了 Old Generation。
這次 GC 事件前後,記憶體的分布,也可以通過下圖表示:
Full GC
在讨論了第一個 Minor GC 事件後,我們再來看看第二個 Full GC 事件日志:
2015-05-26T14:45:59.690-02001: 172.8292:[GC (Allocation Failure) 172.829:[DefNew: 629120K->629120K(629120K), 0.0000372 secs3]172.829:[Tenured4: 1203359K->755802K 5(1398144K) 6,0.1855567 secs7] 1832479K->755802K8(2027264K)9,[Metaspace: 6741K->6741K(1056768K)]10 [Times: user=0.18 sys=0.00, real=0.18 secs]11
1. 2015-05-26T14:45:59.690-0200 :GC事件開始的時間
2. 172.829 : 相對于JVM的啟動時間,GC事件發生的時間,以秒為機關
3. [DefNew: 629120K->629120K(629120K), 0.0000372 secs :類似上一個例子(由于 Allocation Failure觸發的一個minor GC),這次對 Young Generation 的回收也是同樣由 DefNew 回收器完成。它将 Young Generation的使用量由 629,120K 降為 0。需要注意的是:這裡的日志列印有問題,由于一個存在bug的行為,導緻它列印的日志為 Young Generation 使用為滿的狀态。這次回收耗時 0.0000372 秒
4.Tenured :清理 Old 空間時使用的 GC 收集器名稱。這裡 Tenured 表示GC使用了一個單線程的、stop-the-world、mark-sweep-compact 垃圾回收器
5. 1203359K->755802K :在 GC 事件前後,Old Generation 使用的空間大小
6. (1398144K) :Old Generation 空間的總共大小
7. 0.1855567 secs :清理 Old Generation 的時間
8. 1832479K->755802K :清理 Young 以及 Old Generation 前後,整個 heap 使用的記憶體大小
9. (2027264K) :JVM 可用的 heap 大小
10. [Metaspace: 6741K->6741K(1056768K)] :類似 Metaspace 空間回收的資訊,正如日志列印的,這次回收中,沒有Metaspace的垃圾被回收
11. [Times: user=0.18 sys=0.00, real=0.18 secs] :GC事件的持續時間,從三種不同的類别衡量:
Full GC 與 Minor GC 的不同點顯而易見:在 GC 事件中,除了對 Young Generation 做了垃圾回收外,Old Generation 與 Metaspace 也被做了清理。在這個例子中,在 GC 事件前後,記憶體的分布可如下如表示:
Parallel GC
這種GC收集器的組合(對 Young 與 Old 使用的兩種 GC收集器),在 Young Generation 中使用 mark-copy,在Old Generation中使用mark-sweep-compact。對 Young 與 Old Generation的收集均會觸發 stop-the-world 事件,暫停應用的所有線程,以運作 GC。兩個收集器均會以多線程的方式運作 mark-copy / mark-sweep-compact,是以它的名字為 ‘Parallel’。使用并行的方式,可以明顯減少GC的時間。在GC時,使用多少個線程也可以通過參數指定:-XX:ParallelGCThreads=NNN。預設的值是:機器的CPU核數。在啟動JVM時使用以下任一配置即可啟用ParallelGC:
java -XX:+UseParallelGC com.company.MyClass
java -XX:+UseParallelOldGC com.company.MyClass
java -XX:+UseParallelGC -XX:+UseParallelOldGC com.compay.MyClass
Parallel 垃圾收集器适用與多核機器,是以如果你的主要目标是為了提高吞吐,則Parallel GC是一個較好的選擇。可以獲得高吞吐是由于此方法高效地使用了系統資源:
- 在收集過程中,所有CPU 核均會并行回收垃圾,是以應用暫停時間會更短
- 在GC輪數之間,不會有收集器消耗任何資源
另一方面,由于在GC中所有的階段在運作時不可被打斷,是以在你的應用線程暫停時,這些收集器仍容易受到long pause的影響。是以,如果Latency是你需要優先考慮的目标,則你可以考慮下一個垃圾收集器組合。
下面我們看一下使用Parallel GC時,日志輸出的資訊。下面是一個 minor 和一個 major GC 的日志:
2015-05-26T14:27:40.915-0200: 116.115: [GC (Allocation Failure) [PSYoungGen: 2694440K->1305132K(2796544K)] 9556775K->8438926K(11185152K), 0.2406675 secs] [Times: user=1.77 sys=0.01, real=0.24 secs]
2015-05-26T14:27:41.155-0200: 116.356: [Full GC (Ergonomics) [PSYoungGen: 1305132K->0K(2796544K)] [ParOldGen: 7133794K->6597672K(8388608K)] 8438926K->6597672K(11185152K), [Metaspace: 6745K->6745K(1056768K)], 0.9158801 secs] [Times: user=4.49 sys=0.64, real=0.92 secs]
第一條表示了在 Young Generation裡發生的一個GC事件:
2015-05-26T14:27:40.915-02001: 116.1152:[GC3(Allocation Failure4)[PSYoungGen5: 2694440K->1305132K6(2796544K)7]9556775K->8438926K8(11185152K)9, 0.2406675 secs10][Times: user=1.77 sys=0.01, real=0.24 secs]11
1. 2015-05-26T14:27:40.915-0200:GC事件發生的時間
2. 116.115 : 相對于JVM的啟動時間,GC事件發生的時間,以秒為機關
5. PSYoungGen:使用的 GC 收集器名稱。這裡表示的是一個并行的、mark-copy、stop-the-world 垃圾回收器被用于清理Young Generation
6. 2694440K->1305132K:Young Generation的使用情況,分為 GC 前以及 GC 後
7. (2796544K):Young Generation 的總大小
8. 9556775K->8438926K :堆記憶體使用的總大小,分為GC前與GC後
9. (11185152K):堆記憶體總可用大小
10. 0.2406675 secs:GC事件的持續總長時間,以秒為機關
11. [Times: user=1.77 sys=0.01, real=0.24 secs] :GC事件的持續時間,從三種不同的類别衡量:
C. real:應用停止的時間。對于 Parallel GC 來說,它的值應該接近于(user time + system time)/ GC 收集器使用的 CPU 線程數。在這個例子中,GC 收集器使用的是 8 個線程。不過需要注意的是,由于一些活動并不會被并行執行,是以它的真實值會超過一定的比率。
從上面的日志可以看到,在 GC 事件前,整個 heap 中消耗的記憶體為 9,556,775K,其中 Young Generation 消耗了2,694,440K,也就是說 Old Generation 使用了 6,862,335K。在 GC 後,Young Generation 的使用量降了 1,389,308K,但是整個 heap 的使用量僅降了 1,117,849K。也就是說,有 271,459K 從 Young Generation 提升到了 Old Generation。
下面我們繼續看下一行 GC 日志,看看 GC 是如何清理整個 heap 記憶體的:
2015-05-26T14:27:41.155-02001:116.3562:[Full GC3 (Ergonomics4)[PSYoungGen: 1305132K->0K(2796544K)]5[ParOldGen6:7133794K->6597672K 7(8388608K)8] 8438926K->6597672K9(11185152K)10, [Metaspace: 6745K->6745K(1056768K)] 11, 0.9158801 secs12, [Times: user=4.49 sys=0.64, real=0.92 secs]13
1-3 省略
4. Ergonomics:GC 事件發生的原因。這裡表示 JVM 的内部功效決定這時候需要做垃圾回收
5. [PSYoungGen: 1305132K->0K(2796544K)]:與之前的例子類似,一個名為“PSYoungGen”的、并行的、mark-copy、stop-the-world GC 回收器被用于清理 Young Generation。Young Generation 的使用情況由 1,305,132K 降為了 0,因為一般一次 Full GC 經常會将 Young GC 完全清理掉。
6. ParOldGen:用于清理 Old Generation 的收集器類型。在這個例子中,一個名為 ParOldGen 的、并行的、mark-sweep-compact、stop-the-world 垃圾回收器被用于清理 Old Generation。
7. 7133794K->6597672K :在 GC 事件前後,Old Generation 使用的空間大小
8. (8388608K) :Old Generation 空間的總共大小
9. 8438926K->6597672K:清理 Young 以及 Old Generation 前後,整個 heap 使用的記憶體大小
10. (11185152K) :JVM 可用的 heap 大小
11. [Metaspace: 6745K->6745K(1056768K)] :類似 Metaspace 空間回收的資訊,正如日志列印的,這次事件中,沒有Metaspace的垃圾被回收
12. 0.9158801 secs:GC 事件持續的時間
13. [Times: user=4.49 sys=0.64, real=0.92 secs] GC事件的持續時間,從三種不同的類别衡量:
同樣,Full GC 與 Minor GC 的差別較為明顯:除了清理 Young Generation,Old Generation 與 Metaspace 也會被清理。在這個例子中,在 GC 事件前後,記憶體的分布可如下如表示:
References:
https://plumbr.io/handbook/garbage-collection-algorithms-implementations