查看原文
其他

GC 设计与停顿

ImportNew ImportNew 2019-10-03

(给ImportNew加星标,提高Java技能)


编译:唐尤华

链接:shipilev.net/jvm/anatomy-quarks/3-gc-design-and-pauses/


1. 写在前面


“[JVM 解剖公园][1]”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。


Aleksey Shipilёv,JVM 性能极客 

推特 [@shipilev][2]  

问题、评论、建议发送到 [aleksey@shipilev.net][3]


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

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

[3]:aleksey@shipilev.net


 2. 问题


如果说垃圾回收是敌人,那么绝不能害怕,因为恐惧会让人逐步死去直至彻底消亡。等等,这里究竟要讨论什么问题?好吧,这里要讨论的是,“在 `ArrayList` 中分配1亿个对象会让 Java ‘打嗝’“ 是真的吗?


 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 的目标平台,仍然需要知道并选择本机内存分配器。当运行实验负载时,请试着理解运行结果并从中学习。祝你好运!


推荐阅读

(点击标题可跳转阅读)

JDK 11 将引入低延迟 GC,大幅度缩短 GC 暂停时长

减少 GC 开销的 5 个编码技巧

杂谈 GC


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

喜欢就点一下「好看」呗~


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存