77

[译] GC 设计与停顿

 4 years ago
source link: https://mp.weixin.qq.com/s/eCsutL7BRNaQG09Zjvpyqw?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

编译:唐尤华

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

1. 写在前面

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

Aleksey Shipilёv,JVM 性能极客 

推特 [@shipilev][2]  

问题、评论、建议发送到 [[email protected]][3]

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

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

[3]:[email protected]

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

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

关注「ImportNew」,提升Java技能

MJfaYrJ.jpg!web

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK