2024 年我们一个跑了大半年都很稳定的后台服务,开始出现一种很折磨人的症状:每隔三四天,就会有一两个实例响应变得极慢,接口动辄卡顿好几秒,过一阵又自己"好"了,再过几天又犯。监控上看,这台机器的 CPU 会在卡顿时段莫名其妙冲高。一开始我们以为是流量毛刺,后来才意识到——这是典型的 JVM 频繁 Full GC,而 Full GC 的背后,是一处藏得很深的内存泄漏:有个缓存只进不出,堆内存被一点点吃满,GC 拼命回收却收不回多少,只能一遍遍地做代价高昂的 Full GC。投了几天把这次内存问题彻底排查清楚,本文复盘这次实战。
问题背景
业务:后台数据服务,Java 8,堆 4G,常驻运行
事故现象:
- 每隔 3-4 天,个别实例响应变慢,接口卡顿数秒
- 卡顿时段 CPU 冲高,过一阵自行恢复,周期性复发
- 重启实例后立刻恢复正常,几天后又复发
现场排查:
# 1. 看 GC 日志(第一手证据)
$ tail -f gc.log
[Full GC (Ergonomics) 3801M->3780M(4096M), 2.13 secs]
[Full GC (Ergonomics) 3802M->3781M(4096M), 2.20 secs]
# 关键信号:Full GC 后堆几乎没降(3801M->3780M),
# 每次还卡 2 秒 —— 典型的内存泄漏
# 2. 看 GC 频率与停顿
$ jstat -gcutil 1000
O FGC FGCT
99.2 1240 2680.5 # 老年代 99%,Full GC 1240 次
# 老年代长期满,Full GC 收不动 —— 有对象一直被引用,回收不掉
# 3. dump 堆,分析对象占用
$ jmap -dump:live,format=b,file=heap.hprof
# 用 MAT 打开,看 Histogram + Dominator Tree
根因:
1. 有个本地缓存(HashMap)只往里 put,从不清理 -> 无限增长
2. 缓存的 key 还带了时间戳,几乎每次都是新 key,永不命中也永不淘汰
3. 对象被这个 Map 强引用着,GC 判定它们"还在用",回收不掉
4. 堆被泄漏对象占满,Full GC 越来越频繁,直至卡死
修复 1:先分清是"内存不够"还是"内存泄漏"
=== 两种情况,表现像,本质完全不同 ===
内存不够(容量问题):
- 业务量确实涨了,4G 堆真的不够用了
- 现象:堆用量整体抬高,但 Full GC 后能明显回落
- 解法:加堆 / 优化对象大小 / 拆分服务
内存泄漏(Bug):
- 有对象本该被回收,却因为还被引用着,回收不掉
- 现象:Full GC 后堆几乎不降,锯齿状曲线的"谷底"
一次比一次高,最终顶到天花板
- 解法:找到泄漏点,断开那个不该有的引用
=== 怎么快速区分:看 Full GC 后的堆用量 ===
[Full GC 3801M->1200M] -> 回收了 2.6G,健康,可能只是堆偏小
[Full GC 3801M->3780M] -> 只回收 21M,收不动 = 内存泄漏
=== Java 内存泄漏的定义 ===
不是 C 那种"忘了 free",Java 有 GC。
Java 的泄漏是:对象逻辑上已经没用了,
但还有一条引用链把它连着 GC Root,
GC 认为它"可达 = 还在用",于是永远不回收它。
泄漏的根源,永远是【一个不该存在的引用】。
修复 2:GC 日志 —— 排查的第一手证据
=== 一定要开 GC 日志,它是排查的起点 ===
# Java 8 开 GC 日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xloggc:/path/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
# Java 9+ 用统一日志:
-Xlog:gc*:file=/path/gc.log:time,uptime:filecount=5,filesize=20M
=== 从 GC 日志看什么 ===
1. Full GC 的频率:几分钟一次 vs 几天一次,差别巨大
2. Full GC 后的回收效果:堆降了多少 -> 区分泄漏/容量
3. 单次 GC 停顿时长:Full GC 停顿几秒,业务就卡几秒
4. Young GC 是否过于频繁:可能是新生代太小或对象创建太快
=== jstat 实时看 GC 概况 ===
$ jstat -gcutil 1000 10
S0 S1 E O M YGC YGCT FGC FGCT
0 12.3 45.6 99.1 96.2 8821 102.3 1240 2680.5
# E=Eden O=老年代 M=元空间
# O 长期 99% + FGC 次数飞涨 = 老年代有回收不掉的对象
=== 关键判断 ===
"Full GC 频繁 + 每次回收效果差 + 老年代居高不下"
这三个一起出现,基本就能锁定:内存泄漏。
下一步就是 dump 堆,看到底是什么对象在泄漏。
修复 3:dump 堆内存 —— 找出泄漏的对象
# === 第 1 步:把堆 dump 下来 ===
# 主动 dump(只导出存活对象,文件更小)
jmap -dump:live,format=b,file=heap.hprof
# 更推荐:配置 OOM 时自动 dump,抓现场最准
# -XX:+HeapDumpOnOutOfMemoryError
# -XX:HeapDumpPath=/path/dumps/
# === 第 2 步:用 MAT(Eclipse Memory Analyzer)分析 ===
# 打开 heap.hprof,重点看三个视图:
# 1. Histogram(直方图):按类统计对象数量和占用
# 一眼看出"哪个类的实例多到不正常"
# 比如 HashMap$Node 有几千万个 -> 某个 Map 爆了
# 2. Dominator Tree(支配树):按"谁占着最多内存"排序
# 能定位到具体哪个对象实例占了堆的一大半
# 3. Path to GC Roots:对泄漏对象,看它的引用链
# 这是【最关键的一步】—— 它告诉你:
# 这个本该被回收的对象,到底是被谁引用着的。
# 顺着引用链就能找到那个"不该存在的引用"。
# === 第 3 步:对比两次 dump,找"一直在涨"的对象 ===
# 间隔一段时间 dump 两次,对比哪些类的实例数显著增长,
# 持续增长的那个,就是泄漏的嫌疑对象。
=== 我们这次 MAT 看到的 ===
Histogram: java.util.HashMap$Node 实例 4800 万个,占 2.9G
Dominator Tree: 一个 static 的 HashMap 占了堆的 73%
Path to GC Roots:
这个 HashMap 是个 static 字段 -> 被类对象强引用
-> 类对象常驻 -> Map 永不回收
-> Map 里的 4800 万个 Entry 也跟着永不回收
结论:罪魁就是那个 static HashMap 缓存。
修复 4:最常见的泄漏 —— 缓存只进不出
// === 我们踩的坑:一个"缓存"只 put 不清理 ===
public class PriceCache {
// static Map 生命周期 = 整个 JVM,里面的对象永不回收
private static final Map CACHE
= new HashMap<>();
public static PriceInfo get(String skuId, long timestamp) {
// 坑 1:key 里拼了 timestamp,几乎每次都是【新 key】
String key = skuId + "_" + timestamp;
PriceInfo info = CACHE.get(key); // 永远命中不了
if (info == null) {
info = loadFromDb(skuId);
CACHE.put(key, info); // 坑 2:只 put,从不删
}
return info;
// 结果:CACHE 无限膨胀,每个请求都往里塞新 entry
}
}
// === 修复 1:用有容量上限、能自动淘汰的缓存 ===
// 别用裸 HashMap 当缓存。用专业缓存库,设最大容量 + 过期:
private static final Cache CACHE =
Caffeine.newBuilder()
.maximumSize(10_000) // 容量上限,超了自动淘汰
.expireAfterWrite(Duration.ofMinutes(10)) // 写后 10 分钟过期
.build();
public static PriceInfo get(String skuId) {
// 修复 2:key 只用 skuId,不要拼时间戳这种高基数的东西
return CACHE.get(skuId, PriceCache::loadFromDb);
}
// === 本地缓存的铁律 ===
// 1. 必须有容量上限(maximumSize),不能无界增长
// 2. 必须有淘汰策略(LRU / 过期),旧数据要能被清出去
// 3. key 不能是高基数、近乎唯一的值(如带时间戳、UUID),
// 否则缓存命中率为 0,还无限膨胀
// 4. 数据量大、要多实例共享时,用 Redis,别用本地 Map
修复 5:其他常见的泄漏场景
// === 1. ThreadLocal 用完不 remove(线程池场景必踩)===
private static final ThreadLocal CTX =
new ThreadLocal<>();
public void handle(Request req) {
CTX.set(buildContext(req));
try {
process();
} finally {
CTX.remove(); // 必须!线程池线程不销毁,
// 不 remove,ThreadLocal 的值就一直挂在线程上
}
}
// 线程池里线程被复用,ThreadLocal 不清理 ->
// 旧 context 一直被线程引用着 -> 泄漏,还可能串数据。
// === 2. 监听器 / 回调注册了不注销 ===
// register 了 listener,对象销毁时忘了 unregister,
// 发布者一直持有 listener 引用 -> listener 及其关联对象泄漏。
// === 3. 没关闭的资源:流、连接、线程池 ===
// InputStream/Connection 不 close -> 关联的 native 内存/缓冲区泄漏
// 线程池 shutdown 没调 -> 线程一直在,引用链上的对象回收不掉
// 一律 try-with-resources;线程池在容器关闭时 shutdown。
// === 4. 静态集合"只增不减" ===
// static List/Map/Set 往里加元素却没有对应的移除逻辑,
// 它们的生命周期和 JVM 一样长 -> 加进去的对象永远活着。
// === 5. 字符串 / 大对象被意外长期持有 ===
// 比如把一个大 List 的某个元素塞进了一个长生命周期对象,
// 导致整个大 List 都被这一个引用拖着,无法回收(尤其老式 substring)。
// === 共同规律 ===
// 所有 Java 内存泄漏,本质都是同一句话:
// 一个【长生命周期的对象】,持有了一个【本该短命的对象】的引用,
// 让短命对象跟着它一起"长生不老"。
// 排查时,永远去找那条"不该存在的引用链"。
修复 6:内存监控告警
# JVM 内存与 GC 监控(Micrometer / jvm_exporter 指标)
groups:
- name: jvm-memory
rules:
# 1. 老年代使用率持续过高(泄漏的最早信号)
- alert: JvmOldGenHigh
expr: |
jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
/ jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
> 0.85
for: 10m
annotations:
summary: "JVM 老年代使用率 > 85%,排查内存泄漏或堆容量不足"
# 2. Full GC 过于频繁
- alert: JvmFullGcFrequent
expr: increase(jvm_gc_pause_seconds_count{action=~".*Major.*|.*Full.*"}[10m]) > 5
for: 5m
annotations:
summary: "10 分钟内 Full GC 超过 5 次,排查内存泄漏"
# 3. GC 停顿时间过长(直接影响接口 RT)
- alert: JvmGcPauseLong
expr: jvm_gc_pause_seconds_max > 1
for: 5m
annotations:
summary: "单次 GC 停顿 > 1s,业务会明显卡顿"
# 4. 堆内存整体接近上限
- alert: JvmHeapNearFull
expr: |
sum(jvm_memory_used_bytes{area="heap"})
/ sum(jvm_memory_max_bytes{area="heap"}) > 0.9
for: 5m
annotations:
summary: "JVM 堆使用率 > 90%,接近 OOM"
优化效果
指标 治理前 治理后
=============================================================
Full GC 频率 泄漏期每分钟数次 几小时 1 次
Full GC 后堆用量 3780M(几乎不降) 1100M(回收充分)
单次 Full GC 停顿 2.1s 0.2s
老年代使用率 长期 99% 稳定 40-55%
本地缓存 裸 HashMap 无界 Caffeine 容量上限+过期
缓存 key 拼时间戳,命中率 0 只用 skuId,命中率 95%+
实例周期性变慢 3-4 天复发 不再复发
接口 P99 卡顿时 3-5s 稳定 < 200ms
内存可观测 无 老年代/GC 频率/停顿监控
治理过程:
- 开 GC 日志 + jstat 确认是泄漏:0.5 天
- jmap dump + MAT 定位泄漏对象:1 天
- 修复缓存只进不出 + 换 Caffeine:1 天
- 排查其余 ThreadLocal/监听器泄漏点:1.5 天
- 监控接入 + 长期观察验证:1 天
避坑清单
- 实例周期性变慢、CPU 冲高、重启后恢复,典型是频繁 Full GC
- 看 Full GC 后堆降不降:降很多是容量不足,几乎不降是内存泄漏
- Java 内存泄漏不是忘了 free,是对象还被引用着、GC 判定它可达而不回收
- 一定要开 GC 日志,它是排查的第一手证据,jstat 看 GC 概况
- jmap dump 堆 + MAT 分析,Path to GC Roots 能找出不该存在的引用链
- 最常见的泄漏是缓存只进不出,本地缓存必须有容量上限和淘汰策略
- 缓存 key 不能用带时间戳/UUID 的高基数值,否则命中率为 0 还无限膨胀
- 线程池场景 ThreadLocal 必须在 finally 里 remove,否则值挂在复用线程上
- 监听器注册了要注销,流/连接/线程池用完要关,静态集合要有移除逻辑
- 泄漏本质是长生命周期对象持有了短命对象,监控老年代使用率最早预警
总结
这次 JVM 内存泄漏的排查,最值得记下来的是一套可以反复套用的方法论。事故的表象很有迷惑性:服务每隔三四天就有实例变慢、CPU 冲高,然后又自己恢复,重启则立刻见效——这种"周期性发作、重启即愈"的特征,几乎就是内存泄漏写在脸上的签名,因为重启把堆清空了,泄漏的对象被一次性抹掉,于是又能稳定运行几天,直到泄漏物再次把堆填满。排查的第一步永远是 GC 日志,它是最不会骗人的第一手证据。而 GC 日志里最关键的一个信号,是 Full GC 之后堆内存到底降了多少:如果一次 Full GC 把堆从将近 4G 收回到了 1G 多,那说明 GC 是有效的,问题可能只是堆配小了,属于容量问题;但如果像我们这样,Full GC 折腾了两秒,堆却只从 3801M 降到 3780M,只挤出来可怜的 21M,那就说明堆里绝大多数对象 GC 根本动不了——它们明明逻辑上已经没用了,却因为还被某条引用链连着,被 GC 判定为"仍然可达、仍在使用",于是怎么也回收不掉。这就是 Java 内存泄漏的本质,它和 C 语言里"忘记 free"的泄漏是两码事:Java 有垃圾回收,它的泄漏从来不是"没人回收",而是"有一个不该存在的引用,死死拦着不让回收"。一旦确认是泄漏,下一步就是把堆 dump 下来,用 MAT 这样的工具去分析,而 MAT 里最有价值的功能,是对可疑对象去看它的"Path to GC Roots",也就是把那条让对象无法被回收的引用链完整地揪出来——这条链的源头,就是那个"不该存在的引用"。我们这次顺着链一查,源头是一个 static 修饰的 HashMap,它被当作本地缓存用,但写法上犯了两个致命错误:一是它只往里 put、从来没有任何清理逻辑,是个彻头彻尾的无界容器;二是它的 key 里拼接了时间戳,导致几乎每一个请求进来都是一个全新的 key,缓存永远命中不了,于是每个请求都老老实实地往这个 Map 里又塞一条新记录。一个永不淘汰、又持续被高速写入的 static Map,它的结局是注定的——把整个堆吃光。修复它本身不难,换成 Caffeine 这种成熟的缓存库,设上容量上限和过期时间,再把 key 里那个高基数的时间戳去掉就行。但这次复盘真正沉淀下来的,是对"长生命周期对象"的一种警觉。回过头看 Java 里几乎所有的内存泄漏——无界的静态集合、线程池里没有 remove 的 ThreadLocal、注册了却没注销的监听器、忘了关闭的资源——它们可以用同一句话概括:一个生命周期很长的对象,不小心抓住了一个本该很快死去的对象的引用,把后者也拖成了"长生不老"。所以从此以后,每当我写下 static、写下往一个长期存在的容器里 add 元素、写下 ThreadLocal.set,我都会多停留一秒,问自己一个问题:我放进去的这些东西,将来由谁、在什么时候、用什么逻辑,把它们清理出去?如果这个问题我答不上来,那这里大概率就埋着下一个内存泄漏。
—— 别看了 · 2026