关于生产环境改用G1垃圾收集器的思考
阅读原文时间:2021年11月08日阅读:1

背景

由于我们的业务量非常大,响应延迟要求高。目前沿用的老的ParNew+CMS已经不能支撑业务的需求。平均一台机器在1个月内有1次秒级别的stop the world。对系统来说是个巨大的隐患。所以我们采用测试环境压测和逐渐在一些小的试点项目中生产环境引用G1来验证是否可以解决问题以及可能会引入的风险。

预备知识

垃圾回收首先要判断一个对象是不是垃圾,Java里不用引用计数器算法,都是用从GC root开始的可达性分析算法,在实际实现的时候就是标记。所以不管是什么新生代、老年代回收,都有标记的步骤。因为目前市面上能见到的版本都是从分代垃圾收集器开始的,所以更原始的这里就不再提了。

上图中的Serial、ParNew、Parallel Scavenge都是年轻代算法,CMS、Serial Old、Parallel Old是老年代算法。直接连接的线之间才可以配合使用。一般年轻代和老年代的总空间比例是1:2。小的年轻代可以保证更快的进行Young GC。

年轻代算法都是基于复制算法,准确的说是标记-复制算法。因为第一步是要先标记可达对象,然后把可达对象复制到一块空区域,再把原来的区域清空。区别只是Serial是串行的,Serial工作过程中用户线程都是停掉的。ParNew和Paralled Scavenge是并行的。所谓并行是指多个线程同时做垃圾收集的事情,但是仍然是要停下用户线程的工作的。Paralled Scavenge比ParNew的一个优势在于Paralled Scavenge可以设置自适应调节Eden与Survivor区的比例、晋升老年代的比例。

Serial Old老年代算法采用的是标记整理算法,Paralled Old老年代算法采用的也是标记整理算法,不同点只是一个是完全串行的,Paralled Old垃圾回收的时候有多个线程来跑,但是不可以跟用户线程一起跑。但是不管年轻代、老年代,以及目前市面上的所有算法都不能避免STW(stop the world停止用户线程)。

CMS是Concurrent Mark Sweep的缩写,就是并发标记清除算法,它与其他两种老年代算法不同的是它是只标记清除,不整理。目标是减少STW。上面的图中标记了CMS不能配合Paralled Scavenge使用,只能用ParNew。大家想想为啥咧。

上面说了Paralled Scavenge的优势在于可以自动调节。而CMS是只清除操作,不整理。这种算法没有办法应对空间的变化。我看到的文章都没有对它们为何不能配合使用做解释。所以这里强调下。

CMS过程分为下面4步:

上面4步中,初始标记和重新标记其实是一个东西执行两次,就是为了避免在并发标记过程中对象关系有变化。通常来讲STW引用线程的停顿时间:

Serial Old > Paralled Old > CMS。但是CMS有个致命的弱点,CMS必须要在老代码堆内存用尽之前完成垃圾回收,否则会触发担保机制,退化成Serial Old来垃圾回收,这时会造成较大的STW停顿。所以JDK1.8默认的垃圾收集器是Paralled Scavenge+Paralled Old方式。

G1垃圾回收

G1的设计目标是为了替代CMS,它不存在退化为Serial的问题,声称STW时间不超过10ms。主要的特点如下:

在15年16年的时候,很多公司都有使用G1的需求,但是那时候G1由于算法复杂,设计开发困难,所以还不成熟。在17年以后,已经被JDK9选为默认垃圾收集器。注意JDK8的默认垃圾收集器是Paralled Scanvenge,不采用CMS是因为CMS不稳定可能会退化成Serial Old。所以能被选为默认收集器说明它的稳定性是受官方认可的。

G1的原理是分治法,将堆分成若干个等大的区域。优先回收垃圾多的区域。

但是划分的区域之间有可能有相互引用。所以引出了Card Table和Rememberd Set的概念。Rememberd Set(RS)里存的是区域之间的引用。Card Table是把区域进一步细分。搜引用的时候只需要搜索很小的子区域。RS可以看成是一个哈希表,就是存引用关系的。是一种典型的空间换时间的做法。

对于每个区域使用的垃圾收集算法,实际上G1没有什么创新,年轻代还是并行拷贝,老年代主要采用并发标记配合增量压缩。算法方面也比较成熟了。

各种垃圾收集器的对比

怎样选择合适自己业务的垃圾收集器

从理论上,G1是为了替代CMS。我们这边的本质需求也是降低STW,也已经很成熟了。并发量大稳定性高的公司也在用。公司内部也有使用的经验。没有什么问题。

那就从实际上试验一把看看实际运行是否符合预期,并且要测试对G1专门的参数做微调。特别是MaxGCPauseMillis这个参数,因为这个参数设置的是预期每次GC的最大停顿时间。如果设置的不合理,比如太小就会造成GC频繁。如果太大,业务响应时间会很长。

实际上我有用实际代码模拟,但是为了信息安全这里自己用demo来说明。

JVM参数设置为:

-Xms4096m  //最大堆设置

-Xmx4096m  //最小堆设置

-XX:+UseG1GC  //使用G1垃圾收集器

-XX:MaxGCPauseMillis=20 //最大GC停顿时间,默认是200ms,这里设置20ms

-XX:+PrintGCDetails //打印GC详情日志

-XX:+PrintStringTableStatistics //打印字符串常量、引用常量统计

-XX:+PrintSafepointStatistics  //打印停顿原因

-XX:+PrintGCApplicationStoppedTime //停顿时间输出到GC日志中

上面参数中除了堆大小设置、使用G1和设置预期最大停顿时间外都是便于观察的统计信息。设置好之后可以根据自己的业务构造合适的案例。调整参数观察效果,同时也需要用cms的结果做对比。

