2024 年我们一个查询服务收到大量"接口偶尔很卡"的反馈。监控上看平均响应时间正常,但 P99 像心电图一样,每隔三五分钟就尖刺一次,卡顿能到两三秒。一开始以为是慢 SQL 或网络抖动,查了一圈都不是。最后打开 GC 日志才看清:服务在频繁地做 Full GC,每次 STW(Stop The World)就是两三秒,接口在那一刻整个被冻住。投了几天做 GC 专项治理,本文复盘这次"频繁 Full GC"的排查实战。
问题背景
业务:商品查询服务,Spring Boot,8C16G,JDK 8
事故现象:
- 接口 P99 周期性尖刺,每 3-5 分钟一次,卡顿 2-3 秒
- 平均 RT 正常,所以监控大盘上不明显,靠用户投诉才发现
- CPU 在卡顿瞬间冲高,过后回落
现场排查:
# 1. 加 GC 日志参数后重启(JDK 8):
# -XX:+PrintGCDetails -XX:+PrintGCDateStamps
# -Xloggc:/www/wwwroot/app/logs/gc.log
# -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5
# -XX:GCLogFileSize=20M
# 2. 看 gc.log,触目惊心:
2024-xx-xx T10:02:13 [Full GC (Ergonomics)
... 2847.331 ms] <- 一次 Full GC,STW 2.8 秒
2024-xx-xx T10:05:41 [Full GC (Ergonomics)
... 2613.882 ms] <- 3 分钟后又一次
# 几分钟一次 Full GC,每次 STW 两三秒
# 3. jstat 看 GC 频率和各区情况
$ jstat -gcutil 12345 1000 5
S0 S1 E O M YGC YGCT FGC FGCT
0.00 12.3 68.4 98.7 95.2 1820 41.2 612 1583.7
# O(老年代)长期 98%+,FGC 已经 612 次,累计 1583 秒
# 老年代一直接近满,Full GC 反复回收又反复填满
根因初判:
1. 老年代被某些对象长期占满,Full GC 回收不掉多少
2. 回收不掉 -> 很快又满 -> 再次 Full GC -> 频繁 STW
3. 需要 dump 下来看,到底是什么对象堆在老年代里
修复 1:看懂 GC 日志,分清 Young GC 与 Full GC
# === 一次正常的 Young GC(频繁、很快,可接受)===
[GC (Allocation Failure)
[PSYoungGen: 1398016K->174752K(1398272K)]
2089012K->substantial... , 0.0381 ms]
# 含义:年轻代从 1398M 回收到 174M,耗时 38ms
# Young GC 频繁是正常的,只要单次很快(几十 ms),不影响业务
# === 一次 Full GC(应当很少,一旦频繁就是大问题)===
[Full GC (Ergonomics)
[PSYoungGen: 174752K->0K]
[ParOldGen: 2796543K->2795011K(2796544K)] <- 注意这里!
... , 2847.331 ms]
# 关键看 ParOldGen:2796543K -> 2795011K
# 回收前后老年代几乎没变!回收了个寂寞,只腾出 1.5M
# 这说明:老年代里的对象都是"活的",GC 根本清不掉
# === 判断 GC 是否健康的几个信号 ===
# 健康:Young GC 频繁但单次 < 50ms;Full GC 极少(几小时一次甚至没有)
# 不健康信号 1:Full GC 频繁(分钟级)
# 不健康信号 2:Full GC 后老年代几乎不下降 -> 内存泄漏 / 缓存无界
# 不健康信号 3:单次 GC STW 时间过长(秒级)
#
# 我们的情况:信号 1 + 信号 2 同时出现 -> 老年代有东西清不掉
修复 2:用 jmap / dump 定位老年代里的内存大户
# === 1. jmap 看堆内对象直方图(快速,不用 dump 整个堆)===
$ jmap -histo:live 12345 | head -15
num #instances #bytes class name
----------------------------------------------
1: 8,234,011 1,053,953,408 java.lang.String
2: 8,233,900 263,484,800 java.util.HashMap$Node
3: 4,116,950 131,742,400 com.x.ProductDetail <- 可疑
4: 2,058,000 49,392,000 [Ljava.lang.Object;
# 八百多万个 String、四百多万个 ProductDetail
# 一个查询服务,内存里怎么会常驻这么多商品详情对象?
# === 2. 完整 dump 下来用 MAT 深挖 ===
$ jmap -dump:live,format=b,file=/tmp/heap.hprof 12345
# 拉到本地,MAT 打开,看 Dominator Tree:
# MAT 分析结果:
# 一个 static 的 ConcurrentHashMap 占了堆的 76%
# com.x.LocalProductCache.CACHE
# -> 持有 410 万个 ProductDetail
# -> 这个 Map 从服务启动后,只增不减
// === 找到元凶:一个没有任何上限的"本地缓存" ===
public class LocalProductCache {
// static Map,GC Root 可达,里面的东西永远不会被回收
private static final Map<Long, ProductDetail> CACHE =
new ConcurrentHashMap<>();
public static ProductDetail get(Long id) {
// 缓存没有就查库,查到就塞进 CACHE —— 然后【永远不删】
return CACHE.computeIfAbsent(id, LocalProductCache::loadFromDb);
}
}
// 问题:
// 1. 商品 ID 是海量的,每查一个新商品就往 CACHE 里加一个
// 2. CACHE 没有容量上限、没有过期、没有淘汰策略
// 3. 跑得越久,CACHE 越大,最后把老年代占满
// 4. 这些对象全是"活的"(被 static Map 强引用),Full GC 清不掉
// 这是一个典型的【内存泄漏式本地缓存】。
修复 3:给本地缓存装上"上限"
// === 错误的本地缓存:裸 HashMap,无界 ===
// private static final Map<Long, ProductDetail> CACHE = new ConcurrentHashMap<>();
// === 正确:用带淘汰策略的缓存(Caffeine)===
public class LocalProductCache {
private static final Cache<Long, ProductDetail> CACHE =
Caffeine.newBuilder()
.maximumSize(50_000) // 最多 5 万条,超了按 LRU 淘汰
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入 10 分钟后过期
.recordStats() // 开启命中率统计
.build();
public static ProductDetail get(Long id) {
return CACHE.get(id, LocalProductCache::loadFromDb);
}
}
// maximumSize 是关键:本地缓存的内存占用从此有了硬上限,
// 不会再随运行时间无限膨胀。expireAfterWrite 让数据不会太陈旧。
// === 如果只是想要一个简易的有界 LRU,LinkedHashMap 也能做 ===
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LruCache(int maxSize) {
super(16, 0.75f, true); // accessOrder=true,按访问顺序排
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize; // 超过上限,自动移除最久未访问的
}
}
// === 核心原则 ===
// 任何进程内的缓存 / 集合,只要它会随业务运行不断变大,
// 就【必须】有一个明确的上限 —— 容量上限或时间上限。
// "只往里加,从不删"的 static 集合,本质就是内存泄漏。
修复 4:合理设置堆与选择垃圾收集器
# === 堆参数:Xms 和 Xmx 一定要设成相等 ===
# 不相等的话,堆会动态扩缩容,扩容时本身可能触发 Full GC
-Xms8g -Xmx8g
# 16G 物理内存,堆给 8G,留一半给元空间、线程栈、堆外内存、系统
# === JDK 8 默认是 Parallel GC,STW 时间不可控,换成 G1 ===
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 给 G1 一个停顿时间目标:期望单次 STW ≤ 200ms
-XX:InitiatingHeapOccupancyPercent=45 # 老年代占 45% 就启动并发标记,别等满
# === G1 为什么适合我们这种场景 ===
# Parallel GC:吞吐优先,但 Full GC 是单次整堆回收,STW 长且不可控
# G1:把堆分成很多 Region,大部分回收工作【与业务线程并发】进行,
# STW 被切成多个小段,可以朝着 MaxGCPauseMillis 的目标努力
# 对"怕卡顿、要低延迟"的在线服务,G1(及更新的 ZGC)远优于 Parallel
# === 保留出事现场:OOM 时自动 dump ===
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/www/wwwroot/app/dump/
# 真到 OOM 那一刻,自动留一份堆快照,事后才有得分析
# 注意:换 GC、调堆只是"缓解",治本仍是修复 3 那个无界缓存。
# 内存泄漏不修,再大的堆也只是把 Full GC 推迟一会儿。
修复 5:其他常被忽略的 Full GC 诱因
// === 诱因 1:代码里手动调 System.gc() ===
// 某些老代码 / 三方库里藏着 System.gc(),它会直接触发 Full GC
System.gc(); // 删掉它!
// 实在拦不住(三方库),用 -XX:+DisableExplicitGC 让它变成空操作
// === 诱因 2:大对象直接进老年代 ===
// 超过 -XX:PretenureSizeThreshold 的大对象会跳过年轻代直接进老年代
byte[] huge = new byte[20 * 1024 * 1024]; // 一次性 20M 的大数组
// 频繁创建大对象,老年代很快被填满 -> Full GC
// 排查:别一次性把大结果集 / 大文件全读进内存,改成流式 / 分页处理
// === 诱因 3(JDK 8):元空间(Metaspace)打满 ===
// Metaspace 存类的元数据,默认无上限(用本地内存)。
// 动态生成大量类(频繁用 CGLIB 代理、反复加载脚本、热部署)
// 会让 Metaspace 膨胀,打满时同样触发 Full GC。
// -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m 显式设上限并监控
// === 诱因 4:年轻代太小,对象过早晋升 ===
// 年轻代太小 -> Young GC 太频繁 -> 对象还没"死"就被迫晋升到老年代
// -> 老年代被一堆本该在年轻代就死掉的"短命对象"填满 -> Full GC
// 适当调大年轻代(-Xmn),让短命对象在年轻代里就被回收掉。
// === 排查 Full GC 诱因的顺序 ===
// 1. 先看 Full GC 后老年代降不降:不降 -> 内存泄漏(最常见,先 dump)
// 2. 降得很多但很快又满 -> 晋升太快 / 大对象 / 年轻代偏小
// 3. GC 日志里写 "Full GC (System.gc())" -> 有人手动调了 gc
// 4. GC 日志里写 "Full GC (Metadata GC Threshold)" -> 元空间问题
修复 6:GC 监控告警
# GC 是慢性病,要靠监控早发现,别等用户投诉
groups:
- name: jvm-gc
rules:
# 1. Full GC 频率(健康的服务几乎不该有 Full GC)
- alert: FullGcFrequent
expr: increase(jvm_gc_pause_seconds_count{action="end of major GC"}[10m]) > 3
for: 5m
annotations:
summary: "{{ $labels.instance }} 10 分钟内 Full GC > 3 次"
# 2. 单次 GC 停顿过长
- alert: GcPauseTooLong
expr: jvm_gc_pause_seconds_max > 1
for: 3m
annotations:
summary: "{{ $labels.instance }} 单次 GC 停顿 > 1 秒,接口将明显卡顿"
# 3. 老年代使用率持续高位(Full GC 后降不下来 = 泄漏前兆)
- alert: OldGenHigh
expr: |
jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
/ jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"} > 0.9
for: 10m
annotations:
summary: "{{ $labels.instance }} 老年代 > 90%,排查内存泄漏"
# 4. GC 耗时占比(GC 时间 / 总运行时间,反映 GC 开销)
- alert: GcOverhead
expr: rate(jvm_gc_pause_seconds_sum[5m]) > 0.1
for: 5m
annotations:
summary: "{{ $labels.instance }} GC 耗时占比 > 10%,GC 压力过大"
优化效果
指标 治理前 治理后
=============================================================
Full GC 频率 每 3-5 分钟一次 基本为 0(数小时偶发)
单次 Full GC STW 2-3 秒 —
接口 P99 周期尖刺 2-3 秒 稳定 180ms
老年代使用率 长期 98%+ 稳定 40-55%
本地缓存 无界(只增不减) Caffeine 上限 5 万 + 过期
垃圾收集器 Parallel GC G1(停顿目标 200ms)
堆参数 Xms≠Xmx Xms=Xmx=8g
GC 可观测性 无 4 条告警 + GC 日志归档
治理过程:
- 加 GC 日志 + jstat 确认频繁 Full GC:0.5 天
- jmap dump + MAT 定位无界缓存:1 天
- 本地缓存改 Caffeine 有上限:1 天
- 切换 G1 + 堆参数调整 + 压测验证:1.5 天
- GC 监控告警接入:1 天
避坑清单
- 接口周期性卡顿、P99 尖刺,优先怀疑 Full GC,先把 GC 日志打开
- Young GC 频繁正常,只要单次快;Full GC 频繁(分钟级)一定是大问题
- 看 GC 日志重点看 Full GC 后老年代降不降,降不下来就是内存泄漏
- jstat -gcutil 快速看 GC 频率与各区占用,jmap -histo 快速看对象大户
- static 的集合 / 缓存只增不删,就是内存泄漏,Full GC 也清不掉
- 任何进程内缓存必须有上限:容量上限或过期时间,推荐 Caffeine
- Xms 和 Xmx 设成相等,避免堆动态扩缩容时触发 Full GC
- 怕卡顿的在线服务用 G1/ZGC,别用 STW 不可控的 Parallel GC
- 排查 System.gc()、大对象直接进老年代、元空间打满等其他诱因
- 换 GC、调大堆只是缓解,内存泄漏不修复,Full GC 迟早卷土重来
总结
这次"接口周期性卡顿"的排查,最有价值的一课是关于"如何透过表象看本质"。一开始用户反馈接口偶尔很卡,我们第一反应是去查慢 SQL、查网络抖动、查下游依赖,折腾了一圈一无所获,因为监控大盘上的平均响应时间一直是正常的——这正是它最具迷惑性的地方:Full GC 的卡顿是周期性的、瞬时的,它把 P99 拉成了一根根尖刺,但平均值会把这些尖刺稀释得几乎看不见,所以靠看平均 RT 的监控大盘根本发现不了。真正让真相浮出水面的,是把 GC 日志打开的那一刻——服务每隔三五分钟就做一次 Full GC,每次 Stop The World 两三秒,在那两三秒里整个 JVM 的业务线程全部冻结,接口当然就卡死了。所以第一条经验是:遇到周期性、瞬时性的卡顿,GC 一定要排在嫌疑名单的最前面,而排查 GC 问题没有任何捷径,就是老老实实把 GC 日志打开去读。读 GC 日志也有门道,最关键的一个动作是看每次 Full GC 之后老年代到底降没降——如果回收前后老年代纹丝不动,几个 G 只腾出来一两兆,那就说明老年代里堆的全是"活对象",它们被某个 GC Root 牢牢抓着,GC 想清也清不掉,这就是内存泄漏的铁证。顺着这个线索 jmap dump 下来用 MAT 一看,元凶是一个 static 的 ConcurrentHashMap 当本地缓存用,却没有任何容量上限、没有过期、没有淘汰策略,服务跑得越久它就越大,最后把老年代彻底占满。这引出了第二条、也是我认为最该记住的一条经验:任何一个会随着业务运行而不断变大的进程内集合或缓存,都必须有一个明确的上限——要么限容量,要么限时间——一个"只进不出"的 static 集合,无论你给它起名叫缓存还是叫别的,它的本质就是一个内存泄漏。最后还想强调一点:换垃圾收集器、调大堆内存这些手段,在排查过程中我们也做了,G1 确实把停顿切得更碎、更可控,但必须清醒地认识到这些都只是"缓解"而不是"治本"——只要那个无界缓存还在,再大的堆也只是让 Full GC 晚来一会儿而已。真正的修复永远是找到并堵上内存泄漏的源头,而要让这类慢性病在拖垮服务之前就被发现,就得把 Full GC 频率、单次停顿时长、老年代使用率这些指标都接进监控告警,别再让用户的投诉成为你唯一的 GC 监控。
—— 别看了 · 2026