巨大的 CopyOnWriteArrayList 导致的频繁 full GC 的症状

前2天看到有台机器的 Java 应用突然 CPU 高企, 再看其它metrics, 发现是由 GC 导致的, 但是看到 JVM available 内存, 却有很多, 再去观察 verboseGC log, 形状也很奇怪. 它到底是什么原因导致的呢?

症状表现

首先, 我们看到下面的metrics, CPU 几乎100%, GC overhead 几乎是100%, 然而可用内存却有5G空闲, 如下图:
perf.png

初步分析

由于有很多的可用内存, 所以怀疑是不是显式调用 System.gc() 或者 Runtime.gc() 导致的, 于是去查看 verbose GC 日志, 发现并没有找到这些关键字. 于是观察了 verbose GC的形状, 发现很奇怪的地方, 如下图:
gcView.png

这是截取从刚开始 full GC 到 一直 full GC的那段, 从图中可以看到, 年轻代大小一直缩小, 老年代空间一直增大, 老年代被频繁回收(full GC 导致), 每次回收都能回到一个稳定值, 然后又继续快速大量占用, 再次被回收到稳定值.

这是有关heap的相关参数(从verbose GC log 头部截取):

2022-11-23 05:46:52 GC log file created ./log/verbosegc.log.1
OpenJDK 64-Bit Server VM (25.342-b07) for linux-amd64 JRE (Zulu 8.64.0.16-SA-linux64) (1.8.0_342-b07), built on Jul 20 2022 08:43:38 by "zulu_re" with gcc 7.3.0
Memory: 4k page, physical 45613056k(29362788k free), swap 0k(0k free)
CommandLine flags: -XX:CompileCommand=exclude,org/mozilla/javascript/gen/*.* -XX:CompileThreshold=1000 -XX:CompressedClassSpaceSize=528482304 -XX:ConcGCThreads=2 -XX:+DisableExplicitGC -XX:G1HeapRegionSize=4194304 -XX:GCLogFileSize=5120000 -XX:InitialHeapSize=12884901888 -XX:+ManagementServer -XX:MarkStackSize=4194304 -XX:MaxDirectMemorySize=1073741824 -XX:MaxHeapSize=12884901888 -XX:MaxMetaspaceSize=536870912 -XX:MaxNewSize=7730102272 -XX:MinHeapDeltaBytes=4194304 -XX:NumberOfGCLogFiles=40 -XX:ParallelGCThreads=6 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintTenuringDistribution -XX:ReservedCodeCacheSize=251658240 -XX:-TieredCompilation -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseG1GC -XX:+UseGCLogFileRotation 
2022-11-23T05:46:52.810-0700: 882083.527: [GC pause (G1 Evacuation Pause) (young)

从上面的参数可以看到, 这个JVM 的heap 设置的最大值是12G, 结合 verbose GC log的数据, 我们可以看到老年代已经扩展到 10G 多, 每次回收能回收到大概5.6G的样子, 到底是什么对象突然扩展到10G, 然后又能突然缩小呢? 年轻代, 那么小, 从其中移过来的对象, 一般也不大. 那么只有某些直接分配在老年代的对象才可能导致这样. 于是我们做个heap dump 然后做分析.

检查分析

对heap dump的分析很快给我们了答案, 又一个数组太大了:
leak.png

从图中可见, 这个Object[]竟然有145万个元素, 占用了4.1G的heap 空间. 然后我们看它在哪里被使用的, 找到线程栈, 如下图:
add.png

从上面的线程运行栈可以看到, 这个 Object[] 是CopyOnWriteArrayList的内部表示, 而它正在执行 add() 操作, 如果我们去查看 CopyOnWriteArrayList 的 add() 方法, 我们会发现, 每次 add 一个新元素, 它都会复制一块新内存, 把新元素加到结尾, 而给这个 4.1G 的数组加一个新元素, 会导致系统要再申请一个 4.1G多的空间, 把新元素添加到末尾, 这就解释了为啥快速的申请, 做完就释放, 由于4.1G 的新空间只能在老年代申请(年轻代只有2G了), 所以频繁 full GC.

所以这里使用 CopyOnWriteArrayList 就是一个错误, 而如果每个元素都是这么一个个加进来的, 那么对系统来说, 简直是一场巨大灾难.

标签: none

添加新评论