Java 面试笔记III · 07 / 14

Lesson 30 · JVM 原理与调优

内存泄漏排查:Heap Dump + MAT + VisualVM 实战

高级·#JVM·#调优·#实战

第 1 站

面试官:你怎么排查内存泄漏?

"线上服务跑了两周,突然 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 能回收的内存越来越少
最终 OOMjava.lang.OutOfMemoryError: Java heap space / GC overhead limit exceeded
面试官视角

内存泄漏排查是面试中区分"背八股"和"有实战经验"的最佳考点。能说出 jstat、jmap、MAT 完整排查链路的候选人,通常都有真实的线上排障经历。本文就是帮你建立这条完整的排查链路。

第 2 站

发现泄漏:jstat -gcutil 监控堆内存趋势

排查内存泄漏的第一步不是 dump 堆,而是确认泄漏确实存在。盲目 dump 一个 4GB 的堆既耗时又占磁盘,你需要先通过监控数据判断是不是真的在泄漏。

jstat -gcutil 是最轻量的实时监控工具,它以百分比形式展示堆内存各区域的使用率:

jstat 实时监控命令
# 每 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 后老年代使用量无法回落到之前的基线,底部不断抬高。

泄漏判定公式:观察 N 次 Full GC 后的老年代残留量
如果残留量呈单调递增趋势 → 大概率内存泄漏
如果残留量在某个水位稳定下来 → 正常,只是堆设小了

Metaspace(M 列)使用率 89.4% 且纹丝不动,这说明什么?

Metaspace 存的是类元数据,正常情况下类加载完成后就稳定了。如果 M 列持续增长,说明有类加载泄漏(比如 CGLIB 动态代理不断生成新类)。本例中 M 列稳定,问题不在 Metaspace,而在老年代。

面试话术

发现内存泄漏的第一步是用 jstat -gcutil 实时监控堆使用率。重点观察老年代(O 列)——如果每次 Full GC 后的残留量持续上涨、无法回落基线,就基本确认存在内存泄漏。接下来要做的就是导出堆转储文件,用 MAT 分析具体是哪些对象在泄漏。

第 3 站

导出 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 文件大小(约)写入耗时磁盘建议
2GB1.5~2GB2~5 秒预留 10GB
4GB3~4GB5~10 秒预留 20GB
8GB6~8GB10~20 秒预留 40GB
16GB12~16GB20~40 秒预留 80GB

Dump 文件太大,传不出服务器怎么办?

两种方案:第一,直接在服务器上用 MAT 的命令行模式(mat/ParseHeapDump.sh)生成分析报告,只把报告文件(几十 MB)传出来。第二,用 jmap -histo:live <pid> 先看对象直方图,不需要 dump 整个堆也能看到 Top 20 大对象。

快速查看对象直方图(不需要 dump 整个堆)
# 只看存活对象的 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 对象,缩小排查范围。

第 4 站

MAT 分析:Histogram、Dominator Tree 与 Leak Suspects

拿到 .hprof 文件后,用 Eclipse Memory Analyzer Tool(MAT)打开它。MAT 是内存泄漏分析的瑞士军刀——它能从几 GB 的堆快照中,自动找出"谁持有了最多内存"以及"为什么 GC 回收不掉"。

分析工作流

MAT 内存泄漏分析工作流 1. Leak Suspects 自动生成泄漏嫌疑报告 快速定位可疑对象 2. Histogram 按类统计对象数量与大小 找出最大的几个类 3. Dominator Tree 按"支配关系"排列对象 找到内存占用最大的根 4. GC Roots 链 追踪引用链到 GC Root 定位泄漏代码位置 Histogram 关键列: Shallow Heap = 对象自身占用的内存 Retained Heap = 对象 + 它引用的所有对象 重点看 Retained Heap! 一个 HashMap 自身只有 48B(Shallow), 但 Retained 可能是 500MB(它引用的所有 Entry) Dominator Tree 关键概念: 如果删除对象 A 后对象 B 也无法存活, 则 A "支配" B。排在树顶的对象 支配了最多的内存。 Top 1 对象通常就是泄漏元凶! 右键 → List Objects → with outgoing references
图 1 MAT 分析四步走:从泄漏嫌疑报告到 GC Root 引用链,逐步锁定代码位置

