Full GC 频繁但堆内存充足,可能的原因与排查步骤
解读
面试官抛出“堆内存充足却频繁 Full GC”这一看似矛盾的场景,核心想考察两点:
- 对 GC 触发条件的理解是否只停留在“内存不够用”这一层;
- 能否把现象→根因→证据→优化做成闭环,体现性能测试工程师“量化+定位+推动”的价值。
国内互联网生产环境以 8 GB~32 GB 堆、G1/ZGC 为主,回答必须结合国内主流 JDK 版本(8/11/17)以及常用监控链(Prometheus+Grafana、Arthas、CAT、SkyWalking、ELK),避免纯理论堆砌。
知识点
-
GC 触发条件:
- 分配速率过快 → 新生代容纳不下 → 提前晋升 → 老年代涨 → Full GC;
- 大对象直接进老年代(G1 的 Humongous 对象、Parallel 的 PretenureSizeThreshold);
- 元空间/代码缓存满;
- System.gc()、RMI、JMX、第三方库显式触发;
- 内存碎片导致“足够大但无连续空间”触发 Full GC(CMS 的 Concurrent Mode Failure、G1 的 Evacuation Failure)。
-
国内常见“坑”:
- Spring Cloud 微服务滥用 @Scheduled 反射生成大量类,元空间暴涨;
- MySQL 8 驱动、Dubbo 3、Fastjson 在高并发下产生巨型临时 char[]/byte[];
- Netty 池化参数配错,ByteBuf 不释放,老年代引用链看似“充足”实则无法回收;
- 日志框架 AsyncAppender 队列长度无限,撑爆老年代;
- 容器 cgroup 内存上限 4 GB,JVM 堆设 3.5 GB,OS 页缓存挤占,触发 OOM Killer 前 JVM 先 Full GC 自救。
-
排查工具链:
- 监控:Prometheus jmx_exporter + Grafana 模板看 GC 次数/耗时、老年代使用率、元空间、分配速率;
- 现场:arthas 的 dashboard、heapdump、profiler;
- 离线:MAT / jxray 分析 dominator tree,看 GC Root 到巨型对象的引用链;
- 压测:用 Gatling/JMeter 把“并发+数据量+时长”三维拉到 SLA 上限,复现场景。
答案
“堆内存充足”只是容量维度,Full GC 频繁通常由“分配速率、对象生命周期、显式调用、碎片、元空间”五条线驱动,排查时分六步量化:
-
确认“充足”的真伪
通过 Grafana 的老年代使用率曲线,观察 Full GC 触发瞬间老年代是否低于 70%。若低于,直接排除“内存不够”。 -
量化分配速率
在 jmx_exporter 里看jvm_gc_memory_allocated_bytes_total的增速,若 > 1 GB/s,说明新生代太小或对象太大,导致提前晋升;调大-Xmn或降低-XX:PretenureSizeThreshold再压测对比。 -
检查大对象与巨型对象
打开-XX:+PrintGCDetails,G1 日志出现 “Humongous regions: X->Y” 且 X 持续 > 0,即可定位巨型对象;用 arthasprofiler start --event alloc采样,查看 char[]、byte[] 的分配栈,最常见的栈是 Fastjson 的writeStringWithDoubleQuote。优化方式:- 升级 Fastjson1 到 Fastjson2 开启 JSONWriter.Feature.LargeObject;
- 把 2 MB 以上的报文改用 Stream API 分段序列化;
- 调整
-XX:G1HeapRegionSize=16m,减少 Humongous 区数量。
-
排查元空间/代码缓存
Grafana 模板里若jvm_memory_used_bytes{area="nonheap",id="Metaspace"}呈锯齿形上涨,Full GC 触发点与锯齿顶端重合,说明元空间不足;加上-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m并打开-XX:+TraceClassLoading,观察是否每次 GC 后仍有数千类加载。常见根因:- Groovy、Aviator、LiteFlow 等动态脚本引擎未做 ClassLoader 缓存;
- Spring AOP 每次代理都生成新类;
- 热部署插件 spring-devtools 在生产环境未关闭。
-
排查显式 System.gc()
在 JVM 启动参数加-XX:+DisableExplicitGC后压测,若 Full GC 次数降为 0,说明代码或三方库调用了System.gc();用 arthastrace java.lang.System gc即可抓到调用栈。国内踩坑最多的是 RMI 的sun.rmi.dgc.server.gcInterval默认 1 小时,高并发服务建议-Dsun.rmi.dgc.server.gcInterval=9223372036854775807。 -
碎片导致分配失败
CMS 日志出现 “Concurrent Mode Failure” 或 G1 出现 “Evacuation Failure”,但老年代使用率仅 50% 左右,即可判定碎片;解决:- CMS 调大
-XX:CMSInitiatingOccupancyFraction=75并开启-XX:+UseCMSCompactAtFullCollection; - G1 调大
-XX:G1MixedGCCountTarget=8 -XX:G1HeapWastePercent=5,让混合 GC 更早、更频繁地回收碎片; - 升级到 JDK17+ZGC,把暂停时间压到 10 ms 以内,从根本上消除碎片概念。
- CMS 调大
闭环验证:按以上步骤改完,用同一压测脚本(并发 4 k、TPS 8 k、持续 30 min)再跑,对比 Full GC 次数从 18 次降到 0 次,99.9% 响应时间从 800 ms 降到 220 ms,老年代使用率稳定在 40% 以下,即可出具《性能测试报告》并推动上线。
拓展思考
-
如果换成 ZGC,堆内存 32 GB,压力测试中发现“无 Full GC 但 Allocation Stall 上涨”,该如何量化?
提示:关注ZGC Allocation Stall事件与ZGC Live Memory比例,证明是回收速度跟不上分配速度,需调大-XX:ZCollectionInterval=5或降低-XX:ZAllocationSpikeTolerance=2。 -
在容器环境,cgroup v2 使用 memory.high 限流而非 OOM Kill,JVM 感受到的是“内存可用但申请不到”,频繁自触 Full GC,监控却显示老年代仅 30%,如何证明是 cgroup 限流?
提示:通过cat /sys/fs/cgroup/memory.stat看throttled_time与memory_high_events,同时用sar -B观察pgscand/s突增,即可形成“OS 限流→JVM 感知内存压力→Full GC”证据链。 -
对测试工程师而言,除了定位,更要在需求阶段把“GC 预算”写进 SLA。例如“接口 99% 响应 200 ms 内,Full GC 次数 0,YGC < 10 次/分钟”,如何把这种非功能需求转换成可验收的压测脚本?
提示:在 Gatling 的global断言里加jmxGC.fullGC.count <= 0,并通过 Jenkins 性能门禁插件做流水线卡点,实现“GC 劣化即失败”,把性能左移到版本发布前。