[GC pause (G1 Humongous Allocation) (young), 0.0021237 secs]
[Parallel Time: 1.4 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 3885.1, Avg: 3885.5, Max: 3886.4, Diff: 1.3]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 1.3]
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.4, Diff: 0.4, Sum: 0.4]
[Object Copy (ms): Min: 0.0, Avg: 0.7, Max: 1.0, Diff: 1.0, Sum: 5.3]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]
[Termination Attempts: Min: 1, Avg: 6.5, Max: 14, Diff: 13, Sum: 52]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Total (ms): Min: 0.0, Avg: 0.9, Max: 1.3, Diff: 1.3, Sum: 7.4]
[GC Worker End (ms): Min: 3886.4, Avg: 3886.4, Max: 3886.4, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.6 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.1 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.2 ms]
[Humongous Reclaim: 0.1 ms]
[Free CSet: 0.0 ms]
[Eden: 4096.0K(200.0M)->0.0B(202.0M) Survivors: 4096.0K->2048.0K Heap: 3405.5M(4096.0M)->3402.0M(4096.0M)]
[Times: user=0.00 sys=0.00, real=0.00 secs]
Total time for which application threads were stopped: 0.0027839 seconds, Stopping threads took: 0.0000253 seconds
[Full GC (Allocation Failure) 3401M->3401M(4096M), 0.0487029 secs]
[Eden: 0.0B(202.0M)->0.0B(204.0M) Survivors: 2048.0K->0.0B Heap: 3402.0M(4096.0M)->3401.4M(4096.0M)], [Metaspace: 5853K->5853K(1056768K)]
[Times: user=0.06 sys=0.00, real=0.05 secs]
[Full GC (Allocation Failure) 3401M->3401M(4096M), 0.0376090 secs]
[Eden: 0.0B(204.0M)->0.0B(204.0M) Survivors: 0.0B->0.0B Heap: 3401.4M(4096.0M)->3401.4M(4096.0M)], [Metaspace: 5853K->5849K(1056768K)]
[Times: user=0.03 sys=0.00, real=0.04 secs]
Total time for which application threads were stopped: 0.0868891 seconds, Stopping threads took: 0.0000192 seconds

Heap
garbage-first heap total 4194304K, used 3483005K [0x00000006c0000000, 0x00000006c0204000, 0x00000007c0000000)
region size 2048K, 1 young (2048K), 0 survivors (0K)
Metaspace used 5924K, capacity 6074K, committed 6144K, reserved 1056768K
class space used 687K, capacity 722K, committed 768K, reserved 1048576K
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
1.121: no vm operation [ 12 0 1 ] [ 0 7 7 0 0 ] 0
1.226: Deoptimize [ 12 0 0 ] [ 0 0 0 0 0 ] 0
1.326: Deoptimize [ 12 0 0 ] [ 0 0 0 0 0 ] 0
1.399: Deoptimize [ 12 0 0 ] [ 0 0 0 0 0 ] 0
1.497: Deoptimize [ 12 0 0 ] [ 0 0 0 0 0 ] 0
2.409: G1IncCollectionPause [ 12 0 0 ] [ 0 0 0 0 4 ] 0
2.417: CGC_Operation [ 12 0 1 ] [ 0 1 1 0 5 ] 0
2.425: CGC_Operation [ 12 0 1 ] [ 0 4 4 0 1 ] 0
3.431: no vm operation [ 12 0 1 ] [ 0 17 17 0 0 ] 0
3.885: G1IncCollectionPause [ 12 0 0 ] [ 0 0 0 0 2 ] 0
3.888: G1CollectForAllocation [ 12 0 0 ] [ 0 0 0 0 86 ] 0
4.037: Exit [ 12 0 1 ] [ 0 0 0 0 339 ] 0

Polling page always armed
Deoptimize 4
CGC_Operation 2
G1CollectForAllocation 1
G1IncCollectionPause 2
Exit 1
0 VM operations coalesced during safepoint
Maximum sync time 17 ms
Maximum vm operation time (except for Exit VM operation) 86 ms
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 22112 = 530688 bytes, avg 24.000
Number of literals : 22112 = 932040 bytes, avg 42.151
Total footprint : = 1622816 bytes
Average bucket size : 1.105
Variance of bucket size : 1.111
Std. dev. of bucket size: 1.054
Maximum bucket size : 8
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 2944 = 70656 bytes, avg 24.000
Number of literals : 2944 = 238320 bytes, avg 80.951
Total footprint : = 789080 bytes
Average bucket size : 0.049
Variance of bucket size : 0.049
Std. dev. of bucket size: 0.222
Maximum bucket size : 3

G1日志会打印GC的详细过程,便于观察分析。PrintStringTableStatistics这个JVM参数打印出字符串常量信息,在JDK7之后,字符串常量从永久代被移到堆内存中了,所以也会影响GC。

建议做完调优之后,再用优化后的参数重跑用例,并jvisualvm这个jdk自带工具观察一段时间的GC情况。

总结

我总结是否采用一个工具或技术,常规思路是这样:

  1. 明确目标。这里的目标就是要降低STW造成的延迟。

  2. 调查学习。要理解原理、优缺点,多个技术之间对比。

  3. 测试验证。至少要用试验报告的形式给出测试过程和结论。

  4. 做出调整。根据测试结果做出可能的是大局上的调整,比如和目前的系统不兼容,或者是细节调整比如修改参数。

上面这个思路也就是完整的PDCA的过程。

一句话总结就是:目标先行,回绕目标来做事。

相关阅读

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章