环境:CentOS 7 / JDK 17(G1)/ 分析工具 VisualVM 2.1.9(macOS)
演示程序:jvm-tuning-demo(HeapOOM 场景:static List 每秒囤 10MB,永不释放)
系列:CPU 100% / 锁竞争 / 频繁 Young GC / 切换风暴 / 下游慢 / 池耗尽 / 死锁
关键词:老年代单边爬坡、jmap -histo、HeapDumpOnOutOfMemoryError、引用链、GC Root
一、事故现场:服务越来越慢,但还没死
内存泄漏最阴险的形态:不是突然死亡,而是慢性病------老年代一点点被蚕食,GC 越来越频繁,接口越来越卡,最后 OOM 暴毙。
本文完整走一遍"黄金窗口期"排查流程:在 OOM 发生之前抓到凶手。
事故代码(经典的 static 集合无上限缓存):
java
public class HeapOOM {
static List<byte[]> list = new ArrayList<>(); // ← 窝藏犯:static,永不释放
public static void main(String[] args) {
while (true) {
list.add(new byte[1024 * 1024]); // 每秒囤 10MB
Thread.sleep(100);
}
}
}
二、启动时埋好"保险丝"(生产必配)
bash
java -Xms1g -Xmx1g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/home/lhadmin/jvm-demo/dump \
-cp target/classes com.jvm.oom.HeapOOM
-XX:+HeapDumpOnOutOfMemoryError是 OOM 发生时的自动现场保护------JVM 在死前自动把堆写成 hprof 文件。它在 OOM 时才触发(FGC 不会触发),是兜底手段,不能替代主动排查。
三、第一发现人:jstat 的"单边爬坡"
窗口期内持续观察:
bash
jstat -gc <PID> 2000 10
EC OC OU YGC YGCT FGC FGCT CGC
55296.0 993280.0 150496.0 0 0.000 0 0.000 0
55296.0 993280.0 273376.0 0 0.000 0 0.000 0
55296.0 993280.0 394208.0 0 0.000 0 0.000 0
76800.0 970752.0 476128.0 3 0.003 0 0.000 4 ← CGC 开始挣扎
泄漏签名(和健康服务的本质区别):
| 健康服务 | 泄漏服务(本案) | |
|---|---|---|
| 老年代走势 | 锯齿波:涨上去 → GC → 掉下来 | 单边爬坡:只涨不跌,GC 后最多降一点又涨更高 |
| GC 行为 | YGC 规律、FGC 罕见 | 并发周期(CGC)越来越频繁,FGC 逼近,最终 OOM |
口诀:锯齿是呼吸,爬坡是肿瘤。 G1 下别只盯 FGC 次数------它会先用并发周期(CGC)硬扛,看 OU 走势才是一手情报。
四、阶梯第一级:jmap -histo 粗判
不 dump(太重),先做轻量统计:
bash
jmap -histo <PID> | head -8
num #instances #bytes class name
1: 8300 257266528 [B ← byte[] 霸榜,257MB
2: 523 561704 [I
3: 7970 561704 java.lang.String
交叉验证:8300 个 byte\[\] 实例 × 1MB ≈ 日志里"已分配 N MB"的 N------对象数量和业务量对得上,不是正常缓存。
生产纪律回顾:
jmap -histo不带:live不触发 FGC,可随时粗判;jmap -dump= STW + 文件≈堆大小,必须摘流量或低峰期。
五、VisualVM 分析全流程(配图)
OOM 后 dump 文件拉到本地,VisualVM 打开(文件 → 装入 → 选 hprof,513MB 解析约 2 分钟)。
第 1 屏:Summary------三个免费情报

- Classes by Size:
byte[]535,143,208 B(99.9%)------一列定案,堆被 byte 数组吃空 - OutOfMemoryError Thread:
"main" RUNNABLE------自动 dump 会记录 OOM 触发线程 - Instances by Size :每个 byte\[\] 都正好 1,048,592 B(1MB)------和代码
new byte[1024*1024]吻合
第 2 屏:类视图------排行榜确认

Count(实例数):byte\[\] 8,561 个Size(总字节):535MB / 99.9%Retained(保留大小):"如果我被回收能连带释放多少即只有通过我才能找到我的小弟有多少?若是我噶了小弟们也就散了"------支配树核心概念,大堆计算慢,本案不需要
第 3 屏:实例视图------引用链第一环

双击 byte[] → 任选实例 → 展开 <references>("谁在引用我"):
其中Object[] 是 ArrayList 的内部存储数组。
第 4 屏:引用链登顶------全案告破

顺着 references 一层层往上爬,完整链条现形:
byte[] ← Object[]#102 (elementData) ← ArrayList 的内部数组
← java.util.ArrayList#3 ← 赃物藏处
← static list in class com.jvm.oom.HeapOOM ← ★ 窝藏犯:static 字段
← [GC root - sticky class] ← GC Root:类本身,永不卸载
为什么 static 字段 = 永不回收? static 字段属于类对象,类被 AppClassLoader 加载后是 sticky class(GC Root),生命周期 = JVM 生命周期。它攥着的引用链上所有对象,GC 一概不敢碰。
六、MAT 分析:自动破案四板斧(面试标准答案)
VisualVM 是手动爬引用链,MAT(Eclipse Memory Analyzer)是把这条路自动化。面试被问"MAT 怎么定位内存泄漏",标准答案就是下面四板斧。
① Leak Suspects(泄漏嫌疑人报告)------MAT 的招牌
打开 dump 自动弹出(或点工具栏饼图图标):

-
饼图 :Problem Suspect 1 独占 510MB / 511MB。正常服务的饼图应该是碎片化的(缓存、连接池、框架各占一小块),"一家独大"本身就是泄漏形态
⚠️ 注意:饼图的 Total ≠ 堆配置大小。三个"堆大小"别混淆:
概念 本案数值 含义 -Xmx(配置上限)1024 MB JVM 堆允许涨到的天花板 committed(已申请) ~1024 MB(Xms=Xmx) JVM 已向 OS 申请的地盘 饼图 Total(dump 内容) 511 MB dump 那一刻堆里实际存活对象总量 本案实际占用 511MB(离 1G 还很远)就抛了 OOM------因为 1MB 数组在 G1 里是 Humongous 对象 (> Region 50%),需要连续空闲 Region ,碎片化后"总空闲够但连续空间不够",加上 G1 默认 10% 保留空间(
G1ReservePercent)。OOM ≠ 堆 100% 用满------这也是"堆还有空间为什么 OOM"面试题的标准答案(大对象+碎片化)。分析 dump 的第一眼就该对比 Total 和 Xmx:Total 逼近 Xmx → 泄漏嫌疑大(结局一);Total 远小于 Xmx → 考虑大对象碎片/堆配小了(结局二)。
-
判词自动点名:
The class "com.jvm.oom.HeapOOM" occupies 534,785,816 (99.81%) bytes. The memory is accumulated in one instance of "java.lang.Object\[\]"...
一句话给出凶手(HeapOOM 类)+ 赃物形态(一个 Object\[\] 实例),与 VisualVM 手动爬链结论完全一致
② 核心概念:Shallow Heap vs Retained Heap
读懂 MAT 一切报表的前提:
| 概念 | 含义 | 本案数据 |
|---|---|---|
| Shallow Heap | 对象自身占多少内存(对象头+字段,不含它引用的对象) | ArrayList 自身仅 24 B |
| Retained Heap | 该对象被回收后能连带释放的内存总量 | ArrayList 的 Retained = 534 MB |
Retained 怎么算的------支配(Dominator)关系 :从 GC Roots 到对象 X 的所有 引用路径都必须经过 A,则 A 支配 X(A 一松手 X 必死),X 计入 A 的 Retained Set。MAT 解析 dump 时先建支配树,再按 Retained Heap 排座次------泄漏者永远坐头把交椅,因为海量内存的生死都捏在它手里。
口诀:Shallow 看自己多胖,Retained 看手下多少人;找泄漏看 Retained。
③ Dominator Tree(支配树)+ Details 页
点工具栏支配树图标,按 Retained Heap 降序,头名即泄漏者:

Details 页四个板块的分工:
Description 判词:谁占了多少
Shortest Paths To the 引用链:GC Root → 堆积点的最短路径
Accumulation Point (Object[] ← elementData ← ArrayList ← static list)
Accumulated Objects in 赃物清单:被支配的所有对象(510 个 1MB byte[])
Dominator Tree 支配树
Accumulated Objects by Class 赃物按类聚合统计
经验值:背几个常见集合的内部字段名,支配树里看到秒认主:
| 支配树里看到的字段 | 真身 |
|---|---|
elementData |
ArrayList |
table / Node[] |
HashMap |
queue |
线程池的阻塞队列 |
buf(char\[\]/byte\[\]) |
StringBuilder |
④ Path to GC Roots / OQL(进阶)
-
Path to GC Roots :右键嫌疑人 → Path To GC Roots → exclude weak/soft references(排除弱软引用,只看硬引用)→ 一键给出完整引用链,替代手动爬链
-
OQL(对象查询语言) :像 SQL 一样查堆,面试加分项:
sqlSELECT * FROM byte[] b WHERE b.@length >= 1048576
VisualVM vs MAT 怎么选
| VisualVM | MAT | |
|---|---|---|
| 类排行/实例/手动引用链 | ✅ | ✅ |
| Leak Suspects 自动报告 | ❌ | ✅ 一键 |
| 支配树 + Retained Heap | 弱 | ✅ 核心能力 |
| OQL 查询 | ❌ | ✅ |
| 适用 | 轻量快看、顺带监控 | 泄漏实锤、面试标准答案 |
七、结案:判词与修复
判词:static 集合无上限缓存导致内存泄漏(手册"结局一")。
| 修复方案 | 说明 |
|---|---|
| 缓存加上限 + 过期淘汰 | Guava Cache / Caffeine:maximumSize + expireAfterWrite,正解 |
| WeakReference / SoftReference | 内存紧张时允许 GC 回收,适合纯缓存场景 |
| ThreadLocal 泄漏变体 | 若是 ThreadLocal 没 remove()(线程池复用线程时),finally 里 remove |
| 监听器/回调没注销 | 注册-注销必须成对出现 |
配套防御 :-XX:+HeapDumpOnOutOfMemoryError(保险丝)+ 老年代水位监控告警(OU > 80% 持续 10 分钟)+ 定期 jmap -histo 巡检。
八、面试 90 秒结案陈词
"jstat 发现老年代单边爬坡、GC 后收不掉 → jmap -histo 粗判 byte\[\] 占 99.9% → 低峰期 dump 下来,VisualVM/MAT 沿引用链追到 ArrayList 的 elementData → GC Root 是类的 static 字段 → 实锤 static 集合无上限缓存。修复用带淘汰策略的缓存框架替换裸集合,同时预埋 OOM 自动 dump 和老年代水位告警。"
九、全系列总结:一张表背完 8 个场景
| 场景 | 第一现象 | 实锤工具 | 根因 | 药 |
|---|---|---|---|---|
| A1 死循环 | us 高,单线程 ~100% | top -H → %x → jstack | 代码空转 | 修代码 |
| A2 GC 加班 | GC 线程暗忙 | jstat 差值 | 分配速率 > 回收 | 减对象→调堆→换 GC |
| A3 切换风暴 | sy > us | vmstat 看 cs/r | 线程超编 | 砍线程数 |
| B1 锁竞争 | CPU 低,BLOCKED 一片 | jstack 状态统计+锁地址 | 锁串行化 | 缩临界区/无锁化 |
| B2 下游慢 | RUNNABLE 卡 socketRead | jstack 看栈顶 | 下游响应慢 | 超时+熔断 |
| B3 池耗尽 | parking 等同地址 | jstack 看 AQS | 连接只借不还/慢 SQL | 查泄漏→慢 SQL→扩池 |
| B4 OOM 泄漏 | 老年代单边爬坡 | jstat → histo → dump → VisualVM | static 集合无上限 | 缓存淘汰策略 |
| B5 死锁 | 功能卡死 | jstack 拉到最后 | 循环等待 | 统一持锁顺序 |
总口诀:CPU 高分 us/sy,us 高 jstack、sy 高 vmstat;CPU 低看状态,BLOCKED 找锁、socketRead 找下游、parking 找池子;老年代爬坡别犹豫,histo 粗判、dump 实锤。