Full GC 频繁但堆内存充足,可能的原因与排查步骤

解读

面试官抛出“堆内存充足却频繁 Full GC”这一看似矛盾的场景,核心想考察两点:

  1. 对 GC 触发条件的理解是否只停留在“内存不够用”这一层;
  2. 能否把现象→根因→证据→优化做成闭环,体现性能测试工程师“量化+定位+推动”的价值。
    国内互联网生产环境以 8 GB~32 GB 堆、G1/ZGC 为主,回答必须结合国内主流 JDK 版本(8/11/17)以及常用监控链(Prometheus+Grafana、Arthas、CAT、SkyWalking、ELK),避免纯理论堆砌。

知识点

  1. GC 触发条件:

    • 分配速率过快 → 新生代容纳不下 → 提前晋升 → 老年代涨 → Full GC;
    • 大对象直接进老年代(G1 的 Humongous 对象、Parallel 的 PretenureSizeThreshold);
    • 元空间/代码缓存满;
    • System.gc()、RMI、JMX、第三方库显式触发;
    • 内存碎片导致“足够大但无连续空间”触发 Full GC(CMS 的 Concurrent Mode Failure、G1 的 Evacuation Failure)。
  2. 国内常见“坑”:

    • 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 自救。
  3. 排查工具链:

    • 监控:Prometheus jmx_exporter + Grafana 模板看 GC 次数/耗时、老年代使用率、元空间、分配速率;
    • 现场:arthas 的 dashboard、heapdump、profiler;
    • 离线:MAT / jxray 分析 dominator tree,看 GC Root 到巨型对象的引用链;
    • 压测:用 Gatling/JMeter 把“并发+数据量+时长”三维拉到 SLA 上限,复现场景。

答案

“堆内存充足”只是容量维度,Full GC 频繁通常由“分配速率、对象生命周期、显式调用、碎片、元空间”五条线驱动,排查时分六步量化:

  1. 确认“充足”的真伪
    通过 Grafana 的老年代使用率曲线,观察 Full GC 触发瞬间老年代是否低于 70%。若低于,直接排除“内存不够”。

  2. 量化分配速率
    在 jmx_exporter 里看 jvm_gc_memory_allocated_bytes_total 的增速,若 > 1 GB/s,说明新生代太小或对象太大,导致提前晋升;调大 -Xmn 或降低 -XX:PretenureSizeThreshold 再压测对比。

  3. 检查大对象与巨型对象
    打开 -XX:+PrintGCDetails,G1 日志出现 “Humongous regions: X->Y” 且 X 持续 > 0,即可定位巨型对象;用 arthas profiler start --event alloc 采样,查看 char[]、byte[] 的分配栈,最常见的栈是 Fastjson 的 writeStringWithDoubleQuote。优化方式:

    • 升级 Fastjson1 到 Fastjson2 开启 JSONWriter.Feature.LargeObject;
    • 把 2 MB 以上的报文改用 Stream API 分段序列化;
    • 调整 -XX:G1HeapRegionSize=16m,减少 Humongous 区数量。
  4. 排查元空间/代码缓存
    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 在生产环境未关闭。
  5. 排查显式 System.gc()
    在 JVM 启动参数加 -XX:+DisableExplicitGC 后压测,若 Full GC 次数降为 0,说明代码或三方库调用了 System.gc();用 arthas trace java.lang.System gc 即可抓到调用栈。国内踩坑最多的是 RMI 的 sun.rmi.dgc.server.gcInterval 默认 1 小时,高并发服务建议 -Dsun.rmi.dgc.server.gcInterval=9223372036854775807

  6. 碎片导致分配失败
    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 以内,从根本上消除碎片概念。

闭环验证:按以上步骤改完,用同一压测脚本(并发 4 k、TPS 8 k、持续 30 min)再跑,对比 Full GC 次数从 18 次降到 0 次,99.9% 响应时间从 800 ms 降到 220 ms,老年代使用率稳定在 40% 以下,即可出具《性能测试报告》并推动上线。

拓展思考

  1. 如果换成 ZGC,堆内存 32 GB,压力测试中发现“无 Full GC 但 Allocation Stall 上涨”,该如何量化?
    提示:关注 ZGC Allocation Stall 事件与 ZGC Live Memory 比例,证明是回收速度跟不上分配速度,需调大 -XX:ZCollectionInterval=5 或降低 -XX:ZAllocationSpikeTolerance=2

  2. 在容器环境,cgroup v2 使用 memory.high 限流而非 OOM Kill,JVM 感受到的是“内存可用但申请不到”,频繁自触 Full GC,监控却显示老年代仅 30%,如何证明是 cgroup 限流?
    提示:通过 cat /sys/fs/cgroup/memory.statthrottled_timememory_high_events,同时用 sar -B 观察 pgscand/s 突增,即可形成“OS 限流→JVM 感知内存压力→Full GC”证据链。

  3. 对测试工程师而言,除了定位,更要在需求阶段把“GC 预算”写进 SLA。例如“接口 99% 响应 200 ms 内,Full GC 次数 0,YGC < 10 次/分钟”,如何把这种非功能需求转换成可验收的压测脚本?
    提示:在 Gatling 的 global 断言里加 jmxGC.fullGC.count <= 0,并通过 Jenkins 性能门禁插件做流水线卡点,实现“GC 劣化即失败”,把性能左移到版本发布前。