实战:Leak Suspects 报告怎么看

打开 .hprof 文件后,MAT 会自动弹出 Leak Suspects Report。这份报告用饼图展示了堆内存的分布,并高亮标注"可疑"的大对象。典型输出如下:

Leak Suspects 报告摘要
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$ThreadLocalMapjava.lang.ThreadLocal$ThreadLocalMap$Entry[]
        → com.app.context.UserContextjava.util.HashMapcom.app.cache.LocalCacheEntry[]
              → byte[] (800MB retained)

这份报告告诉你三件事:

  1. 谁占了最多内存——OrderService 的一个实例持有了 38.2% 的堆
  2. 里面是什么——ConcurrentHashMap 存了 800MB 的缓存条目
  3. 为什么 GC 不回收——引用链从 ThreadLocal 出发,经过 UserContext 一直到 byte[],说明是 ThreadLocal 泄漏
Retained Heap 的意义:如果删除这个对象,能释放多少内存
一个 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 引用等),就能定位到是哪行代码持有引用没有释放。

第 5 站

三大典型泄漏场景:ThreadLocal、连接池、缓存

线上内存泄漏 90% 以上都逃不出这三种场景。每种都有固定的模式和固定的修复方法。

场景 A:ThreadLocal 泄漏

ThreadLocal 是内存泄漏的"重灾区"。原因是:ThreadLocal 的值存储在 ThreadLocalMap 中,key 是 ThreadLocal 实例的弱引用,value 是强引用。当 ThreadLocal 实例被 GC 后,key 变成 null,但 value 仍然被线程持有。在线程池场景下,线程不销毁,value 就永远不会被回收。

ThreadLocal 泄漏:问题代码 vs 修复代码
// ===== 泄漏代码 =====
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,不仅泄漏数据库连接(导致连接池耗尽),还会泄漏堆内存中关联的对象:

连接泄漏 vs 修复
// ===== 泄漏代码:异常时 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:内存缓存无淘汰策略

很多开发者自己实现本地缓存,用 HashMapConcurrentHashMap 存数据,但从未设置容量上限和淘汰策略。随着时间推移,缓存只增不减,最终 OOM:

无淘汰缓存 vs Caffeine 缓存
// ===== 泄漏代码: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 缓存。

第 6 站

VisualVM:实时监控与快速诊断工作流

如果 MAT 是"手术刀",VisualVM 就是"听诊器"——它提供实时的可视化监控,让你在问题发生的第一时间就能感知到,而不必等到 OOM 才开始排查。

VisualVM 三大核心功能

功能作用使用场景
Monitor(监控面板)实时显示堆内存、CPU、线程数曲线图日常巡检,发现内存上涨趋势
Heap Dump(堆转储)一键触发堆快照,直接拖入 MAT 分析发现问题后立即保留现场
Threads(线程面板)实时展示所有线程的状态和堆栈排查死锁、线程泄漏、阻塞

快速诊断工作流

当线上服务出现内存上涨的苗头时,用 VisualVM 可以快速走完一轮诊断:

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 更适合临时诊断。

生产环境长期监控方案(替代 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 频率的告警阈值,做到问题早发现早处理。

第 7 站

总结:内存泄漏排查 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 CLILeak Suspects → Dominator Tree → GC Roots
5. 定位追踪引用链到代码MAT "Path To GC Roots"找到持有引用的代码位置
6. 修复释放引用 + 验证代码修复 + 压测验证修复后堆使用趋势恢复锯齿形

预防最佳实践

实践说明
生产必开 HeapDumpOnOutOfMemoryErrorOOM 自动保留现场,事后分析不慌
GC 日志必须开启-Xlog:gc* 开销极低,是排查的第一手数据
ThreadLocal 必须 try-finally + remove这是铁律,不是建议
IO 资源必须 try-with-resourcesConnection / 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、本地缓存没设容量上限。"