20 GC 日志解读与分析(实例分析下篇) 复习一下:G1 的全称是 Garbage-First,意为垃圾优先,哪一块的垃圾最多就优先清理它。

G1 相关的调优参数,可以参考: https://www.oracle.com/technical-resources/articles/java/g1gc.html

G1 使用示例:

/# 请注意命令行启动时没有换行 java -XX:+UseG1GC -Xms512m -Xmx512m -Xloggc:gc.demo.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps demo.jvm0204.GCLogAnalysis

运行之后,我们看看 G1 的日志长什么样:

Java HotSpot(TM) 64-Bit Server VM (25.162-b12) …… Memory: 4k page,physical 16777216k(709304k free) CommandLine flags: -XX:InitialHeapSize=536870912 -XX:MaxHeapSize=536870912 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC 2019-12-23T01:45:40.605-0800: 0.181: [GC pause (G1 Evacuation Pause) (young),0.0038577 secs] [Parallel Time: 3.1 ms,GC Workers: 8] …… 此处省略多行 [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.2 ms] [Other: 0.6 ms] …… 此处省略多行 [Eden: 25.0M(25.0M)->0.0B(25.0M) Survivors: 0.0B->4096.0K Heap: 28.2M(512.0M)->9162.7K(512.0M)] [Times: user=0.01 sys=0.01,real=0.00 secs] 2019-12-23T01:45:40.881-0800: 0.456: [GC pause (G1 Evacuation Pause) (young) (to-space exhausted),0.0147955 secs] [Parallel Time: 12.3 ms,GC Workers: 8] …… 此处省略多行 [Eden: 298.0M(298.0M)->0.0B(63.0M) Survivors: 9216.0K->26.0M Heap: 434.1M(512.0M)->344.2M(512.0M)] [Times: user=0.02 sys=0.05,real=0.02 secs] 2019-12-23T01:45:41.563-0800: 1.139: [GC pause (G1 Evacuation Pause) (mixed),0.0042371 secs] [Parallel Time: 3.7 ms,GC Workers: 8] …… 此处省略多行 [Eden: 20.0M(20.0M)->0.0B(34.0M) Survivors: 5120.0K->4096.0K Heap: 393.7M(512.0M)->358.5M(512.0M)] [Times: user=0.02 sys=0.00,real=0.00 secs] 2019-12-23T01:45:41.568-0800: 1.144: [GC pause (G1 Humongous Allocation) (young) (initial-mark),0.0012116 secs] [Parallel Time: 0.7 ms,GC Workers: 8] …… 此处省略多行 [Other: 0.4 ms] [Humongous Register: 0.1 ms] [Humongous Reclaim: 0.0 ms] [Eden: 2048.0K(34.0M)->0.0B(33.0M) Survivors: 4096.0K->1024.0K Heap: 359.5M(512.0M)->359.0M(512.0M)] [Times: user=0.01 sys=0.00,real=0.00 secs] 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-root-region-scan-start] 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-root-region-scan-end,0.0000360 secs] 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-mark-start] 2019-12-23T01:45:41.571-0800: 1.146: [GC concurrent-mark-end,0.0015209 secs] 2019-12-23T01:45:41.571-0800: 1.146: [GC remark 2019-12-23T01:45:41.571-0800: 1.147: [Finalize Marking,0.0002456 secs] 2019-12-23T01:45:41.571-0800: 1.147: [GC ref-proc,0.0000504 secs] 2019-12-23T01:45:41.571-0800: 1.147: [Unloading,0.0007297 secs], 0.0021658 secs] [Times: user=0.01 sys=0.00,real=0.00 secs] 2019-12-23T01:45:41.573-0800: 1.149: [GC cleanup 366M->366M(512M),0.0006795 secs] [Times: user=0.00 sys=0.00,real=0.00 secs] Heap garbage-first heap total 524288K,used 381470K […… region size 1024K,12 young (12288K),1 survivors (1024K) Metaspace used 3331K,capacity 4494K,committed 4864K,reserved 1056768K class space used 364K,capacity 386K,committed 512K,reserved 1048576K

以上是摘录的一部分 GC 日志信息。实际运行我们的示例程序1秒钟,可能会生成上千行的 GC 日志。

Evacuation Pause:young(纯年轻代模式转移暂停)

当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。

拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。

转移暂停的日志信息很长,为简单起见,我们去除了一些不重要的信息。在并发阶段之后我们会进行详细的讲解。此外,由于日志记录很多,所以并行阶段和“其他”阶段的日志将拆分为多个部分来进行讲解。

我们从 GC 日志中抽取部分关键信息: 2019-12-23T01:45:40.605-0800: 0.181: [GC pause (G1 Evacuation Pause) (young),0.0038577 secs] [Parallel Time: 3.1 ms,GC Workers: 8] …… worker 线程的详情,下面单独讲解 [Code Root Fixup: 0.0 ms] [Code Root Purge: 0.0 ms] [Clear CT: 0.2 ms] [Other: 0.6 ms] …… 其他琐碎任务,下面单独讲解 [Eden: 25.0M(25.0M)->0.0B(25.0M) Survivors: 0.0B->4096.0K Heap: 28.2M(512.0M)->9162.7K(512.0M)] [Times: user=0.01 sys=0.01,real=0.00 secs]

大家一起来分析:

  • [GC pause (G1 Evacuation Pause) (young),0.0038577 secs] :G1 转移暂停,纯年轻代模式;只清理年轻代空间。这次暂停在 JVM 启动之后 181ms 开始,持续的系统时间为 0.0038577 秒,也就是 3.8ms。
  • [Parallel Time: 3.1 ms,GC Workers: 8] :表明后面的活动由 8 个 Worker 线程并行执行,消耗时间为 3.1 毫秒(real time);

worker 是一种模式,类似于一个老板指挥多个工人干活。

  • …… :为阅读方便,省略了部分内容,可以参考前面的日志,下面紧接着也会讲解。
  • [Code Root Fixup: 0.0 ms] :释放用于管理并行活动的内部数据,一般都接近于零。这个过程是串行执行的。
  • [Code Root Purge: 0.0 ms] :清理其他部分数据,也是非常快的,如非必要基本上等于零。也是串行执行的过程。
  • [Other: 0.6 ms] :其他活动消耗的时间,其中大部分是并行执行的。
  • … :请参考后文。
  • [Eden: 25.0M(25.0M)->0.0B(25.0M) :暂停之前和暂停之后,Eden 区的使用量/总容量。
  • Survivors: 0.0B->4096.0K :GC 暂停前后,存活区的使用量。

Heap: 28.2M(512.0M)->9162.7K(512.0M)] :暂停前后,整个堆内存的使用量与总容量。

  • [Times: user=0.01 sys=0.01,real=0.00 secs] :GC 事件的持续时间。

说明:系统时间(wall clock time/elapsed time),是指一段程序从运行到终止,系统时钟走过的时间。一般系统时间都要比 CPU 时间略微长一点。

最繁重的 GC 任务由多个专用的 worker 线程来执行,下面的日志描述了它们的行为: [Parallel Time: 3.1 ms,GC Workers: 8] [GC Worker Start (ms): Min: 180.6,Avg: 180.6,Max: 180.7,Diff: 0.1] [Ext Root Scanning (ms): Min: 0.1,Avg: 0.3,Max: 0.6,Diff: 0.4,Sum: 2.1] [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.1,Diff: 0.1,Sum: 0.1] [Object Copy (ms): Min: 2.2,Avg: 2.5,Max: 2.7,Diff: 0.4,Sum: 19.8] [Termination (ms): Min: 0.0,Avg: 0.2,Max: 0.4,Diff: 0.4,Sum: 1.5] [Termination Attempts: Min: 1,Avg: 1.0,Max: 1,Diff: 0,Sum: 8] [GC Worker Other (ms): Min: 0.0,Avg: 0.0,Max: 0.0,Diff: 0.0,Sum: 0.1] [GC Worker Total (ms): Min: 2.9,Avg: 3.0,Max: 3.0,Diff: 0.1,Sum: 23.7] [GC Worker End (ms): Min: 183.6,Avg: 183.6,Max: 183.6,Diff: 0.0]

Worker 线程的日志信息解读:

  • [Parallel Time: 3.1 ms,GC Workers: 8] :前面介绍过,这表明下列活动由 8 个线程并行执行,消耗的时间为 3.1 毫秒(real time)。
  • GC Worker Start (ms) :GC 的 worker 线程开始启动时,相对于 pause 开始时间的毫秒间隔。如果 Min 和 Max 差别很大,则表明本机其他进程所使用的线程数量过多,挤占了 GC 的可用 CPU 时间。
  • Ext Root Scanning (ms) :用了多长时间来扫描堆外内存(non-heap)的 GC ROOT,如 classloaders、JNI 引用、JVM 系统 ROOT 等。后面显示了运行时间,“Sum”指的是 CPU 时间。
  • Update RS 、

Processed Buffers 、

Scan RS 这三部分也是类似的,

RS 是

Remembered Set 的缩写,可以参考前面章节。

  • Code Root Scanning (ms) :扫描实际代码中的 root 用了多长时间:例如线程栈中的局部变量。
  • Object Copy (ms) :用了多长时间来拷贝回收集中的存活对象。
  • Termination (ms) :GC 的 worker 线程用了多长时间来确保自身可以安全地停止,在这段时间内什么也不做,完成后 GC 线程就终止运行了,所以叫终止等待时间。
  • Termination Attempts :GC 的 worker 线程尝试多少次 try 和 teminate。如果 worker 发现还有一些任务没处理完,则这一次尝试就是失败的,暂时还不能终止。
  • GC Worker Other (ms) :其他的小任务, 因为时间很短,在 GC 日志将他们归结在一起。
  • GC Worker Total (ms) :GC 的 worker 线程工作时间总计。
  • [GC Worker End (ms) :GC 的 worker 线程完成作业时刻,相对于此次 GC 暂停开始时间的毫秒数。通常来说这部分数字应该大致相等,否则就说明有太多的线程被挂起,很可能是因为“坏邻居效应(noisy neighbor)”所导致的。

此外,在转移暂停期间,还有一些琐碎的小任务。 [Other: 0.6 ms] [Choose CSet: 0.0 ms] [Ref Proc: 0.3 ms] [Ref Enq: 0.0 ms] [Redirty Cards: 0.1 ms] [Humongous Register: 0.0 ms] [Humongous Reclaim: 0.0 ms] [Free CSet: 0.0 ms]

其他琐碎任务,这里只介绍其中的一部分:

  • [Other: 0.6 ms] :其他活动消耗的时间,其中很多是并行执行的。
  • Choose CSet :选择 CSet 消耗的时间,CSet 是 Collection Set 的缩写。
  • [Ref Proc: 0.3 ms] :处理非强引用(non-strong)的时间,进行清理或者决定是否需要清理。
  • [Ref Enq: 0.0 ms] :用来将剩下的 non-strong 引用排列到合适的 ReferenceQueue 中。
  • Humongous Register 、

Humongous Reclaim 大对象相关的部分,后面进行介绍。

  • [Free CSet: 0.0 ms] :将回收集中被释放的小堆归还所消耗的时间,以便他们能用来分配新的对象。

此次 Young GC 对应的示意图如下所示:

58726143.png

Concurrent Marking(并发标记)

当堆内存的总体使用比例达到一定数值时,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。

阶段 1:Initial Mark(初始标记)

可以在 Evacuation Pause 日志中的第一行看到(initial-mark)暂停,类似这样: 2019-12-23T01:45:41.568-0800: 1.144: [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0012116 secs]

当然,这里引发 GC 的原因是大对象分配,也可能是其他原因,例如:to-space exhausted,或者默认 GC 原因等等。

阶段 2:Root Region Scan(Root 区扫描)

此阶段标记所有从“根区域”可达的存活对象。

根区域包括:非空的区域,以及在标记过程中不得不收集的区域。

对应的日志: 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-root-region-scan-start] 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-root-region-scan-end,0.0000360 secs]

