GC 設計與停頓

1. 寫在前面java


「[JVM 解剖公園][1]」是一個持續更新的系列迷你博客,閱讀每篇文章通常須要5到10分鐘。限於篇幅,僅對某個主題按照問題、測試、基準程序、觀察結果深刻講解。所以,這裏的數據和討論能夠當軼事看,不作寫做風格、句法和語義錯誤、重複或一致性檢查。若是選擇採信文中內容,風險自負。c++


Aleksey Shipilёv,JVM 性能極客 shell

推特 [@shipilev][2]  併發

問題、評論、建議發送到 [aleksey@shipilev.net][3]jvm


[1]:https://shipilev.net/jvm-anatomy-parkide

[2]:http://twitter.com/shipilev性能

[3]:aleksey@shipilev.net學習


 2. 問題測試


若是說垃圾回收是敵人,那麼毫不能懼怕,由於恐懼會讓人逐步死去直至完全消亡。等等,這裏究竟要討論什麼問題?好吧,這裏要討論的是,「在 `ArrayList` 中分配1億個對象會讓 Java ‘打嗝’「 是真的嗎?atom


 3. 全貌圖


能夠簡單地把性能問題歸罪於通用 GC,而真正的問題是對於實際工做負載 GC 的表現沒有達到預期。不少時候是工做負載自己有問題,其餘狀況則是使用了不匹配的 GC。請注意大多數回收器在其 GC 週期中是如何停頓的。


4. 實驗


對於「向 `ArrayList` 加入1億個對象」這個實驗,雖然不切實際且略顯搞笑,但在仍是能夠運行一下看看效果。下面是實驗代碼:


```java
import java.util.*;

public class AL {
   static List<Object> l;
   public static void main(String... args) {
       l = new ArrayList<>();
       for (int c = 0; c < 100_000_000; c++) {
           l.add(new Object());
       }
   }
}
```


下面是來自奶牛的評論:


```shell
$ cowsay ...
________________________________________
/ 順便說一下,這是一個糟糕的 GC 基準測試       \
| 即便我是一頭奶牛,也能清楚地知道             |
\ 這一點。                                 /
----------------------------------------
       \   ^__^
        \  (oo)\_______
           (__)\       )\/\
               ||----w |
               ||     ||
```


儘管如此,即便一個糟糕的基準測試,只要仔細分析仍是能夠從運行結果中瞭解一些測試系統的有用信息。事實證實,在 OpenJDK 中選擇不一樣的回收器及其對應的 GC 設計,在這樣的負載下運行更能凸顯彼此之間的差別。


下面使用 JDK 9 + Shenandoah 垃圾回收器享受 GC 全部最新改進。在配置較低的 1.7 GHz i5 超極本運行 Linux x86_64 進行測試。要分配1億個16字節大小的對象,這裏 heap 設爲靜態 4GB 以消除不一樣回收器之間的自由度差別。


4.1 G1(JDK9 默認 GC)


```shell
$ time java -Xms4G -Xmx4G -Xlog:gc AL
[0.030s][info][gc] Using G1
[1.525s][info][gc] GC(0) Pause Young (G1 Evacuation Pause) 370M->367M(4096M) 991.610ms
[2.808s][info][gc] GC(1) Pause Young (G1 Evacuation Pause) 745M->747M(4096M) 928.510ms
[3.918s][info][gc] GC(2) Pause Young (G1 Evacuation Pause) 1105M->1107M(4096M) 764.967ms
[5.061s][info][gc] GC(3) Pause Young (G1 Evacuation Pause) 1553M->1555M(4096M) 601.680ms
[5.835s][info][gc] GC(4) Pause Young (G1 Evacuation Pause) 1733M->1735M(4096M) 465.216ms
[6.459s][info][gc] GC(5) Pause Initial Mark (G1 Humongous Allocation) 1894M->1897M(4096M) 398.453ms
[6.459s][info][gc] GC(6) Concurrent Cycle
[7.790s][info][gc] GC(7) Pause Young (G1 Evacuation Pause) 2477M->2478M(4096M) 472.079ms
[8.524s][info][gc] GC(8) Pause Young (G1 Evacuation Pause) 2656M->2659M(4096M) 434.435ms
[11.104s][info][gc] GC(6) Pause Remark 2761M->2761M(4096M) 1.020ms
[11.979s][info][gc] GC(6) Pause Cleanup 2761M->2215M(4096M) 2.446ms
[11.988s][info][gc] GC(6) Concurrent Cycle 5529.427ms

real  0m12.016s
user  0m34.588s
sys   0m0.964s
```


