JVM Full GC 导致接口超时:一次线上排查实录

一次大促前的压测中,核心下单接口 P99 从 80ms 飙到 3s,监控显示老年代在频繁 Full GC。本文复盘我们如何用 jstat、GC 日志与 Arthas 三步定位根因,并给出 JVM 调优与代码层修复方案,避免接口超时再次发生。

一、现象:接口超时与告警连环触发

问题最初表现为「偶发慢请求」。压测进行到第 8 分钟,APM 面板里下单接口的 P99 从 80ms 一路爬升到 3000ms,TPS 同时腰斩。紧接着容器健康检查开始间歇性失败,K8s 把 Pod 重启,但重启后几分钟内又复现——典型的“假死→重启→复现”循环。

登录节点后第一反应是看 CPU,结果 CPU 使用率只有 35%,并不高。这基本排除了死循环或计算密集,反而指向了停顿型问题:线程大部分时间在等待,而不是在计算。

二、排查思路:先确认是不是 GC 的问题

吞吐下降但 CPU 不高,最常见的元凶就是 Stop-The-World。我们用两个轻量手段快速确认。

2.1 用 jstat 看 GC 频率

jstat 不需要重启进程,对线上最友好。下面这条命令每 1 秒采样一次 GC 情况:

# 每 1s 打印一次 GC 统计,-gcutil 看各分区使用率百分比
jstat -gcutil <pid> 1000

# 输出示例(FGC 在持续上涨)
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  99.12  88.40  99.97  94.30  91.05   182    12.4    47     61.8     74.2
  0.00  99.12  90.10  99.98  94.30  91.05   182    12.4    48     63.1     75.5

关键看两列:O(老年代使用率)长期卡在 99% 以上,FGC(Full GC 次数)和 FGCT(Full GC 总耗时)在持续上涨。一次 Full GC 就要停顿 1~2 秒,刚好对上接口超时的节奏。

2.2 打开 GC 日志交叉验证

如果启动时没开 GC 日志,可以用 jcmd 动态开启,无需重启:

# 动态开启 GC 日志(JDK 11+)
jcmd <pid> VM.log what=gc* logfile=/tmp/gc-%p.log

# 典型日志:老年代回收后几乎没腾出空间
[Full GC (Allocation Failure) ... [PSOldGen: 4095M->4094M(4096M)] 5119M->4094M ... 1842ms]

注意看 Full GC 前后老年代的数值:回收前 4095M,回收后 4094M——几乎没回收掉任何对象。这说明老年代里塞满了存活的大对象,GC 回收不动,只能反复 Full GC。

三、用 Arthas 在线定位大对象

确认是内存里的大对象后,下一步是找出“谁”占了这么多。Arthas 的 dashboardheapdump 能在线分析,不重启进程。

3.1 找出占用内存的“元凶”

# 启动 Arthas 并 attach 到目标进程
java -jar arthas-boot.jar

# 查看内存中实例最多的类(TOP 10)
dashboard

# 直接统计某个类的实例数与占用字节
sc -d com.xxx.OrderCache
vmtool --action getInstances --className com.xxx.OrderCache --limit 100

排查结果很直白:OrderCache 这个本该是“临时缓存”的对象,实例数高达 120 万,占用约 3.2GB,几乎吃满老年代。进一步的 heapdump 分析显示,它是一个没有容量上限的本地缓存

四、根因:缓存未限大小

翻代码发现,下单时为了“提速”,把每次请求的订单快照塞进一个 ConcurrentHashMap,本意是短时复用,却忘了设置过期和上限。压测流量一大,缓存只进不出,老年代被迅速填满,触发连续 Full GC,所有业务线程被 STW 卡住,于是接口集体超时。

这类问题在 Spring Boot 3 升级踩坑实录 里也提到过:框架升级后默认行为变化,最容易暴露出原本就存在的资源未释放隐患。Java 应用的内存管理,Java 21 虚拟线程 能提升并发吞吐,但救不了“对象泄漏”这种根因问题。

五、修复方案

5.1 代码层:换成有界缓存

// 用 Caffeine 替代无界 Map,设置最大条数与过期时间
Cache<String, OrderSnapshot> orderCache = Caffeine.newBuilder()
        .maximumSize(10_000)              // 硬上限,杜绝无限增长
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .recordStats()
        .build();

5.2 JVM 参数调优

代码修好后,JVM 参数也做了针对性调整,降低单次 Full GC 的影响面:

-Xms4g -Xmx4g                 # 堆初始=最大,避免动态扩容抖动
-XX:+UseG1GC                 # 低延迟收集器,代替默认 ParallelGC
-XX:MaxGCPauseMillis=200     # 目标最大停顿 200ms
-XX:InitiatingHeapOccupancyPercent=35  # 提前启动并发标记
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
参数调整前调整后作用
收集器ParallelGCG1GC降低 STW 停顿
堆大小默认动态-Xms=-Xmx=4g消除扩容抖动
本地缓存无界 MapCaffeine 限容+过期根治对象堆积
监控OOM 自动 dump下次可秒级定位

六、验证与复盘

修复后重新压测:Full GC 次数从每分钟 5~6 次降到 0,P99 稳定在 90ms 以内,TPS 提升约 3 倍。后续我们把 GC 日志与 GitHub Actions 流水线里的压测回归测试绑定,每次发布都自动跑一轮压测,避免同类问题再次上线。

复盘三点经验:① 接口超时先查 GC 再查 CPU;② 任何本地缓存都必须有容量上限;③ 压测不仅要看 TPS,更要盯住 Full GC 次数。把可观测性做在前面,排查时间能从几小时压缩到几分钟。

上一篇 后端开发者必备的 10 个命令行效率工具(2026 实测)
下一篇 MCP 与 A2A:2026 AI Agent 协议选型指南