阶段 3:Concurrent Mark(并发标记)

对应的日志: 2019-12-23T01:45:41.569-0800: 1.145: [GC concurrent-mark-start] 2019-12-23T01:45:41.571-0800: 1.146: [GC concurrent-mark-end,0.0015209 secs]

阶段 4:Remark(再次标记)

对应的日志: 2019-12-23T01:45:41.571-0800: 1.146: [GC remark 2019-12-23T01:45:41.571-0800: 1.147: [Finalize Marking,0.0002456 secs] 2019-12-23T01:45:41.571-0800: 1.147: [GC ref-proc,0.0000504 secs] 2019-12-23T01:45:41.571-0800: 1.147: [Unloading,0.0007297 secs],0.0021658 secs] [Times: user=0.01 sys=0.00,real=0.00 secs]

阶段 5:Cleanup(清理)

最后这个清理阶段为即将到来的转移阶段做准备,统计小堆块中所有存活的对象,并将小堆块进行排序,以提升 GC 的效率。此阶段也为下一次标记执行必需的所有整理工作(house-keeping activities)——维护并发标记的内部状态。

要提醒的是,所有不包含存活对象的小堆块在此阶段都被回收了。有一部分任务是并发的:例如空堆区的回收,还有大部分的存活率计算,此阶段也需要一个短暂的 STW 暂停,才能不受应用线程的影响并完成作业。

