Lesson 30 · JVM 原理与调优
内存泄漏排查:Heap Dump + MAT + VisualVM 实战
面试官:你怎么排查内存泄漏?
"线上服务跑了两周,突然 OOM 了,你怎么定位?" 大部分候选人会说"看日志"或"加内存"。但面试官真正想听到的是——你能不能从发现泄漏、导出堆转储、到 MAT 定位根因,完整走一遍排查流程。
先看一个真实事故:某电商订单服务上线后一切正常,但运维监控发现老年代内存占用每天悄悄上涨约 2%。起初没人注意——Full GC 偶尔触发,也能回收掉一部分。两周后的一个凌晨,老年代使用率突破 95%,Full GC 再也收不回来,服务抛出 java.lang.OutOfMemoryError: Java heap space,全面不可用。
事后复盘发现,罪魁祸首是一个全局的 ThreadLocal 变量——每个请求线程都在往里塞数据,但从未调用 remove()。线程池中的线程不会销毁,数据就一直挂着,两周累积了 3.2GB。
这就是内存泄漏最阴险的地方:它不像 bug 那样立即报错,而是像慢性病一样慢慢侵蚀,等到爆发时已经积重难返。
| 内存泄漏的典型症状 | 表现 |
|---|---|
| 内存缓慢上涨 | 老年代使用量每天增长 1%~5%,Full GC 后无法回落到基线 |
| Full GC 越来越频繁 | 从一周一次到一天一次,间隔越来越短 |
| Full GC 回收量递减 | 每次 Full GC 能回收的内存越来越少 |
| 最终 OOM | java.lang.OutOfMemoryError: Java heap space / GC overhead limit exceeded |
内存泄漏排查是面试中区分"背八股"和"有实战经验"的最佳考点。能说出 jstat、jmap、MAT 完整排查链路的候选人,通常都有真实的线上排障经历。本文就是帮你建立这条完整的排查链路。
发现泄漏:jstat -gcutil 监控堆内存趋势
排查内存泄漏的第一步不是 dump 堆,而是确认泄漏确实存在。盲目 dump 一个 4GB 的堆既耗时又占磁盘,你需要先通过监控数据判断是不是真的在泄漏。
jstat -gcutil 是最轻量的实时监控工具,它以百分比形式展示堆内存各区域的使用率:
# 每 5 秒采样一次,持续观察 jstat -gcutil <pid> 5000 # 输出示例(关键列): # S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.0 45.2 78.3 62.1 89.4 88.7 342 5.67 2 1.23 6.90 0.0 12.5 15.6 63.5 89.4 88.7 344 5.71 2 1.23 6.94 0.0 12.5 67.2 64.9 89.4 88.7 344 5.71 2 1.23 6.94 # O=Old 老年代使用率 M=Metaspace YGC/FGC=Young/Full GC 次数 GCT=GC 总耗时
看这组输出,关键发现:老年代(O 列)从 62.1% 稳步上涨到 64.9%,中间经历了一次 Young GC(Eden 清零、Survivor 变化),但老年代纹丝不动地往上走。这就是泄漏的信号。
如何判断是泄漏还是正常增长?
正常的堆使用是"锯齿形"——Eden 区填满后触发 Young GC 清零,然后重新填充,周而复始。老年代也会缓慢增长,但 Full GC 后能明显回落。内存泄漏的特征是:Full GC 后老年代使用量无法回落到之前的基线,底部不断抬高。
如果残留量呈单调递增趋势 → 大概率内存泄漏
如果残留量在某个水位稳定下来 → 正常,只是堆设小了
Metaspace(M 列)使用率 89.4% 且纹丝不动,这说明什么?
Metaspace 存的是类元数据,正常情况下类加载完成后就稳定了。如果 M 列持续增长,说明有类加载泄漏(比如 CGLIB 动态代理不断生成新类)。本例中 M 列稳定,问题不在 Metaspace,而在老年代。
发现内存泄漏的第一步是用 jstat -gcutil 实时监控堆使用率。重点观察老年代(O 列)——如果每次 Full GC 后的残留量持续上涨、无法回落基线,就基本确认存在内存泄漏。接下来要做的就是导出堆转储文件,用 MAT 分析具体是哪些对象在泄漏。
导出 Heap Dump:jmap 与自动转储配置
确认泄漏后,下一步是获取堆内存快照(Heap Dump)——它记录了 dump 时刻堆中所有对象的实例、引用关系和占用大小。这是 MAT 分析的输入文件。
方式一:手动导出(jmap)
# 基本命令:导出二进制格式的堆转储 jmap -dump:format=b,file=/data/dumps/heap_$(date +%Y%m%d_%H%M%S).hprof <pid> # 如果 jmap 无响应(JVM 处于 GC 中),使用 F 参数强制 dump jmap -F -dump:format=b,file=heap.hprof <pid> # JDK 9+ 推荐用 jcmd 替代 jmap jcmd <pid> GC.heap_dump /data/dumps/heap.hprof
jmap dump 会触发一次 Full GC(STW),堆越大耗时越长。4GB 堆大约暂停 1~3 秒,16GB 堆可能暂停 10 秒以上。生产环境慎用,最好在流量低谷或摘流后操作。
方式二:OOM 自动转储(推荐)
比手动 dump 更靠谱的方式是让 JVM 在 OOM 时自动保存堆快照,这样即使凌晨三点 OOM 你也能拿到现场:
java \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dumps/heap_%p.hprof \ -jar app.jar # %p 会替换为进程 PID,避免多次 OOM 覆盖同一文件 # OOM 发生时 JVM 自动执行:dump 堆 → 写入指定路径 → 然后退出
Dump 文件大小预估
| 堆大小(-Xmx) | Dump 文件大小(约) | 写入耗时 | 磁盘建议 |
|---|---|---|---|
| 2GB | 1.5~2GB | 2~5 秒 | 预留 10GB |
| 4GB | 3~4GB | 5~10 秒 | 预留 20GB |
| 8GB | 6~8GB | 10~20 秒 | 预留 40GB |
| 16GB | 12~16GB | 20~40 秒 | 预留 80GB |
Dump 文件太大,传不出服务器怎么办?
两种方案:第一,直接在服务器上用 MAT 的命令行模式(mat/ParseHeapDump.sh)生成分析报告,只把报告文件(几十 MB)传出来。第二,用 jmap -histo:live <pid> 先看对象直方图,不需要 dump 整个堆也能看到 Top 20 大对象。
# 只看存活对象的 Top 20,按字节数排序 jmap -histo:live <pid> | head -25 # 输出示例: # num #instances #bytes class name # 1: 1280000 327680000 [B ← byte[] 327MB # 2: 640000 204800000 com.app.CacheEntry ← 自定义缓存 204MB # 3: 512000 122880000 java.lang.String # 4: 80000 51200000 java.util.HashMap$Node
生产环境我会配 -XX:+HeapDumpOnOutOfMemoryError 加 -XX:HeapDumpPath,OOM 时自动保留堆快照。如果需要主动 dump,用 jmap -dump:format=b 或 jcmd GC.heap_dump。Dump 前要注意——它会触发 Full GC 导致 STW,高峰期慎用。大堆环境可以先用 jmap -histo:live 快速看 Top 对象,缩小排查范围。
MAT 分析:Histogram、Dominator Tree 与 Leak Suspects
拿到 .hprof 文件后,用 Eclipse Memory Analyzer Tool(MAT)打开它。MAT 是内存泄漏分析的瑞士军刀——它能从几 GB 的堆快照中,自动找出"谁持有了最多内存"以及"为什么 GC 回收不掉"。
分析工作流
实战:Leak Suspects 报告怎么看
打开 .hprof 文件后,MAT 会自动弹出 Leak Suspects Report。这份报告用饼图展示了堆内存的分布,并高亮标注"可疑"的大对象。典型输出如下:
Leak Suspect 1: One instance of "com.app.service.OrderService" loaded by "org.springframework.boot.loader.LaunchedURLClassLoader" occupies 1,234,567,890 (38.2%) bytes. Biggest instances: - java.util.concurrent.ConcurrentHashMap$Node[] → 800MB - com.app.cache.LocalCacheEntry[] → 350MB - java.lang.String[] → 84MB Reference chain: java.lang.Thread "http-nio-8080-exec-1" → java.lang.ThreadLocal$ThreadLocalMap → java.lang.ThreadLocal$ThreadLocalMap$Entry[] → com.app.context.UserContext → java.util.HashMap → com.app.cache.LocalCacheEntry[] → byte[] (800MB retained)
这份报告告诉你三件事:
- 谁占了最多内存——OrderService 的一个实例持有了 38.2% 的堆
- 里面是什么——ConcurrentHashMap 存了 800MB 的缓存条目
- 为什么 GC 不回收——引用链从 ThreadLocal 出发,经过 UserContext 一直到 byte[],说明是 ThreadLocal 泄漏
一个 HashMap 的 Shallow Heap 只有 48B,但 Retained Heap 可能高达 500MB
MAT 分析的核心就是找到 Retained Heap 最大的对象,然后追溯它的 GC Root 引用链
MAT 分析的流程是:先打开 Leak Suspects 报告看全局概览,找到 Retained Heap 占比最大的对象;然后用 Dominator Tree 确认支配关系;最后通过"Path To GC Roots"功能追踪引用链——从泄漏对象一路追溯到 GC Root(线程、静态变量、JNI 引用等),就能定位到是哪行代码持有引用没有释放。
三大典型泄漏场景:ThreadLocal、连接池、缓存
线上内存泄漏 90% 以上都逃不出这三种场景。每种都有固定的模式和固定的修复方法。
场景 A:ThreadLocal 泄漏
ThreadLocal 是内存泄漏的"重灾区"。原因是:ThreadLocal 的值存储在 ThreadLocalMap 中,key 是 ThreadLocal 实例的弱引用,value 是强引用。当 ThreadLocal 实例被 GC 后,key 变成 null,但 value 仍然被线程持有。在线程池场景下,线程不销毁,value 就永远不会被回收。
// ===== 泄漏代码 ===== public class UserContext { private static final ThreadLocal<Map<String, Object>> ctx = ThreadLocal.withInitial(HashMap::new); public static void set(String key, Object value) { ctx.get().put(key, value); // 只 put 不 remove } } // Filter 中设值,但忘了清理 public void doFilter(req, resp, chain) { UserContext.set("userId", req.getHeader("X-User-Id")); chain.doFilter(req, resp); // 请求结束,ThreadLocal 中的数据没清除! } // ===== 修复代码 ===== public void doFilter(req, resp, chain) { try { UserContext.set("userId", req.getHeader("X-User-Id")); chain.doFilter(req, resp); } finally { UserContext.clear(); // ← 关键:finally 中必须 remove } }
ThreadLocal 的使用必须遵循 try-finally 模式:在 try 中 set,在 finally 中 remove。这不是可选的最佳实践,而是必须遵守的铁律。
场景 B:数据库连接 / 资源未关闭
Connection、Statement、ResultSet 等 JDBC 资源如果忘记 close,不仅泄漏数据库连接(导致连接池耗尽),还会泄漏堆内存中关联的对象:
// ===== 泄漏代码:异常时 Connection 没关闭 ===== public List<Order> findOrders(Long userId) { Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE uid=?"); ps.setLong(1, userId); ResultSet rs = ps.executeQuery(); // 如果这里抛异常,conn 永远不会 close // ... 处理结果 ... conn.close(); // 这行在异常时不会执行! return orders; } // ===== 修复代码:try-with-resources ===== public List<Order> findOrders(Long userId) { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE uid=?")) { ps.setLong(1, userId); try (ResultSet rs = ps.executeQuery()) { // ... 处理结果 ... } } // ← 自动 close,即使发生异常 return orders; }
场景 C:内存缓存无淘汰策略
很多开发者自己实现本地缓存,用 HashMap 或 ConcurrentHashMap 存数据,但从未设置容量上限和淘汰策略。随着时间推移,缓存只增不减,最终 OOM:
// ===== 泄漏代码:HashMap 缓存无上限 ===== public class ProductCache { private final Map<String, Product> cache = new ConcurrentHashMap<>(); public Product get(String id) { return cache.computeIfAbsent(id, this::loadFromDB); // 只进不出!cache 无限增长 } } // ===== 修复代码:用 Caffeine 设置容量上限 + 过期策略 ===== public class ProductCache { private final Cache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) // 最多 10000 条 .expireAfterWrite(Duration.ofMinutes(30)) // 写入后 30 分钟过期 .recordStats() // 开启命中率统计 .build(); public Product get(String id) { return cache.get(id, this::loadFromDB); } }
| 泄漏场景 | MAT 中的特征 | 修复方法 |
|---|---|---|
| ThreadLocal 泄漏 | ThreadLocal$ThreadLocalMap$Entry[] 占用巨大 | finally 中 remove() |
| 连接 / 资源未关闭 | Connection、Socket 实例堆积 | try-with-resources |
| 缓存无淘汰 | HashMap / ConcurrentHashMap 的 Entry[] 占用巨大 | Caffeine / Guava Cache + 容量上限 |
线上最常见的三种内存泄漏:ThreadLocal 使用后没在 finally 中 remove,JDBC 资源没用 try-with-resources,本地缓存没设容量上限和过期策略。MAT 分析时,ThreadLocal 泄漏看 ThreadLocalMap$Entry[],连接泄漏看 Connection 实例数,缓存泄漏看 HashMap$Node[]。每种都有固定的修复模式——try-finally、try-with-resources、Caffeine 缓存。
VisualVM:实时监控与快速诊断工作流
如果 MAT 是"手术刀",VisualVM 就是"听诊器"——它提供实时的可视化监控,让你在问题发生的第一时间就能感知到,而不必等到 OOM 才开始排查。
VisualVM 三大核心功能
| 功能 | 作用 | 使用场景 |
|---|---|---|
| Monitor(监控面板) | 实时显示堆内存、CPU、线程数曲线图 | 日常巡检,发现内存上涨趋势 |
| Heap Dump(堆转储) | 一键触发堆快照,直接拖入 MAT 分析 | 发现问题后立即保留现场 |
| Threads(线程面板) | 实时展示所有线程的状态和堆栈 | 排查死锁、线程泄漏、阻塞 |
快速诊断工作流
当线上服务出现内存上涨的苗头时,用 VisualVM 可以快速走完一轮诊断:
# 1. 远程连接目标 JVM # 在目标服务器启动 JMX(如果未使用 Spring Boot Actuator) java \ -Dcom.sun.management.jmxremote \ -Dcom.sun.management.jmxremote.port=9010 \ -Dcom.sun.management.jmxremote.authenticate=false \ -Dcom.sun.management.jmxremote.ssl=false \ -jar app.jar # 2. VisualVM 中:File → Add JMX Connection → 输入 host:9010 # 3. Monitor 面板:观察 Heap 曲线 # - 正常:锯齿形,GC 后回落稳定基线 # - 泄漏:底部持续抬高,Full GC 后回落线越来越高 # 4. 确认泄漏后:Monitor → "Heap Dump" 按钮 → 保存 .hprof # 5. 将 .hprof 文件拖入 MAT → 按第 4 站流程分析
线程面板的关键用法
VisualVM 的 Threads 面板不仅能看线程状态,还能做两件非常有价值的事:
- 发现线程泄漏:如果线程数持续上涨不下降(特别是定时任务线程、异步线程),说明线程创建后没有正确终止
- 一键生成 Thread Dump:点击 "Thread Dump" 按钮等价于
jstack,可以连续打三次 Thread Dump 做对比,找出长期阻塞的线程
VisualVM 和 JConsole 有什么区别?该用哪个?
JConsole 是 JDK 自带的基础监控工具,功能比较简陋。VisualVM 是 JConsole 的升级版,集成了监控、Heap Dump、Thread Dump、CPU Profiler 等功能。JDK 9 之后 VisualVM 不再随 JDK 捆绑,需要单独下载。生产环境推荐用 Prometheus + Grafana 做长期监控,VisualVM 更适合临时诊断。
# Prometheus + JMX Exporter 方案 # 1. 下载 jmx_prometheus_javaagent.jar # 2. 编写 jmx-config.yml 定义采集指标 # 3. 启动时挂载 agent java \ -javaagent:jmx_prometheus_javaagent.jar=9404:jmx-config.yml \ -jar app.jar # 4. Prometheus 抓取 :9404/metrics # 5. Grafana 导入 JVM Dashboard(模板 ID: 4701) # → 实时监控堆内存、GC、线程,自动告警
VisualVM 是快速诊断的首选工具——Monitor 面板实时看堆内存曲线判断是否泄漏,一键 Heap Dump 保留现场交给 MAT 分析,Threads 面板排查线程泄漏和死锁。生产环境的长期监控则用 Prometheus + JMX Exporter + Grafana,设置堆使用率和 Full GC 频率的告警阈值,做到问题早发现早处理。
总结:内存泄漏排查 SOP 与预防体系
把前面 6 站的内容浓缩成一份标准操作流程(SOP),遇到内存问题时按顺序执行:
排查 SOP 清单
| 步骤 | 操作 | 工具 / 命令 | 判断标准 |
|---|---|---|---|
| 1. 发现 | 监控堆使用率趋势 | jstat -gcutil / Grafana | 老年代 Full GC 后残留量持续上涨 |
| 2. 确认 | 快速查看 Top 对象 | jmap -histo:live | 某个类的实例数或大小异常增长 |
| 3. 保留现场 | 导出堆转储 | jmap -dump 或 OOM 自动 dump | 生成 .hprof 文件 |
| 4. 分析 | MAT 打开 dump 文件 | Eclipse MAT / MAT CLI | Leak Suspects → Dominator Tree → GC Roots |
| 5. 定位 | 追踪引用链到代码 | MAT "Path To GC Roots" | 找到持有引用的代码位置 |
| 6. 修复 | 释放引用 + 验证 | 代码修复 + 压测验证 | 修复后堆使用趋势恢复锯齿形 |
预防最佳实践
| 实践 | 说明 |
|---|---|
| 生产必开 HeapDumpOnOutOfMemoryError | OOM 自动保留现场,事后分析不慌 |
| GC 日志必须开启 | -Xlog:gc* 开销极低,是排查的第一手数据 |
| ThreadLocal 必须 try-finally + remove | 这是铁律,不是建议 |
| IO 资源必须 try-with-resources | Connection / Stream / Lock 全部用 TWR |
| 本地缓存用 Caffeine 替代 HashMap | 设好 maximumSize + expireAfterWrite |
| Prometheus + Grafana 长期监控 | 堆使用率 > 85% 或 Full GC > 1 次/10 分钟触发告警 |
| 定期 Code Review 关注资源管理 | 搜索代码中的 ThreadLocal.set、getConnection 等关键调用 |
全文核心要点回顾
- 发现泄漏:jstat -gcutil 监控老年代趋势,Full GC 后残留量持续上涨 = 泄漏
- 导出 Dump:jmap -dump:format=b 手动导出;-XX:+HeapDumpOnOutOfMemoryError 自动导出
- MAT 分析:Leak Suspects → Histogram(看 Retained Heap)→ Dominator Tree → GC Roots 引用链
- 三大泄漏场景:ThreadLocal 不 remove、JDBC 资源不 close、缓存无淘汰策略
- VisualVM:实时监控 + 一键 Heap Dump + 线程面板,快速诊断的首选工具
- 预防体系:自动 dump + GC 日志 + Prometheus 监控 + 代码规范三重防线
"内存泄漏排查我分五步走:第一步用 jstat -gcutil 实时看堆使用率,确认老年代 Full GC 后残留量在上涨;第二步用 jmap -histo:live 快速看 Top 对象缩小范围;第三步 jmap -dump 导出堆转储或者依赖 HeapDumpOnOutOfMemoryError 自动 dump;第四步用 MAT 打开 dump 文件,先看 Leak Suspects 报告找嫌疑对象,再用 Dominator Tree 确认支配关系,最后追踪 Path To GC Roots 定位到具体代码;第五步修复后压测验证。线上最常见的三种泄漏是 ThreadLocal 没 remove、JDBC 资源没 try-with-resources、本地缓存没设容量上限。"