内存泄漏是 Java 线上最隐蔽的一类故障。它不像 CPU 飙高那样立刻报警,而是让老年代堆内存缓慢、不可逆地增长,直到某次 Full GC 也收不回空间,最终抛出 OutOfMemoryError: Java heap space,把进程拖垮。本文复盘一次真实的生产排查:从监控告警发现堆曲线异常,到用 jmap/heapdump 保留现场,再用 MAT 顺着对象引用链锁定「谁拽着内存不放」,最后定位到一行被忽视的代码并根治。整个过程不靠猜,全靠现场数据——这和用 Arthas 做线上不停机诊断的思路一脉相承。
一、为什么内存泄漏最容易被忽视
很多人把「内存泄漏」和「内存溢出」混为一谈,其实二者差别很大。内存溢出(OOM)可能是一次流量尖峰把堆打满,问题一过就恢复;而内存泄漏是对象明明已经用完,却因为还有引用被持有,导致 GC 永远回收不掉。它像慢性中毒:前几个小时一切正常,监控曲线只是轻微上扬,没人当回事,直到某天凌晨堆彻底涨满,服务雪崩。
判断要点很简单:GC 之后老年代使用率不回落,反而逐次抬高,就是泄漏的典型信号。关于 Full GC 频次与回收效果的判读,可以对照JVM 内存模型与 GC 调优实战那篇的基础指标。
二、监控先说话:从三条曲线看异常
故障不是从报警那刻才开始的,数据早有预兆。下面这张表是我们在 Grafana 上重点盯的三条曲线,以及它们各自说明什么:
| 监控指标 | 异常形态 | 它说明什么 |
|---|---|---|
| 老年代(Old)使用率 | 锯齿形上移,每次 GC 谷底越来越高 | 有对象持续堆积、回收不掉,强烈指向泄漏 |
| Full GC 频次 | 从几小时一次,恶化到几分钟一次 | 回收越来越频繁但回收量越来越小 |
| 单次 Full GC 后回收量 | 呈下降趋势,后期接近 0 | 堆里存活对象占比逼近 100%,随时 OOM |
一个经验法则:只要「老年代谷底逐次抬高」持续三四个 GC 周期,就可以基本确认是泄漏,不必等 OOM 真的发生再动手。越早介入,保留的现场越干净,定位越快。
三、止血第一步:先保住现场,别急着重启
线上内存涨满时,运维和开发的本能反应是「重启大法好」。但这恰恰是最错误的做法:重启会把堆里所有对象清零,现场彻底丢失,问题再也无法在测试环境复现,下次还爆。正确顺序是先观察、再留证、最后才处理。
# 1) 看各内存区实时用量(不用重启,秒级)
jmap -heap <pid>
# 2) 看 GC 回收效率(每 1 秒采样一次,连续 10 次)
jstat -gcutil <pid> 1000 10
# 3) 抓线程栈,排查是否有线程暴涨堆积任务
jstack <pid> > /tmp/jstack.txt
# 4) 关键:保留堆现场(live 只转储存活对象,体积更小)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 容器/K8s 环境:先进容器再 dump,或拷出来
kubectl exec <pod> -c <container> -- jmap -dump:live,format=b,file=/tmp/heap.hprof 1
kubectl cp <pod>:/tmp/heap.hprof ./heap.hprof
纪律红线:OOM 前一定要先把 heap.hprof 落盘,再谈重启。这个 dump 文件是后面所有分析的唯一依据。如果进程已经因 OOM 退出,也可以在启动参数里加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/heap.hprof,让 JVM 在崩溃瞬间自动留证。
四、MAT 分析:找到「占用最大的对象」
拿到 heap.hprof 后,用 Eclipse MAT(Memory Analyzer)打开。新手最容易陷进「逐个看对象」的误区,其实只需三步就能锁定元凶:
1. Dominator Tree:谁占着最多的「保留内存」
Dominator Tree 按 Retained Heap(被该对象支配、一旦回收就能释放的总内存)排序。排在最前面的一两个类,通常就是泄漏的容器。比如看到某个 HashMap 的 Retained Heap 占了堆的 70%,那它下面一定装着不被期望的大对象。
2. Histogram:按类统计实例数与占用
如果你心里已经有怀疑对象(例如某个缓存 Map),直接用 Histogram 按类名过滤,看它的实例数量和 Shallow/Retained Heap 是否异常膨胀。
3. 顺引用链追到 GC Roots
找到大对象后,右键 Path to GC Roots → exclude weak/soft/phantom references。泄漏之所以回收不掉,就是因为有一条强引用链连到了 GC Roots(静态变量、活动线程、JNI 等)。排除掉弱/软引用后,剩下的强引用链就是「凶手」。
-- MAT 的 OQL(对象查询语言)示例:找出某缓存里实例数异常的键
SELECT * FROM com.example.order.OrderCache
WHERE toString(this).length() > 0
-- 统计某个类的实例总数与总占用
SELECT count(*) AS instances, sum(shallowSize) AS bytes
FROM java.util.HashMap$Node
WHERE toString(classof(this)) LIKE ".*OrderCache.*"
-- 找出被 ThreadLocal 持有的大对象(泄漏高发区)
SELECT * FROM java.lang.ThreadLocalMap$Entry
WHERE value instanceof com.example.biz.ReportBuffer
顺着 GC Roots 引用链一层层展开,常常会在某个 static Map 或线程的 threadLocals 字段上看到那个不该存在的引用——这一刻,根因就水落石出了。
五、三种最常见的泄漏根因
复盘了十几起内存泄漏后我们发现,根因高度集中在三类代码模式上。下面这张对照表可以直接拿来当 Code Review 的「黑名单」:
| 根因模式 | 典型代码 | 修复方向 |
|---|---|---|
| 静态集合当缓存不淘汰 | static Map<K,V> cache = new HashMap<>(),只 put 不 remove | 换用带上限的 LRUMap / Caffeine,或落地到Redis 这类外部存储 |
| ThreadLocal 用完不 remove | 线程池复用线程,set 后忘记 remove,对象随线程存活 | try-finally 中 remove(),或改用弱引用封装 |
| 资源/流未关闭 | 大 InputStream、数据库连接、缓冲未 close | 一律 try-with-resources,禁止裸 new |
六、定位到代码:一个生产级反例
本次复盘的根因,是「ThreadLocal + 线程池」组合的经典坑。服务用线程池处理请求,每个请求把一个大报表缓冲塞进 ThreadLocal 方便后续方法取用,却只在正常分支里清理,异常分支漏了 remove()。线程被池子复用后,上一个请求的缓冲区就一直挂在那个线程上,请求量越大泄漏越快。
// 反例:异常分支漏掉 remove,线程池复用导致泄漏
private static final ThreadLocal<ReportBuffer> BUF = new ThreadLocal<>();
void handle(Request req) {
BUF.set(new ReportBuffer(1024 * 1024)); // 每次 1MB 缓冲
try {
doBusiness(req); // 若这里抛异常...
} finally {
BUF.get().writeToDb(); // 且这里也抛异常
}
BUF.remove(); // 永远执行不到
}
修复方式朴素而有效:把 remove() 放进 finally 的第一行,保证无论成败都清理;同时给缓冲加一个全局上限,兜底防住任何遗漏:
// 修复:finally 中先 remove,且用带上限的缓存兜底
void handle(Request req) {
BUF.set(new ReportBuffer(1024 * 1024));
try {
doBusiness(req);
} finally {
BUF.remove(); // 第一优先:无条件清理
BUF.set(null);
}
}
// 全局兜底:用 Caffeine 替代裸 static Map,写入即计容量
private static final Cache<String, Report> CACHE =
Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build();
七、根治与验证:压测 + 回归用例
改完代码不等于结束。我们做两件事确认真的修好了:一是针对性压测,用接近生产的并发持续跑几小时,观察老年代曲线是否重新变得平稳;二是补一条回归用例,模拟「长期运行 + 异常路径」让泄漏提前暴露,作为以后 CI 的护栏。如何写出可信赖的测试,可以参考Java 单元测试实战:JUnit 5 与 Mockito 指南。
# 压测期间打开 GC 日志,事后分析回收效果
java -Xlog:gc*,gc+heap=debug:file=/data/gc.log:time,uptime,level,tags -jar order-service.jar
# 用 JMeter / wrk 打持续流量,重点看堆是否不再单调上升
wrk -t8 -c200 -d7200s http://localhost:8080/api/order
# 验证点:jstat 老年代谷底不再逐次抬高,Full GC 后回收量恢复
把「构建产物 + 压测脚本 + 部署」串进流水线,让每次发版都自动跑一遍回归,是避免同类问题回潮的关键。这部分交给GitHub Actions CI/CD 实战里的流水线去兜底最合适。
八、把这次故障沉淀成防线
一次排查的价值,远不止修好那一行代码。我们用 Postmortem 复盘把过程写成了团队资产:告警阈值的设定(老年代谷底连续三次抬高即触发)、Code Review 的黑名单(裸 static 集合、ThreadLocal 无 remove)、以及一条永远生效的护栏——任何本地缓存必须用带容量上限的实现。故障不可怕,可怕的是它第二次以同样的方式发生。
最后送你一份肌肉记忆清单:堆曲线异常别重启 → 先 jmap 留 dump → MAT 看 Dominator Tree 和 GC Roots 引用链 → 定位 static/ThreadLocal/未关流 → 修复后压测 + 补用例。等下次凌晨告警响起,你要做的只是按这套流程走,而不是对着监控干瞪眼。