这种 STW 停顿的对应的日志如下: 2019-12-23T01:45:41.573-0800: 1.149: [GC cleanup 366M->366M(512M),0.0006795 secs] [Times: user=0.00 sys=0.00,real=0.00 secs]

如果发现某些小堆块中只包含垃圾,则日志格式可能会有点不同,如:

2019-12-23T21:26:42.411-0800: 0.689: [GC cleanup 247M->242M(512M),0.0005349 secs] [Times: user=0.00 sys=0.00,real=0.00 secs] 2019-12-23T21:26:42.412-0800: 0.689: [GC concurrent-cleanup-start] 2019-12-23T21:26:42.412-0800: 0.689: [GC concurrent-cleanup-end,0.0000134 secs]

如果你在执行示例程序之后没有看到对应的 GC 日志,可以多跑几遍试试。毕竟 GC 和内存分配属于运行时动态的,每次运行都可能有些不同。

我们在示例程序中生成的数组大小和缓存哪个对象都是用的随机数,每次运行结果都不一样。

请思考一下我们学过的 Java 随机数 API,有什么办法让每次生成的随机数结果都一致呢?

如有不了解的同学,请搜索:随机数种子。

标记周期一般只在碰到 region 中一个存活对象都没有的时候,才会顺手处理一把,大多数情况下都不释放内存。