從 G1 運行結果中能觀察到什麼?年輕代的停頓時間從500至1000毫秒不等。到達穩定狀態後停頓開始減小,啓發式方法給出告終束停頓需回收多少內存。一段時間後,會進入併發 GC 階段直到結束(請注意年輕代與併發階段重疊)。接下來應該還有「混合停頓」,可是 VM 已經提早退出。這些不肯定的停頓是運行時間過長的罪魁禍首。


另外,能夠注意到「user」時間比「real」(時鐘時間)要長。因爲 GC 並行執行,而應用程序是單線程執行,所以 GC 會利用全部可用的並行機制從而讓收集時間變得比時鐘時間短。


4.2 Parallel


```shell
$ time java -XX:+UseParallelOldGC -Xms4G -Xmx4G -Xlog:gc AL
[0.023s][info][gc] Using Parallel
[1.579s][info][gc] GC(0) Pause Young (Allocation Failure) 878M->714M(3925M) 1144.518ms
[3.619s][info][gc] GC(1) Pause Young (Allocation Failure) 1738M->1442M(3925M) 1739.009ms

real  0m3.882s
user  0m11.032s
sys   0m1.516s
```


從 Parallel 結果中,能夠看到相似的年輕代停頓。緣由多是調整 Eden 區或 Survivor 區的大小以容納更多臨時分配的內存。這裏有兩次長停頓,完成任務總用時很短。當處於穩定狀態,回收器會保持相同頻率的長停頓。「user」時間一樣遠大於「real」時間,併發隱藏了一些 GC 開銷。


4.3 CMS(併發標記-清掃)


```shell
$ time java -XX:+UseConcMarkSweepGC -Xms4G -Xmx4G -Xlog:gc AL
[0.012s][info][gc] Using Concurrent Mark Sweep
[1.984s][info][gc] GC(0) Pause Young (Allocation Failure) 259M->231M(4062M) 1788.983ms
[2.938s][info][gc] GC(1) Pause Young (Allocation Failure) 497M->511M(4062M) 871.435ms
[3.970s][info][gc] GC(2) Pause Young (Allocation Failure) 777M->850M(4062M) 949.590ms
[4.779s][info][gc] GC(3) Pause Young (Allocation Failure) 1117M->1161M(4062M) 732.888ms
[6.604s][info][gc] GC(4) Pause Young (Allocation Failure) 1694M->1964M(4062M) 1662.255ms
[6.619s][info][gc] GC(5) Pause Initial Mark 1969M->1969M(4062M) 14.831ms
[6.619s][info][gc] GC(5) Concurrent Mark
[8.373s][info][gc] GC(6) Pause Young (Allocation Failure) 2230M->2365M(4062M) 1656.866ms
[10.397s][info][gc] GC(7) Pause Young (Allocation Failure) 3032M->3167M(4062M) 1761.868ms
[16.323s][info][gc] GC(5) Concurrent Mark 9704.075ms
[16.323s][info][gc] GC(5) Concurrent Preclean
[16.365s][info][gc] GC(5) Concurrent Preclean 41.998ms
[16.365s][info][gc] GC(5) Concurrent Abortable Preclean
[16.365s][info][gc] GC(5) Concurrent Abortable Preclean 0.022ms
[16.478s][info][gc] GC(5) Pause Remark 3390M->3390M(4062M) 113.598ms
[16.479s][info][gc] GC(5) Concurrent Sweep
[17.696s][info][gc] GC(5) Concurrent Sweep 1217.415ms
[17.696s][info][gc] GC(5) Concurrent Reset
[17.701s][info][gc] GC(5) Concurrent Reset 5.439ms

real  0m17.719s
user  0m45.692s
sys   0m0.588s
```


