OOM 排查实战(大结局):从老年代爬坡到 VisualVM 引用链实锤(场景 B4)

环境: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------三个免费情报

  1. Classes by Size:byte[] 535,143,208 B(99.9%)------一列定案,堆被 byte 数组吃空
  2. OutOfMemoryError Thread:"main" RUNNABLE------自动 dump 会记录 OOM 触发线程
  3. 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 一样查堆,面试加分项:

    sql 复制代码
    SELECT * 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 实锤。

相关推荐
H_老邪1 小时前
JVM 遇到的问题-1.0
jvm
liangbo76 小时前
JVM规范第 4 章:class 文件格式
java·jvm
夕除6 小时前
redis--008
java·jvm·数据库
liangbo76 小时前
JVM规范第 1章:从一段历史到「JVM 并不认识 Java」
java·jvm
月华路20 小时前
G1 GC 对数组与大对象(Humongous)的处理
java·jvm·算法
月华路1 天前
G1 新生代对象晋升老年代:实现机制与 GC 日志
java·jvm·算法
Shadow(⊙o⊙)1 天前
C++进阶知识5.0
jvm
一木 之林1 天前
五、C++新特性、关键字与编译原理
java·jvm·算法
Mr. zhihao1 天前
线上卡顿排查决策手册:场景 × 命令 × 现象 × 调整方案
java·开发语言·jvm