示意图如下所示:

52452256.png

Evacuation Pause(mixed)(转移暂停:混合模式)

并发标记完成之后,G1 将执行一次混合收集(mixed collection),不只清理年轻代,还将一部分老年代区域也加入到 collection set 中。

混合模式的转移暂停(Evacuation Pause)不一定紧跟并发标记阶段。

在并发标记与混合转移暂停之间,很可能会存在多次 Young 模式的转移暂停。 “混合模式”就是指这次 GC 事件混合着处理年轻代和老年代的 region。这也是 G1 等增量垃圾收集器的特色。

而 ZGC 等最新的垃圾收集器则不使用分代算法。当然,以后可能还是会实现分代的,毕竟分代之后性能还会有提升。

混合模式下的日志,和纯年轻代模式相比,可以发现一些有趣的地方:

2019-12-23T21:26:42.383-0800: 0.661: [GC pause (G1 Evacuation Pause) (mixed),0.0029192 secs] [Parallel Time: 2.2 ms,GC Workers: 8] …… [Update RS (ms): Min: 0.1,Avg: 0.2,Max: 0.3,Diff: 0.2,Sum: 1.4] [Processed Buffers: Min: 0,Avg: 1.8,Max: 3,Diff: 3,Sum: 14] [Scan RS (ms): Min: 0.0,Avg: 0.0,Max: 0.0,Diff: 0.0,Sum: 0.1] …… [Clear CT: 0.4 ms] [Other: 0.4 ms] [Choose CSet: 0.0 ms] [Ref Proc: 0.1 ms] [Ref Enq: 0.0 ms] [Redirty Cards: 0.1 ms] [Free CSet: 0.1 ms] [Eden: 21.0M(21.0M)->0.0B(21.0M) Survivors: 4096.0K->4096.0K Heap: 337.7M(512.0M)->274.3M(512.0M)] [Times: user=0.01 sys=0.00,real=0.00 secs]