與通常見解相反,CMS 中的 「Concurrent」指年老代併發回收。正如結果中看到的,年輕代仍是處於萬物靜止狀態。GC 日誌看起來與 G1 相似:年輕代暫停,循環進行併發收集。區別在於,與 G1 「混合停頓」相比,「併發清掃」能夠不間斷清掃不會形成應用中止。年輕代 GC 停頓時間越長影響了任務的執行性能。


4.4 Shenandoah


```shell
$ time java -XX:+UseShenandoahGC -Xms4G -Xmx4G -Xlog:gc AL
[0.026s][info][gc] Using Shenandoah
[0.808s][info][gc] GC(0) Pause Init Mark 0.839ms
[1.883s][info][gc] GC(0) Concurrent marking 2076M->3326M(4096M) 1074.924ms
[1.893s][info][gc] GC(0) Pause Final Mark 3326M->2784M(4096M) 10.240ms
[1.894s][info][gc] GC(0) Concurrent evacuation  2786M->2792M(4096M) 0.759ms
[1.894s][info][gc] GC(0) Concurrent reset bitmaps 0.153ms
[1.895s][info][gc] GC(1) Pause Init Mark 0.920ms
[1.998s][info][gc] Cancelling concurrent GC: Stopping VM
[2.000s][info][gc] GC(1) Concurrent marking 2794M->2982M(4096M) 104.697ms

real  0m2.021s
user  0m5.172s
sys   0m0.420s
```


[Shenandoah][4] 回收器中沒有年輕代,至少今天如此。也有一些不引入分代進行部分回收的設想,但幾乎不可能避免萬物靜止的狀況。併發 GC 與應用同步啓動,初始化標記和結束併發標記引起了兩次小停頓。由於全部內容都處於活躍狀態沒有碎片化,因此併發拷貝不會引起停頓。第二次 GC 因爲 VM 關閉過早結束了。因爲沒有其它回收器那樣的長停頓,任務很快執行結束。


[4]:https://wiki.openjdk.java.net/display/shenandoah/Main


4.5 Epsilon


```shell
$ time java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4G -Xmx4G  -Xlog:gc AL
[0.031s][info][gc] Initialized with 4096M non-resizable heap.
[0.031s][info][gc] Using Epsilon GC
[1.361s][info][gc] Total allocated: 2834042 KB.
[1.361s][info][gc] Average allocation rate: 2081990 KB/sec

real  0m1.415s
user  0m1.240s
sys   0m0.304s
```


使用實驗性「no-op」 [Epsilon GC][5] 不會運行任何回收器,有助於評估 GC 開銷。 咱們能夠準確地放入預先設定好的 4GB 堆,應用運行過程當中沒有任何停頓。不過,發生任何忽然的變化都致使程序結束。注意,「real」和「user」 + 「sys」的時間幾乎相等,這證明了應用只有一個線程。


*譯註:Epsilon GC 處理內存分配,但不實現任何實際的內存回收機制。一旦可用的Java堆耗盡,JVM就會關閉。*


[5]:http://openjdk.java.net/jeps/318


5. 觀察


不一樣的 GC 實現有着各自的設計權衡,取消 GC 可看做一種延伸的「壞主意」。經過了解工做負載、性能要求以及可用的 GC 實現,才能根據實際狀況選擇合適的回收器。即便選擇不使用 GC 的目標平臺,仍然須要知道並選擇本機內存分配器。當運行實驗負載時,請試着理解運行結果並從中學習。祝你好運!

相關文章
相關標籤/搜索