JAVA · Vol.III · DAY 30 · JVM 原理与调优
内存泄漏排查:Heap Dump + MAT + VisualVM 实战
开场:内存泄漏到底是什么?
面试官很喜欢用真实事故开场:"线上服务跑了两周,突然 OOM 了,你怎么排查?"大部分候选人会答"看日志"或者"把内存加一点"。前者是背过的,后者是运维思维。面试官真正想听的是——你能不能从确认泄漏存在、导出堆转储、MAT 定位到类、一路追到具体那行代码,再到修复验证,完整走一遍排查链路。这一篇就帮你把这条链路全部走通。
先把"泄漏"这个词说准确
在带 GC 的语言里,一个对象能被回收的前提是"从 GC Roots 不可达"。所以 Java 里的内存泄漏,准确定义是:
→ GC 不是不想收,是被引用"绑架"了,收不掉
→ 本质:引用没释放,不是内存没回收
注意这个定义里最值钱的后半句:Java 内存泄漏的排查,关键问题从来不是"内存去哪了",而是"谁还拽着这些对象不放"。问对问题,方向才不会跑偏。在《GC Roots 详解》里我们梳理过泄漏的六种常见模式:① 静态集合只增不减;② 资源(连接 / 流 / 监听器)未关闭或未注销;③ ThreadLocal 未 remove;④ 缓存没有淘汰策略;⑤ 内部类 / 匿名类隐式持有外部对象;⑥ 动态生成类导致类加载器泄漏(这个漏的是 Metaspace,不是堆)。本文第 5 站的三大场景正好覆盖其中 ①③④,附加场景再补 ⑤⑥。
泄漏 vs 内存不足:OOM 的两种根因
一个容易混淆的点:OOM(OutOfMemoryError)≠ 内存泄漏。OutOfMemoryError: Java heap space 至少有两种根因,处理方式完全不同——一种是泄漏(对象该死不死,缓慢累积),另一种是容量 / 大对象问题(堆真的不够大,或单个对象大到装不下)。区分方法看曲线:
| 维度 | 内存泄漏(缓慢增长) | 容量 / 大对象问题(突然撑爆) |
|---|---|---|
| 老年代曲线 | Full GC 后基线逐级抬高,楼梯状爬升 | 在某个水位锯齿震荡、每次都能回收;或被单个大对象一次顶满 |
| 与业务量的关系 | 与流量无关,低峰期也在涨 | 通常与负载或单次请求大小相关 |
| 到 OOM 的时间 | 慢,数天到数周,"慢性病" | 快,数小时甚至启动即 OOM(大对象场景见《JVM 堆内存》的大对象直通老年代路径) |
| 修复方向 | 找到并释放那条引用链 | 扩堆 / 拆大对象 / 换收集器 |
两种情况抛的异常可能一模一样,所以光看异常堆栈区分不了,必须看内存趋势——这就是为什么排查 SOP 的第一步永远是"看趋势"而不是"dump 堆"。OOM 的其余几类(GC overhead limit exceeded、Metaspace、Direct buffer memory 等)在《OOM 排查实战》里有完整分类,这里不展开。
全文主线:五步 SOP
把一次泄漏排查拆成五个阶段,后文每站对应其中一步,读完你会发现整篇文章就是一条流水线:
- 趋势:确认泄漏确实存在(第 2 站,工具 jstat / 监控)。
- 取证:导出堆转储快照(第 3 站,工具 jmap / jcmd / OOM 自动转储)。
- 定位:MAT 四步走,找到嫌疑类与引用链(第 4 站,核心)。
- 根因:把引用链对应回代码,确认业务语义(第 5 站,三大典型场景)。
- 修复:改代码 + 验证基线恢复锯齿形(第 7 站,预防体系让这类问题不再发生)。
类比医院:看症状(趋势)→ 抽血化验(取证)→ CT 成像(定位)→ 病理确诊(根因)→ 治疗(修复)。跳步的后果是真实的——没看趋势就 dump,可能白停一次服务;拿到 dump 不会 MAT,证据就在眼前也读不出来。
不是,但机制不同。没有 GC 的语言(C / C++)里泄漏是忘了 free(),内存永远回不来;Java 有 GC,"忘"的不是释放内存,而是释放引用。所以 Java 排查的问法从"哪块内存没还"变成"谁还指着这个对象"。这个区别决定了你后面选工具:C 语言查泄漏用 AddressSanitizer 一类工具盯"分配未释放",Java 查泄漏用 MAT 这类工具盯"引用链"。
Java 内存泄漏 = 没用的对象被引用链拽着不让 GC 收;排查五步 趋势 → 取证 → 定位 → 根因 → 修复,第一步看曲线区分"泄漏"和"堆太小",后面每步都有固定工具。OOM 不等于泄漏,先看病灶,再开药方。
发现泄漏:先看趋势,再下判断
面试官第一问:"你凭什么判断它在泄漏?直接 dump 不行吗?"4GB 的堆 dump 一次要 STW 数秒、产出几 GB 文件、再花几十分钟传出来——在没确认泄漏之前动这个工具,等于没化验就开刀。发现泄漏这一步的关键词是低成本、看趋势。
jstat -gcutil:最轻量的"趋势显微镜"
jstat -gcutil 按百分比展示各区使用率,几乎零开销,适合连着采样:S0 / S1 / E / O / M / CCS 是各区使用率,YGC / YGCT / FGC / FGCT 是 Young / Full GC 的次数与累计耗时,GCT 是 GC 总耗时。读它的关键动作只有一个——盯 FGC 每次 +1 之后 O 列停在哪里:
# 每 10 秒采样一次,重点盯 O(老年代使用率)与 FGC(Full GC 次数)
$ jstat -gcutil 23456 1000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.0 45.2 78.3 89.4 88.2 87.6 1204 12.31 2 3.10 15.41
0.0 12.5 22.6 91.2 88.2 87.6 1207 12.44 5 4.92 17.36
0.0 0.0 45.8 93.5 88.2 87.6 1211 12.59 8 6.85 19.44
0.0 38.2 67.4 96.1 88.2 87.6 1216 12.78 11 8.90 21.68
# FGC 从 2→5→8→11,每次 Full GC 刚结束时 O 列的残留:91.2 → 93.5 → 96.1
# 每次 Full GC 的"回收后基线"都比上一次更高——典型的泄漏曲线
上面这份示例日志里,Eden(E 列)该清零清零、Survivor 该变化变化,年轻代一切正常;唯一的异常是:FGC 从 2 涨到 11 的三次 Full GC 之后,老年代残留从 91.2% 一路抬到 96.1%,一次都没落回去。Full GC 是堆里最彻底的清理,它都清不掉的残留,就是"被引用拽着"的对象。这就是泄漏的强信号。
判断"泄漏 vs 正常增长"的三问
曲线涨了不等于泄漏——缓存预热、大查询结果集、批量任务都会让老年代短期上涨。对着曲线问三个问题,基本能定性:
- Full GC 之后能否回落?能回落到之前的水位 → 大概率正常;回不去 → 继续问第二问。
- 回落后的基线是否逐次抬高?在某个水位稳定下来(比如稳定在 55% 不再动)→ 正常,只是堆设小了,见《JVM 调优参数手册》;像楼梯一样每级 +2% → 泄漏。
- 增长速率与业务量是否线性相关?正常缓存的增长随业务量增长而收敛(热点被填满后就不涨了);泄漏的增长与流量无关,凌晨低峰照样涨——这是泄漏最"诚实"的特征。
一个帮助记忆的画面:正常内存使用像潮汐——涨潮落潮,海岸线(基线)不动;泄漏像海平面上升——每次落潮都多露出一片新的陆地,潮水永远回不到原来的位置。
生产环境:从"人工看"到"机器盯"
jstat 是人肉排查工具,生产长期监控要靠指标体系:Micrometer 把 JVM 指标(堆各代使用量、GC 次数与耗时)暴露给 Prometheus,配两条告警就能覆盖大部分泄漏的早期信号:
- Full GC 后老年代残留 > 85%(或连续 3 次 Full GC 残留递增)——泄漏强信号;
- 10 分钟内 Full GC > 3 次,或 GC 时间占比 > 30%——服务已经在"用 GC 续命"。
指标的采集、告警阈值的完整设计见《GC 日志分析实战》的监控一节,这里只强调一点:泄漏排查拼的是"发现得早",等 OOM 了再查,你手里只剩一个已经发生过的现场。
加分项:JFR 长周期录制
如果你用的 JDK 是 9+(JDK 11 起 JFR 免费开放),还有一个低成本手段:JFR(Java Flight Recorder)持续录制。它开销极低(通常不到 1%),可以录数小时甚至数天,OOM 或怀疑泄漏时随时导出回放:
# 启动时开启(JDK 9+,JDK 11 起免费):低开销,可长时间录制
-XX:StartFlightRecording
# 运行时按需开始 / 导出(settings=profile 才会采集高频分配事件)
$ jcmd 23456 JFR.start name=leakhunt duration=1h filename=/data/jfr/leak.jfr settings=profile
$ jcmd 23456 JFR.dump name=leakhunt filename=/data/jfr/leak2.jfr
# 回放时看分配事件(jdk.ObjectAllocationInNewTLAB 等):谁在分配大对象
# 配合 MAT 的 dump,一个回答"谁在分配",一个回答"谁还拽着"
JFR 的分配事件回答的是"谁在分配",MAT 的引用链回答的是"谁在持有"——两个工具拼起来,泄漏排查就没有盲区了。
jstat 里 M 列(Metaspace)持续增长,别当成堆泄漏——那是类加载泄漏(CGLIB 动态代理、Groovy / JS 脚本引擎反复编译新类),漏的是堆外的 Metaspace,heap dump 根本看不见它。正确姿势是 jstat -class 看类数量趋势,详见《OOM 排查实战》。看趋势时 O 和 M 要分开判。
jstat -gcutil 是泄漏的"听诊器":盯 FGC 每次 +1 后 O 列的残留,基线逐级抬高 = 泄漏强信号;再用三问定性(能否回落 / 基线是否抬高 / 与业务量是否解耦)。生产用 Prometheus 告警提前发现,JFR 长周期录制留一手回放数据。
导出 Heap Dump:三种方式与风险取舍
趋势确认后进入取证:把堆在某个时刻的完整快照存成文件(.hprof)。Heap Dump 记录 dump 时刻堆里每个对象的实例数据、引用关系和占用大小——它是 MAT 的"检材"。取证的原则是在风险最小的时机拿最完整的证据。
方式一:手动导出(jmap)
# ① 完整导出:不触发 GC,dump 时刻堆里的全部对象(含将死的)
$ jmap -dump:format=b,file=/data/dumps/heap_$(date +%Y%m%d_%H%M%S).hprof 23456
# ② 只导存活对象::live 先触发一次 Full GC,再只导出存活的
$ jmap -dump:live,format=b,file=/data/dumps/heap_live.hprof 23456
# ③ JDK 9+ 官方更推荐 jcmd(输出更规范,参数模型一致):
$ jcmd 23456 GC.heap_dump /data/dumps/heap.hprof
# jcmd 对应 JMX 的 DiagnosticMXBean#dump_heap 命令,
# 远程 JMX 场景下也能通过同一通道触发,不一定要登录服务器
# ④ 快速看 Top 对象(不 dump 整个堆,先缩小范围;:live 同样触发 Full GC)
$ jmap -histo:live 23456 | head -25
两个参数细节值得说透:
:live是双刃剑:它先触发 Full GC 只导存活对象,文件小、分析快;但"刚死掉的对象"和 GC 前的引用关系全部消失——如果你要分析的对象正好是短命的(比如想确认某个临时大对象为什么没被收),:live会把它洗掉。泄漏排查通常加:live(泄漏对象本来就"死不了",GC 一次反而能把噪音清掉、文件更小)。-F(强制)是最后手段:jmap 无响应(进程卡在长 GC 里)时,jmap -F改用 SA 直接读进程内存,更慢更危险,能用jcmd就不要用它。
风险:dump 全程 STW,暂停时长与堆大小和存活对象数成正比——4GB 堆通常停数秒,16GB 堆、存活对象多时可能停 10 秒以上。线上高峰期这一秒停顿可能是 P0 事故,所以手动 dump 的最佳时机是流量低谷,或者更稳妥的做法:摘一台同版本的副本实例(同步一份数据),在副本上 dump,线上进程完全不受影响。
方式二:OOM 自动转储(生产必配)
比手动 dump 更可靠的思路是提前埋伏:让 JVM 在 OOM 的那一刻自己保存快照——反正那一刻服务已经要挂了,dump 的 STW 成本是零:
java -jar app.jar \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps/heap_%p.hprof
# %p 展开为进程 PID(较新的 JDK 8 版本及 JDK 9+ 支持)
# 效果:OOM 发生 → JVM 自动写完整堆快照 → 进程退出
# 凌晨三点 OOM,早上你也能拿到第一现场,而不是一个空壳容器
# 参数全貌与配套参数见《JVM 调优参数手册》
注意:OOM 自动 dump 的是OOM 时刻的完整堆(没加 live 语义),如果堆 8G 且几乎全满,dump 文件就是 7~8G 的大家伙——所以 HeapDumpPath 所在磁盘必须提前留足空间(下一节说预估)。
三种方式怎么选:风险对比表
| 方式 | STW 成本 | 触发时机 | 文件内容 | 适用场景 |
|---|---|---|---|---|
jmap -dump(不带 live) | 有,与堆成正比 | 手动,须挑低峰 | dump 时刻全量堆(含将死对象) | 需要分析 GC 前状态 / 短命对象 |
jmap -dump:live / jcmd GC.heap_dump | 有,还多一次 Full GC | 手动,须挑低峰 | 存活对象(文件小,推荐) | 常规泄漏排查首选 |
| OOM 自动转储 | 无(服务已 OOM) | 自动 | OOM 时刻全量堆 | 生产必配,事故第一现场 |
Dump 文件多大?先算账再动手
.hprof 的大小约等于存活对象的总大小(加上引用信息的少量开销),一定小于 -Xmx。粗略估算:5G 堆、老年代占用 80% 左右 → dump 文件约 1~4G(取决于对象头开销和引用结构密度)。动手前两个动作:df -h 确认磁盘余量(dump 文件 + MAT 索引通常还要 1~2 倍空间);确认目标目录在独立磁盘上,而不是和日志抢同一块盘。
别在磁盘紧张的生产机上 dump。dump 写到一半磁盘打满,日志写不了、容器写不了元数据,一个内存问题变成整机故障——二次事故比原事故还难查。磁盘余量不足 dump 文件预估的 3 倍时,改用"服务器本地 MAT 解析"或"副本实例 dump"(下一站开头会讲)。
两个办法。其一:在服务器上跑 MAT 的命令行模式(mat/ParseHeapDump.sh heap.hprof)建索引、生成 Leak Suspects 报告,只把几十 MB 的报告文件(.html/.zip)传出来,MAT 图形界面离线看报告。其二:先 jmap -histo:live 看 Top 20 类,如果某个业务类实例数已经明显反常(比如 CacheEntry 一千万个),直接去代码里找它,连 dump 都省了。不是每个泄漏都需要完整 dump,histogram 经常就够定性。
"取证有两条路:平时手动,用 jcmd GC.heap_dump(比 jmap 更现代),加 live 语义先 Full GC 只导存活对象,挑流量低谷操作;更重要的是一条生产必配的埋伏——HeapDumpOnOutOfMemoryError + HeapDumpPath,OOM 时自动留现场,零成本。dump 前先看磁盘余量,大堆服务优先在副本实例上 dump,或者服务器本地用 MAT 命令行解析、只把报告传出来。"
MAT 分析(上):从 dump 到嫌疑对象
面试官第二问:"拿到一个 3G 的 hprof,你具体怎么操作 MAT?"这一站是全文核心,我按真实工作流走:打开 → Leak Suspects 报告 → Histogram → Dominator Tree → Path to GC Roots → OQL → 两次 dump 对比,每一步都告诉你看什么、点什么、输出长什么样。
先交代成本:MAT 打开 hprof 后要先建索引(解析全部对象和引用),2G 的 dump 通常要几分钟到十几分钟,索引文件还要额外占 1~2 倍空间——所以服务器本地解析时同样要看磁盘。
第一步:Leak Suspects 报告——让 MAT 先自首
索引建完,MAT 会自动生成 Leak Suspects Report,包含四个部分:Overview(全局概览 + 潜在泄漏占比)、Dominators(最大的支配者)、Top Components(按组件归类的大头)、System Overview(JVM / 线程 / 类加载器概况)。第一眼看 Overview 里的 "Potential Memory Leak":MAT 会算出"如果某个对象死了,能释放掉堆的百分之多少",占比超过 90% 时,基本可以直接点名。示例输出:
System Overview
heap: 4,830,956,416 bytes (4.5 GB)
class loaders: 37, threads: 82, JVM: HotSpot 64-Bit Server VM (11.0.20)
Potential Memory Leak
97.8% of heap is occupied by one component:
- com.app.cache.ProductCache (1 instance, 3.1 GB retained)
via static field ProductCache.INSTANCE
Dominators (top 3 by retained heap)
1. com.app.cache.ProductCache 3,328,641,536 97.8%
2. java.lang.ClassLoader (app) 120,418,000 3.5%
3. Thread "http-nio-8080-exec-3" 8,202,144 0.2%
Reference chain(报告里自带的引用链)
GC Root: static field com.app.cache.ProductCache.INSTANCE
→ field map → java.util.concurrent.ConcurrentHashMap
→ field table → Node[] (1,048,576 entries)
→ Node → value → com.app.model.Product → byte[] …
这份报告一次性回答了三件事:谁占了最多(ProductCache,97.8% 的堆)、里面装了什么(一个装了 100 多万条目的 ConcurrentHashMap)、为什么 GC 不收(static 字段直接挂着,是 GC Root)。90% 以上的占比就是"铁证",直接进第五站对代码;占比分散(比如前三名各 30%)时,报告只能指方向,需要下面的工具接力。
第二步:Histogram——类聚合视图,先分清两个 heap
Histogram 把全堆对象按类聚合:每行一个类,显示实例数、shallow heap 合计、retained heap 合计。用它之前必须分清两个概念——这是整个 MAT 里最值钱的知识点:
- Shallow Heap(浅堆):对象自己占多大——对象头 + 实例字段,不含它引用的对象。
- Retained Heap(保留堆 / 独占堆):如果把这个对象删掉,总共能释放多少内存——它自己 + 所有"只能经过它才能到达"的对象(独占保留)。
Retained Heap = 它 + 只有它拽着才活着的对象("我死了能腾出多少")
查泄漏永远按 Retained Heap 排序找"老板",Shallow 排序找到的多是"叶子"
为什么按 shallow 排序会"被骗"?因为 byte[]、char[]、HashMap$Node 这些"叶子"类在 shallow 榜上永远排前几名——它们自己确实大,但它们只是被引用的数据,真正决定"该不该死"的是持有它们的容器。按 retained 排序,容器(那个 Map、那个 Service 实例)才会浮出水面:它自己很小,但它一死能腾出几个 G,这就是"老板"。
第三步:Dominator Tree——内存的"管辖权"视图
支配(Dominator)关系:对象 A 支配对象 B,当且仅当从所有 GC Root 到 B 的路径都必须经过 A——A 一死,B 必然跟着死。Dominator Tree 按这个关系把全堆组织成一棵树:根是 GC Roots,每个节点的 retained heap 就是"这块内存归谁管"。
文字版示例(对应上面的报告):
# 打开 Dominator Tree(Window → Dominator Tree),按 Retained Heap 排序:
com.app.cache.ProductCache 3,328,641,536 97.8% ← 顶层第 1 名:3.2G 归它管
└─ java.util.concurrent.ConcurrentHashMap 3,328,590,000
└─ Node[] 3,300,000,000 ← 1,048,576 个槽位
└─ (每个 Node → key String + value Product → byte[] …)
java.lang.Thread "http-nio-8080-exec-3" 8,202,144 0.2% ← 顶层第 3 名
└─ ThreadLocal$ThreadLocalMap → Entry[] → value …(第 5 站的主角)
用法:顶层节点就是"最大一块内存归谁管"。对顶层第 1 名右键 → List Objects → with outgoing references,可以一层层展开它管着哪些对象,找到真正的"重灾区"字段。如果顶层是 java.lang.Thread(线程对象支配了几个 G),基本可以直接跳到第 5 站的 ThreadLocal 场景——线程本身不占多少内存,它管着的 ThreadLocalMap 才是大头。
第四步:Path to GC Roots——反查"谁还拽着它"
前两步找到嫌疑对象后,最后一步是回答"它为什么死不了":选中嫌疑对象(或它的类),右键 → Path to GC Roots → exclude weak/soft/phantom references。这个勾选项必须勾——不勾的话,经过弱引用的链也会显示出来,而弱引用在 GC 时是允许被清掉的,那种链是假线索(对象其实下轮 GC 就没了,不是泄漏)。
输出是一串从 GC Root 到嫌疑对象的引用链,文字版示例(ThreadLocal 泄漏场景):
# 选中某个 UserContext 实例 → Path to GC Roots (exclude weak/soft/phantom):
GC Root: Thread "http-nio-8080-exec-1" ← 线程池里的长命线程
→ field threadLocals java.lang.ThreadLocal$ThreadLocalMap
→ field table Entry[] (length = 64)
→ element [17] ThreadLocal$ThreadLocalMap$Entry
# ref == null —— key(弱引用)已被回收,但 value 还活着
→ field value com.app.context.UserContext
→ field data java.util.HashMap (1024 entries)
→ … byte[] / String …(合计 10MB)
读链的方法从下往上读:最下面是被拽的对象(UserContext),沿着链往上看谁在持有它——Entry 的 value 字段、Entry 的持有者 ThreadLocalMap、Map 的持有者 Thread——最后一个"活人"就是元凶:一个长命线程池线程,通过 ThreadLocalMap 里一个 ref == null 的 Entry,把本该在请求结束就死掉的 UserContext 挂了两周。链读到这里,代码定位就只剩一步:全局搜索 UserContext.set,找到那个没写 remove() 的 Filter。
第五步:OQL——对堆写 SQL
GUI 看不到的精确问题,用 OQL(Object Query Language)查。三条最常用的:
// ① 找大字符串:自身超过 1MB 的 String(@usedBytes 是 shallow 大小)
SELECT * FROM java.lang.String s WHERE s.@usedBytes > 1000000
// ② 数"僵尸 Entry":ThreadLocalMap 里 key 已被回收但 value 还活着的条目
// (stale entry —— ThreadLocal 泄漏的指纹,count 很大就是实锤)
SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap$Entry e WHERE e.ref == null
// ③ 查某个类的所有实例:确认是不是"同一个 Map 实例在涨"(缓存泄漏特征)
SELECT * FROM INSTANCEOF "com.app.cache.ProductCache"
第六步:两次 dump 对比——锁定"增长源"
什么时候用?两种情况:Leak Suspects 指向不明(占比分散,看不出谁是大头),或者泄漏刚起步(单看任何一个 dump,嫌疑对象还没长到显眼)。做法:间隔 30 分钟(或一个业务周期)导两个 dump,用 MAT 的 Compare Two Snapshots(老版本用 Merge Baselines 先合并基线再对比),直接看delta(增量)最大的类和实例——增长最快的就是增长源,不用猜"谁占得多",只看"谁在涨"。
回答模板:shallow heap 是对象自己的大小,retained heap 是"删掉它能释放的总量"。泄漏的大头通常是容器——一个 HashMap 自己只有几十字节(shallow 榜上排不进前 20),但它独占保住了几 GB 的 Entry 和 value(retained 榜上排第 1)。按 shallow 排序,你只会看到一堆 byte[]、char[] 这些"叶子",它们大,但决定生死的是持有它们的"老板"。所以 MAT 查泄漏的第一动作永远是:Histogram 按 retained heap 排序,找 retained 最大的实例,再看它归谁支配、被谁引用。
完整案例:一次泄漏排查的闭环
把前四站串起来走一遍(场景:电商商品服务,老年代 96% 不回落):
- 趋势(第 2 站):监控告警"Full GC 后老年代残留连续 3 次递增",jstat 确认 O 列 91%→93%→96%,FGC 间隔从 2 小时缩到 20 分钟。三问定性:回不去、基线抬高、凌晨低峰也在涨 → 泄漏。
- 取证(第 3 站):凌晨流量低谷,
jcmd <pid> GC.heap_dump /data/dumps/heap.hprof,4.5G 文件;同时确认 HeapDumpOnOutOfMemoryError 已配(兜底)。 - 定位(本站):MAT 打开 → Leak Suspects:Overview 显示 "97.8% 被一个 com.app.cache.ProductCache 实例占用(static 字段)" → Histogram 按 retained 排序,ProductCache 第 1,其内部 ConcurrentHashMap$Node 实例 104 万 → Dominator Tree 确认"3.2G 归 ProductCache 管" → Path to GC Roots:static 字段 ProductCache.INSTANCE 直连 GC Root。
- 根因(第 5 站):全局搜索 INSTANCE,定位到
ProductCache第 23 行:private static final Map<String, Product> CACHE = new ConcurrentHashMap<>();——只 put 不淘汰、无容量上限、无过期。缓存泄漏实锤。 - 修复(第 7 站):换成 Caffeine(maximumSize + expireAfterWrite),压测一周,老年代基线恢复锯齿形、Full GC 消失。
MAT 四步速查表
| 步骤 | 看什么 | 点什么 | 输出长什么样 |
|---|---|---|---|
| Leak Suspects | Overview 的 Potential Memory Leak 占比(>90% 直接点名) | 打开 hprof 后自动生成 | 四段式报告:概览 / 支配者 / Top 组件 / 系统概况 |
| Histogram | 按 retained heap 排序找"老板"类与实例 | Histogram 窗口,点 "Retained Heap" 列头 | 类聚合表:实例数 / shallow / retained |
| Dominator Tree | 顶层节点:"最大一块内存归谁管" | 右键 → List Objects → with outgoing references 逐层展开 | 按支配关系的树,节点标 retained 大小与占比 |
| Path to GC Roots | 引用链(勾 exclude weak/soft/phantom) | 选中嫌疑对象 → 右键 → Path to GC Roots | 从 GC Root 到对象的字段级引用链 |
"MAT 分析我按四步走:先读自动生成的 Leak Suspects 报告,Overview 里 Potential Memory Leak 占比超 90% 就直接点名;不直观就上 Histogram 按 retained heap 排序找占大头的实例(shallow 排序会被 byte[] 这些叶子骗了);再用 Dominator Tree 确认'这块内存归谁管';最后 Path to GC Roots(勾上 exclude weak/soft/phantom)反查'谁还拽着它',引用链落到具体字段,对应回代码。拿不准时补两招:OQL 精确查询、两次 dump 对比看增量。"
三大典型泄漏场景逐一拆解
定位到"谁在持有"之后,最后一问是"业务上为什么持有"。线上内存泄漏绝大多数逃不出三个场景,每个都有固定的现象、固定的 dump 特征、固定的修法。把这三个场景的"dump 长相"记熟,看到 dump 就能反推代码问题。
场景一:ThreadLocal 未 remove(线程池重灾区)
先看机制。每个 Thread 对象内部有一个 ThreadLocalMap,Map 的 Entry 结构是:key 是 ThreadLocal 实例的弱引用(WeakReference),value 是业务对象的强引用。当 ThreadLocal 变量超出作用域、实例被回收后,key 变成 null(这种条目叫 stale entry,僵尸条目),但 value 是强引用,照样活着——于是 value 被线程"永久"持有。
三个关键点,每个都是面试追问点:
- 为什么 key 设计成弱引用?——这是 JDK 的"止损设计":key 用弱引用,至少保证 ThreadLocal 实例自己不会被 Map 拽着泄漏;但它救不了 value。所以弱引用 key 只解决了一半问题,另一半(remove)必须业务代码自己负责。
- 为什么 ThreadLocalMap 不自己清掉 stale entry?——它其实会:每次
set / get / remove时顺带清理相邻的 stale entry(探测式清理)。但线程池场景下,如果业务之后再也不碰这个 ThreadLocal,清理就永远不会发生;而且探测式清理只清邻近槽位,不遍历全表——线程不销毁时,value 就是永久滞留。 - 为什么线程池放大问题?——非线程池场景线程跑完就销毁,ThreadLocalMap 整体随线程回收,问题被"掩埋";线程池线程长期存活,泄漏变成显性的、累积的。
// ===== 泄漏代码:set 了,但从没 remove =====
public class UserContextFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws Exception {
UserContext.set(req.getHeader("X-User-Id")); // 只 set 不 remove
chain.doFilter(req, resp); // 请求结束,线程池线程里的值还挂着
}
}
// ===== 修复代码:try-finally 铁律 =====
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws Exception {
try {
UserContext.set(req.getHeader("X-User-Id"));
chain.doFilter(req, resp);
} finally {
UserContext.remove(); // ← 关键:无论成功异常都要清
}
}
跨线程传递场景(线程池提交任务时带上下文)用 TransmittableThreadLocal(阿里 TTL),它在任务提交 / 执行 / 结束的生命周期里自动完成传递与清理,原理见并发卷《ThreadLocal 原理与内存泄漏》。dump 特征:大量 ThreadLocal$ThreadLocalMap$Entry 且 ref == null(OQL 第二条查询直接数数量);顶层 Dominator 是若干 java.lang.Thread。
场景二:数据库连接 / 资源未关闭
泄漏链:Connection 被某处持有(异常跳过了 close、或连接被塞进某个集合"借出去还回来")→ 连接池可用数持续下降 → 连接池耗尽(业务表现为 getConnection 超时 / 等待)→ 堆里伴随 Connection / PreparedStatement / 结果集 byte[] 堆积。
// ===== 泄漏代码:executeQuery 抛异常,conn 永远不会 close =====
public List<Order> findOrders(Long userId) throws SQLException {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE uid = ?");
ps.setLong(1, userId);
ResultSet rs = ps.executeQuery(); // 这里抛异常 → 下面一行不执行
conn.close(); // ← 异常路径永远到不了这里
return map(rs);
}
// ===== 修复代码:try-with-resources(所有 Closeable 的铁律)=====
public List<Order> findOrders(Long userId) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE uid = ?")) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
return map(rs);
}
} // ← 自动 close,逆序、异常也执行
}
加分点:连接池泄漏探测。HikariCP 提供 leakDetectionThreshold(毫秒):连接被借出超过阈值还没归还,池就会打印借出那一刻的完整调用栈——相当于自动帮你抓"谁拿着连接不还",比 dump 快得多。生产建议配 30s 量级(覆盖最慢的慢查询)。
"连接池耗尽"不等于"堆内存泄漏"。先分清战场:看池指标(active / idle / wait count)确认是不是连接没还;如果堆里 Connection 对象并不堆积,问题多半在堆外(线程、文件句柄)或数据库侧,别急着 dump 堆。
场景三:内存缓存无淘汰策略
用 static HashMap / ConcurrentHashMap 当缓存、只 put 不删、无容量上限——第 4 站案例的根因就是它。dump 特征:Dominator Tree 顶层是某个 Map 实例(retained 巨大),且对比两次 dump 能看到实例条目数持续增长(增长源明确)。
修复首选 Caffeine:maximumSize(容量上限)+ expireAfterWrite(写后过期)+ recordStats(命中率统计)。它的淘汰策略是 W-TinyLFU(窗口 + 频次准入),对偏斜访问(少数热点 key 反复读)比经典 LRU 命中率高得多——这是它"比 HashMap 缓存安全"的完整答案:有界(maximumSize 封顶)+ 有淘汰(W-TinyLFU 主动驱逐)+ 有统计(命中率可监控,能提前发现缓存被刷穿)。Guava Cache 思路相同,但 Caffeine 是其重写升级版(并发性与统计更好),新项目直接用 Caffeine。
"用 SoftReference 当缓存不是天然安全吗?"——不推荐生产使用。软引用的回收由 GC 决定,与内存压力强耦合:压力小时它跟强引用一样赖着不走(没帮你省内存),压力大时一口气全清(缓存瞬间失效、请求全打到 DB)。行为不可控、不可预测,只适合"能放更好、丢了也无所谓"的边缘场景。缓存要可控,就用显式的容量 + 过期。
附加场景(简述):还有两种"长得像泄漏"的问题
- 内部类持有外部类:非静态内部类 / 匿名类隐式持有外部实例的
this$0引用——一个存进长命集合的 Runnable 内部类,会把它所在的外部 Service 整个拽住。修法:内部类改 static、或对依赖用弱引用。GC Roots 视角的分析见《GC Roots 详解》。 - 类加载器泄漏 → Metaspace:动态代理 / 脚本引擎反复定义新类,旧类加载器因被某处引用无法卸载,Metaspace 只增不减(M 列持续增长就是信号)。注意它不是堆泄漏,heap dump 查不到:用
jstat -class看已加载类数量趋势(持续上涨 = 有类在不停生成),JDK 9+ 用jcmd <pid> VM.classloader_stats看每个类加载器加载的类数与字节数,完整排查见《OOM 排查实战》。
| 场景 | 典型现象 | dump 特征(MAT 里长什么样) | 修复 |
|---|---|---|---|
| ThreadLocal 未 remove | 老年代缓慢上涨;线程池线程的 ThreadLocalMap 越挂越多 | 大量 ThreadLocal$Entry 且 ref == null;顶层 Dominator 是 Thread | finally 中 remove();跨线程用 TransmittableThreadLocal |
| 连接 / 资源未关闭 | 连接池耗尽告警;getConnection 超时 | Connection / Statement / 结果集 byte[] 堆积 | try-with-resources;HikariCP leakDetectionThreshold |
| 缓存无淘汰 | 老年代上涨与请求量弱相关;缓存命中率先升后降 | 单个 Map 实例 retained 巨大;两次 dump 条目数持续增长 | Caffeine(maximumSize + expireAfterWrite + recordStats) |
| 类加载器泄漏(附加) | M 列持续增长,最终 Metaspace OOM | heap dump 看不到;看类数量与 classloader 统计 | 修复动态生成类的复用;jstat -class / jcmd VM.classloader_stats |
三大场景各记一句:ThreadLocal 记"key 弱 value 强 + 线程池长命 = 僵尸 Entry",修法是 finally remove;连接记"异常路径跳过 close",修法是 try-with-resources + leakDetectionThreshold;缓存记"static Map 只进不出",修法是 Caffeine 有界有淘汰。dump 特征反过来记:stale Entry、Connection 堆积、单个 Map retained 巨大——看到 dump 就能反推代码。
VisualVM:听诊器与工具分工
前几站的工具都偏"事后"——问题已经发生了才查。VisualVM 补上"事前感知"这一块:如果 MAT 是手术刀(深度解剖),VisualVM 就是听诊器(快速听个大概)。它通过 JMX 实时连到目标 JVM,免费、轻量、开箱即用。
三个面板,三个用法
- Monitor 面板:堆内存曲线(和 jstat 的 O 列同构)、CPU、GC、线程数四个实时图。日常盯盘就看堆曲线——锯齿形且基线不动 = 健康;基线开始爬坡 = 启动第 2 站的三问流程。线程数曲线单独看:线程数持续上涨不回落,是线程泄漏(任务线程创建后从不结束),线程泄漏同样会间接吃内存(每个线程默认 1MB 栈,还在堆外)。
- Threads 面板:实时线程列表 + 状态;点 "Thread Dump" 等价于
jstack。排查技巧:隔 30 秒连打 3 次 Thread Dump 对比,连续 3 次都卡在同一个栈帧的线程,就是长期阻塞点(死锁、锁竞争、IO 等待)。 - Sampler 面板:CPU 采样(谁在空转)和 Allocation 采样(新版 VisualVM 1.4+ 对 JDK 8 支持)——"谁在分配"视图,直接看分配热点方法。Allocation 采样比 dump 更早发现泄漏苗头:老年代还没涨起来时,分配曲线里某个方法的分配量就已经异常了,这是"趋势"阶段的有力补充。
四个工具的分工(一张表记住)
| 工具 | 角色 | 什么时候用 | 产出 |
|---|---|---|---|
| VisualVM | 听诊器 | 日常盯盘、快速感知异常 | 实时曲线、Thread Dump、采样热点 |
| jstat -gcutil | 心电图 | 确认趋势(三问定性),低开销长时采样 | 数字趋势(FGC 后 O 列残留) |
| jcmd GC.heap_dump | 证据箱 | 确认后取证(低峰 / 副本 / OOM 自动) | .hprof 快照 |
| MAT | 显微镜 | 深度解剖:找类、找引用链、找根因 | Leak Suspects / 引用链 / OQL 结果 |
完整工作流:VisualVM 日常盯盘(感知)→ jstat 确认趋势(判断)→ jcmd 导出 dump(取证)→ MAT 深度分析(定位)。生产环境的长期监控不要靠 VisualVM 人工盯,用 Prometheus + Grafana 指标告警(第 2 站),VisualVM 留给临时诊断——它需要图形界面和 JMX 通道,不适合 7x24 值守。
同源不同代。JConsole 是 JDK 自带的基础监控(内存 / CPU / 线程 / VM 五个标签页);VisualVM 在 JConsole 基础上加了 Heap Dump、Thread Dump、Profiler / Sampler 等能力,还能远程连 JMX、多实例并排看。JDK 9 之后 VisualVM 不再随 JDK 捆绑,要单独下载(官网或 maven 仓库)。面试里顺带提一句"JDK 9 起不捆绑、生产长期监控交给指标体系",能体现你跟过版本演进。
VisualVM = 实时听诊:Monitor 看堆曲线与线程数、Threads 连打 3 次 Dump 抓阻塞、Sampler 的 Allocation 视图比 dump 更早看到"谁在分配"。定位和定性仍交给 jstat + jcmd + MAT,四工具各管一段。
预防体系:让泄漏不发生,而不是难排查
排查是救火,预防是防火。泄漏的根因高度集中在"引用生命周期没人管",所以预防也高度模式化:编码规范管住手,架构手段兜住底,CI 验证兜住最后一条线。
编码规范:五条铁律
- ThreadLocal 必须 finally remove——set 与 remove 必须成对出现,写在同一个方法里,不依赖"框架会帮我清"。
- Closeable 必须 try-with-resources——Connection / Stream / Lock / Channel,凡是实现了 AutoCloseable 的,手动 close 一律视为坏味道。
- 本地缓存必须有界 + 有过期——maximumSize 与 expireAfterWrite 一个都不能少;无界 Map 出现在 static 字段里,Review 直接打回。
- 监听器 / 观察者必须成对注册、注销——addListener 的地方必须有对称的 removeListener(对象销毁钩子里)。
- 静态集合必须有淘汰机制——任何
static Map / List / Set,要么有界,要么有定期清理,要么能说出"为什么它永远不会大"。
架构手段:四个兜底
- Caffeine 替代手搓缓存:统一本地缓存选型,开
recordStats并把命中率暴露成 Prometheus 指标——命中率突然掉到 30% 以下,往往意味着缓存被异常 key 刷穿(也是泄漏前兆)。 - 对象池关注"归还":HikariCP 的 leakDetectionThreshold 常开;自研池子的借出 / 归还必须配对埋点。
- JFR 长周期录制 + 定期回放:核心服务常开 JFR(开销可忽略),每周回放一次 allocation 与 GC 事件,泄漏苗头在"趋势"阶段就能被看见。
- CI / 预发加内存泄漏测试:同一负载循环跑 N 轮(比如 20 轮压测脚本),每轮记录 Full GC 后老年代基线——基线稳定 = 没有泄漏,基线逐轮抬高 = 有泄漏。这就是第 2 站"三问"的工程化版本,把"等两周才发现"压缩到"发布前 30 分钟"。
Code Review 检查清单(6 条,可直接贴进团队 Review 模板)
- ① ThreadLocal.set 之后,finally 里有没有 remove?
- ② 每个 getConnection / openStream 是否都在 try-with-resources 里?
- ③ 这个 Map 有容量上限吗?有过期策略吗?谁负责淘汰?
- ④ listener / observer 注册了,在哪里注销?
- ⑤ 这个 static 集合会一直增长吗?上界是什么?
- ⑥ 放进长命容器(缓存 / 池 / 静态字段)的对象,业务上真的还需要那么久吗?
预防 = 五条编码铁律(remove / TWR / 有界缓存 / 监听器成对 / 静态集合有淘汰)+ 四个架构兜底(Caffeine 统一 + 泄漏探测 + JFR 常开 + CI 多轮压测看基线)。排查能力决定你多快止血,预防体系决定你多久才流血。
总结:SOP 流程图 + 知识地图 + 面试模板
排查 SOP 全流程图
知识地图
本文全部知识点(能默画出来就算内化):
- 定义:泄漏 = 无用途对象仍被 GC Roots 可达(引用没释放);与"堆太小 / 大对象"是 OOM 的两种不同根因,看曲线区分(《GC Roots 详解》《JVM 堆内存》《OOM 排查实战》)。
- 发现:jstat -gcutil 盯 FGC 后 O 列残留,基线逐级抬高 = 强信号;三问定性(能否回落 / 基线抬高否 / 与业务量解耦否);Prometheus 告警 + JFR 长周期录制(《GC 日志分析实战》)。
- 取证:三种方式——jmap/jcmd 手动(:live 先 GC 只导存活;STW 与堆成正比,挑低峰/副本)、OOM 自动转储(生产必配,零风险时机)、GUI/JMX;dump 大小 ≈ 存活对象总量,动手前看磁盘(《JVM 调优参数手册》)。
- 定位:MAT 四步——Leak Suspects(占比 >90% 点名)→ Histogram(retained 排序找老板,别被 shallow 的 byte[] 骗)→ Dominator Tree(支配 = 所有路径必经)→ Path to GC Roots(勾 exclude weak/soft/phantom,从下往上读链);辅助:OQL 三条、两次 dump 对比看增量。
- 根因:三大场景——ThreadLocal(key 弱 value 强 + 线程池长命 = stale Entry,finally remove)/ 连接资源(异常跳过 close,TWR + leakDetectionThreshold)/ 无界缓存(Caffeine 有界+淘汰+统计);附加:内部类 this$0、类加载器 → Metaspace(jstat -class / jcmd VM.classloader_stats)。
- 工具分工:VisualVM 听诊(Monitor / Threads / Allocation 采样)→ jstat 心电图 → jcmd 证据箱 → MAT 显微镜。
- 预防:五条编码铁律 + 四个架构兜底 + CI 多轮压测看老年代基线 + Code Review 六问。
面试回答模板
"内存泄漏是对象没用了还被引用链拽着,GC 收不掉。我按五步排查:趋势——jstat -gcutil 看 Full GC 后老年代残留是否逐级抬高;取证——jcmd GC.heap_dump,生产必配 HeapDumpOnOutOfMemoryError 兜底;定位——MAT 三把刀:Leak Suspects 看占比、Histogram 按 retained heap 排序找大户、Path to GC Roots 反查谁持有它;根因——九成是 ThreadLocal 没 remove、资源没 close、缓存无淘汰,dump 特征各不相同;修复后压测验证基线恢复锯齿形。"
30 秒版 + 一个案例 + 三个深度点。案例:服务两周后 OOM,jstat 显示 FGC 后老年代 91%→93%→96% 不回落,三问确认泄漏(低峰也涨);低峰 jcmd dump,MAT 的 Leak Suspects 没直接点名(占比分散),改看 Dominator Tree——顶层是几个线程对象,展开是 ThreadLocalMap 里大量 ref == null 的 Entry;Path to GC Roots 读出引用链:线程池长命线程 → ThreadLocalMap → stale Entry → value(请求上下文);定位到 Filter 里 set 后没有 remove。三个深度点:① 为什么 value 收不掉——key 是弱引用(JDK 止损设计)但 value 是强引用,弱引用 key 救不了 value;② 为什么线程池放大——线程不销毁、没有"下一次 set/get"触发探测式清理,stale Entry 永久滞留;③ 为什么按 retained 排序——容器 shallow 很小但独占保住几 GB,shallow 榜上排前的是 byte[] 这类"叶子",不是"老板"。收尾:修复是 finally remove + CI 加多轮压测看老年代基线,把两周才能发现的泄漏压到发布前发现。
高频追问速答
| 追问 | 答案要点 |
|---|---|
| 怎么区分泄漏和正常增长? | 三问:Full GC 后能否回落?回落基线是否逐次抬高(楼梯状 = 泄漏,稳定 = 堆小)?增长是否与业务量解耦(低峰也涨 = 泄漏) |
| dump 会不会影响线上服务? | 会:STW 时长与堆和存活对象数成正比(4G 堆通常数秒)。对策:挑流量低谷 / 副本实例 dump;:live 先 Full GC 只导存活;最稳的是 OOM 自动转储(服务已挂,零成本);dump 前 df -h 看磁盘 |
| shallow heap 和 retained heap 区别? | shallow = 对象自身大小;retained = 删掉它能释放的总量(独占保留)。查泄漏按 retained 排序找容器"老板",按 shallow 会被 byte[]/char[] 叶子骗 |
| MAT 里 ref == null 的 Entry 是什么? | stale entry:ThreadLocalMap 里 key(弱引用)已被回收、value 还活着的僵尸条目,是 ThreadLocal 泄漏指纹;OQL: SELECT ... WHERE e.ref == null 数数量即实锤 |
| Metaspace 泄漏怎么查? | 不是堆泄漏,heap dump 查不到:jstat -class 看已加载类数量趋势(持续涨 = 有类不停生成);JDK 9+ jcmd VM.classloader_stats 看各加载器类数;根因多是动态代理/脚本引擎不释放旧加载器(见《OOM 排查实战》) |
| Caffeine 为什么比 HashMap 缓存安全? | 三点:有界(maximumSize 封顶内存)、有淘汰(W-TinyLFU 主动驱逐,比 LRU 更抗偏斜访问)、有统计(recordStats 命中率可监控,提前发现刷穿)。HashMap 只进不出,必然 OOM |
内存泄漏排查是一条流水线:趋势(FGC 后基线抬高)→ 取证(低峰 dump / OOM 自动兜底)→ 定位(MAT 三把刀:Leak Suspects 看占比、Histogram 看 retained、Path to GC Roots 读引用链)→ 根因(ThreadLocal / 连接 / 缓存三大场景,dump 特征反向对应代码)→ 修复验证(基线恢复锯齿形)。把"谁还拽着它"当成排查的唯一问题,工具只是回答这个问题的不同姿势——下一篇我们换战场,用《CPU 100% 排查五步法》处理另一种最常见的线上告警。
Comments · 评论