简单解读(部分概念和名称,可以参考 G1 章节):

  • [Update RS (ms) :因为 Remembered Sets 是并发处理的,必须确保在实际的垃圾收集之前,缓冲区中的 card 得到处理。如果 card 数量很多,则 GC 并发线程的负载可能就会很高。可能的原因是修改的字段过多,或者 CPU 资源受限。
  • Processed Buffers :各个 worker 线程处理了多少个本地缓冲区(local buffer)。
  • Scan RS (ms) :用了多长时间扫描来自 RSet 的引用。
  • [Clear CT: 0.4 ms] :清理 card table 中 cards 的时间。清理工作只是简单地删除“脏”状态,此状态用来标识一个字段是否被更新的,供 Remembered Sets 使用。
  • [Redirty Cards: 0.1 ms] :将 card table 中适当的位置标记为 dirty 所花费的时间。“适当的位置”是由 GC 本身执行的堆内存改变所决定的,例如引用排队等。

Full GC(Allocation Failure)

G1 是一款自适应的增量垃圾收集器。一般来说,只有在内存严重不足的情况下才会发生 Full GC。比如堆空间不足或者 to-space 空间不足。

在前面的示例程序基础上,增加缓存对象的数量,即可模拟 Full GC。

示例日志如下: 2020-03-02T18:44:17.814-0800: 2.826: [Full GC (Allocation Failure) 403M->401M(512M),0.0046647 secs] [Eden: 0.0B(25.0M)->0.0B(25.0M) Survivors: 0.0B->0.0B Heap: 403.6M(512.0M)->401.5M(512.0M)], [Metaspace: 2789K->2789K(1056768K)] [Times: user=0.00 sys=0.00,real=0.00 secs]

因为我们的堆内存空间很小,存活对象的数量也不多,所以这里看到的 Full GC 暂停时间很短。

此次 Full GC 的示意图如下所示:

59111406.png

在堆内存较大的情况下(8G+),如果 G1 发生了 Full GC,暂停时间可能会退化,达到几十秒甚至更多。如下面这张图片所示:

5b03ee3d-1e0a-4375-a5f6-aab17f4d1184.jpg

从其中的 OldGen 部分可以看到,118 次 Full GC 消耗了 31 分钟,平均每次达到 20 秒,按图像比例可粗略得知,吞吐率不足 30%。

这张图片所表示的场景是在压测 Flink 按时间窗口进行聚合计算时发生的,主要原因是对象太多,堆内存空间不足而导致的,修改对象类型为原生数据类型之后问题得到缓解,加大堆内存空间,满足批处理/流计算的需求之后 GC 问题不再复现。

发生持续时间很长的 Full GC 暂停时,就需要我们进行排查和分析,确定是否需要修改 GC 配置,或者增加内存,还是需要修改程序的业务逻辑。关于 G1 的调优,我们在后面的调优部分再进行介绍。

关于 G1 的日志分析,到此就告一段落了,后面我们看看番外篇,怎么用可视化的工具来查看和分析 GC 日志。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/JVM%20%e6%a0%b8%e5%bf%83%e6%8a%80%e6%9c%af%2032%20%e8%ae%b2%ef%bc%88%e5%ae%8c%ef%bc%89/20%20GC%20%e6%97%a5%e5%bf%97%e8%a7%a3%e8%af%bb%e4%b8%8e%e5%88%86%e6%9e%90%ef%bc%88%e5%ae%9e%e4%be%8b%e5%88%86%e6%9e%90%e4%b8%8b%e7%af%87%ef%bc%89.md