首页 / Java 学习笔记 / 30

JAVA · Vol.III · DAY 30 · JVM 原理与调优

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

高级高频#JVM#调优#实战
第 1 站

开场:内存泄漏到底是什么?

面试官很喜欢用真实事故开场:"线上服务跑了两周,突然 OOM 了,你怎么排查?"大部分候选人会答"看日志"或者"把内存加一点"。前者是背过的,后者是运维思维。面试官真正想听的是——你能不能从确认泄漏存在、导出堆转储、MAT 定位到类、一路追到具体那行代码,再到修复验证,完整走一遍排查链路。这一篇就帮你把这条链路全部走通。

先把"泄漏"这个词说准确

在带 GC 的语言里,一个对象能被回收的前提是"从 GC Roots 不可达"。所以 Java 里的内存泄漏,准确定义是:

内存泄漏 = 对象已无业务用途 + 仍被某条引用链从 GC Roots 拽着(可达)
→ 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

把一次泄漏排查拆成五个阶段,后文每站对应其中一步,读完你会发现整篇文章就是一条流水线:

  1. 趋势:确认泄漏确实存在(第 2 站,工具 jstat / 监控)。
  2. 取证:导出堆转储快照(第 3 站,工具 jmap / jcmd / OOM 自动转储)。
  3. 定位:MAT 四步走,找到嫌疑类与引用链(第 4 站,核心)。
  4. 根因:把引用链对应回代码,确认业务语义(第 5 站,三大典型场景)。
  5. 修复:改代码 + 验证基线恢复锯齿形(第 7 站,预防体系让这类问题不再发生)。

类比医院:看症状(趋势)→ 抽血化验(取证)→ CT 成像(定位)→ 病理确诊(根因)→ 治疗(修复)。跳步的后果是真实的——没看趋势就 dump,可能白停一次服务;拿到 dump 不会 MAT,证据就在眼前也读不出来。

"内存泄漏是 Java 特有的问题吗?"——面试官追问时这样接

不是,但机制不同。没有 GC 的语言(C / C++)里泄漏是忘了 free(),内存永远回不来;Java 有 GC,"忘"的不是释放内存,而是释放引用。所以 Java 排查的问法从"哪块内存没还"变成"谁还指着这个对象"。这个区别决定了你后面选工具:C 语言查泄漏用 AddressSanitizer 一类工具盯"分配未释放",Java 查泄漏用 MAT 这类工具盯"引用链"。

一句话核心

Java 内存泄漏 = 没用的对象被引用链拽着不让 GC 收;排查五步 趋势 → 取证 → 定位 → 根因 → 修复,第一步看曲线区分"泄漏"和"堆太小",后面每步都有固定工具。OOM 不等于泄漏,先看病灶,再开药方。

第 2 站

发现泄漏:先看趋势,再下判断

面试官第一问:"你凭什么判断它在泄漏?直接 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 列停在哪里

jstat 采样 · 示例日志(老年代基线逐级抬高的泄漏信号)
# 每 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 是堆里最彻底的清理,它都清不掉的残留,就是"被引用拽着"的对象。这就是泄漏的强信号。

老年代使用率随时间的两条曲线(纵轴 0~100%,纵轴向下为 0) 100% 75% 50% 25% 0 时间 → 每一条陡峭的竖直跌落 = 一次 Full GC 正常服务:锯齿形,基线稳定在 30% 附近 泄漏服务:Full GC 后基线 35%→50%→62%→72%→82%→90% 逐级抬高 虚线连成的"上升基线" = 泄漏的签名 正常:每次 Full GC 都能回到原基线 泄漏:每次 Full GC 的落脚点都比上次高(楼梯状)
图 1 泄漏判定看"Full GC 后的落脚点":正常服务回基线,泄漏服务基线逐级抬高——这是 jstat / 监控图里最该盯的一条线

判断"泄漏 vs 正常增长"的三问

曲线涨了不等于泄漏——缓存预热、大查询结果集、批量任务都会让老年代短期上涨。对着曲线问三个问题,基本能定性:

  1. Full GC 之后能否回落?能回落到之前的水位 → 大概率正常;回不去 → 继续问第二问。
  2. 回落后的基线是否逐次抬高?在某个水位稳定下来(比如稳定在 55% 不再动)→ 正常,只是堆设小了,见《JVM 调优参数手册》;像楼梯一样每级 +2% → 泄漏。
  3. 增长速率与业务量是否线性相关?正常缓存的增长随业务量增长而收敛(热点被填满后就不涨了);泄漏的增长与流量无关,凌晨低峰照样涨——这是泄漏最"诚实"的特征。

一个帮助记忆的画面:正常内存使用像潮汐——涨潮落潮,海岸线(基线)不动;泄漏像海平面上升——每次落潮都多露出一片新的陆地,潮水永远回不到原来的位置。

生产环境:从"人工看"到"机器盯"

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 或怀疑泄漏时随时导出回放:

JFR 命令 · 启动参数 + 运行时按需录制
# 启动时开启(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 长周期录制留一手回放数据。

第 3 站

导出 Heap Dump:三种方式与风险取舍

趋势确认后进入取证:把堆在某个时刻的完整快照存成文件(.hprof)。Heap Dump 记录 dump 时刻堆里每个对象的实例数据、引用关系和占用大小——它是 MAT 的"检材"。取证的原则是在风险最小的时机拿最完整的证据

方式一:手动导出(jmap)

jmap 手动导出 · 三种 dump 命令对比
# ① 完整导出:不触发 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"(下一站开头会讲)。

"dump 文件几个 G,传不出内网服务器怎么办?"

两个办法。其一:在服务器上跑 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 命令行解析、只把报告传出来。"

第 4 站

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% 时,基本可以直接点名。示例输出:

Leak Suspects 报告 · 示例文本(缓存泄漏场景)
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(保留堆 / 独占堆):如果把这个对象删掉,总共能释放多少内存——它自己 + 所有"只能经过它才能到达"的对象(独占保留)。
Shallow Heap = 对象自己的尺寸("我多大")
Retained Heap = 它 + 只有它拽着才活着的对象("我死了能腾出多少")
查泄漏永远按 Retained Heap 排序找"老板",Shallow 排序找到的多是"叶子"
同一个 HashMap 实例:Shallow Heap vs Retained Heap HashMap (ProductCache.map) Shallow ≈ 48 B 对象头 + 几个字段引用 field map 引用 Retained ≈ 3.2 GB(只有这个 Map 拽着才活着的对象) Node[] 1,048,576 个槽位 Node × N key + value 引用 key String + char[]/byte[] value Product 含 byte[] 大字段 HashMap 死了 → 上面全部可回收 = 3.2 GB 按 Shallow 排序:这个 Map 排不进前 20(它才 48B);按 Retained 排序:它排第 1 —— 这就是"找老板"的方法
图 2 Shallow vs Retained:容器实例自身很小(shallow 48B),但它独占保住的对象链巨大(retained 3.2GB)——MAT 查泄漏按 retained 排序

为什么按 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 · 示例(顶层节点)
# 打开 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 Rootsexclude weak/soft/phantom references。这个勾选项必须勾——不勾的话,经过弱引用的链也会显示出来,而弱引用在 GC 时是允许被清掉的,那种链是假线索(对象其实下轮 GC 就没了,不是泄漏)。

输出是一串从 GC Root 到嫌疑对象的引用链,文字版示例(ThreadLocal 泄漏场景):

Path to GC Roots 输出 · 示例(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)查。三条最常用的:

OQL 查询 · 三条实用语句(MAT → OQL 窗口)
// ① 找大字符串:自身超过 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(增量)最大的类和实例——增长最快的就是增长源,不用猜"谁占得多",只看"谁在涨"。

面试追问:"为什么查泄漏要盯 retained heap,而不是 shallow heap?"

回答模板:shallow heap 是对象自己的大小,retained heap 是"删掉它能释放的总量"。泄漏的大头通常是容器——一个 HashMap 自己只有几十字节(shallow 榜上排不进前 20),但它独占保住了几 GB 的 Entry 和 value(retained 榜上排第 1)。按 shallow 排序,你只会看到一堆 byte[]、char[] 这些"叶子",它们大,但决定生死的是持有它们的"老板"。所以 MAT 查泄漏的第一动作永远是:Histogram 按 retained heap 排序,找 retained 最大的实例,再看它归谁支配、被谁引用

完整案例:一次泄漏排查的闭环

把前四站串起来走一遍(场景:电商商品服务,老年代 96% 不回落):

  1. 趋势(第 2 站):监控告警"Full GC 后老年代残留连续 3 次递增",jstat 确认 O 列 91%→93%→96%,FGC 间隔从 2 小时缩到 20 分钟。三问定性:回不去、基线抬高、凌晨低峰也在涨 → 泄漏。
  2. 取证(第 3 站):凌晨流量低谷,jcmd <pid> GC.heap_dump /data/dumps/heap.hprof,4.5G 文件;同时确认 HeapDumpOnOutOfMemoryError 已配(兜底)。
  3. 定位(本站):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。
  4. 根因(第 5 站):全局搜索 INSTANCE,定位到 ProductCache 第 23 行:private static final Map<String, Product> CACHE = new ConcurrentHashMap<>();——只 put 不淘汰、无容量上限、无过期。缓存泄漏实锤。
  5. 修复(第 7 站):换成 Caffeine(maximumSize + expireAfterWrite),压测一周,老年代基线恢复锯齿形、Full GC 消失。

MAT 四步速查表

步骤看什么点什么输出长什么样
Leak SuspectsOverview 的 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 对比看增量。"

第 5 站

三大典型泄漏场景逐一拆解

定位到"谁在持有"之后,最后一问是"业务上为什么持有"。线上内存泄漏绝大多数逃不出三个场景,每个都有固定的现象、固定的 dump 特征、固定的修法。把这三个场景的"dump 长相"记熟,看到 dump 就能反推代码问题。

场景一:ThreadLocal 未 remove(线程池重灾区)

先看机制。每个 Thread 对象内部有一个 ThreadLocalMap,Map 的 Entry 结构是:key 是 ThreadLocal 实例的弱引用(WeakReference),value 是业务对象的强引用。当 ThreadLocal 变量超出作用域、实例被回收后,key 变成 null(这种条目叫 stale entry,僵尸条目),但 value 是强引用,照样活着——于是 value 被线程"永久"持有。

ThreadLocal 泄漏链(线程池场景) Thread http-nio-8080-exec-1 线程池长命线程,不销毁 field threadLocals ThreadLocalMap field table → Entry[] key 弱引用 / value 强引用 Entry[17] Entry ref == null(stale) key 已被 GC 回收 key(弱引用)已断 ThreadLocal 实例 已回收(灰色 = 死) value 强引用 UserContext 强引用,活着 内含 HashMap / byte[] 线程不销毁 + 没有"下一次 set/get" → 僵尸 Entry 永不清理 → value 逐请求累积 两个请求上下文叠在同一个线程上,数据还会互相"串号"
图 3 ThreadLocal 泄漏链:key 是弱引用(可回收),value 是强引用(收不掉);线程池长命线程让僵尸 Entry 永无"清理时机"

三个关键点,每个都是面试追问点:

  • 为什么 key 设计成弱引用?——这是 JDK 的"止损设计":key 用弱引用,至少保证 ThreadLocal 实例自己不会被 Map 拽着泄漏;但它救不了 value。所以弱引用 key 只解决了一半问题,另一半(remove)必须业务代码自己负责。
  • 为什么 ThreadLocalMap 不自己清掉 stale entry?——它其实会:每次 set / get / remove 时顺带清理相邻的 stale entry(探测式清理)。但线程池场景下,如果业务之后再也不碰这个 ThreadLocal,清理就永远不会发生;而且探测式清理只清邻近槽位,不遍历全表——线程不销毁时,value 就是永久滞留。
  • 为什么线程池放大问题?——非线程池场景线程跑完就销毁,ThreadLocalMap 整体随线程回收,问题被"掩埋";线程池线程长期存活,泄漏变成显性的、累积的。
ThreadLocal 泄漏代码 · 问题代码 vs 修复代码
// ===== 泄漏代码: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$Entryref == null(OQL 第二条查询直接数数量);顶层 Dominator 是若干 java.lang.Thread

场景二:数据库连接 / 资源未关闭

泄漏链:Connection 被某处持有(异常跳过了 close、或连接被塞进某个集合"借出去还回来")→ 连接池可用数持续下降 → 连接池耗尽(业务表现为 getConnection 超时 / 等待)→ 堆里伴随 Connection / PreparedStatement / 结果集 byte[] 堆积。

连接泄漏代码 · 问题代码 vs try-with-resources
// ===== 泄漏代码: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 能看到实例条目数持续增长(增长源明确)。

修复首选 CaffeinemaximumSize(容量上限)+ 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$Entryref == null;顶层 Dominator 是 Threadfinally 中 remove();跨线程用 TransmittableThreadLocal
连接 / 资源未关闭连接池耗尽告警;getConnection 超时Connection / Statement / 结果集 byte[] 堆积try-with-resources;HikariCP leakDetectionThreshold
缓存无淘汰老年代上涨与请求量弱相关;缓存命中率先升后降单个 Map 实例 retained 巨大;两次 dump 条目数持续增长Caffeine(maximumSize + expireAfterWrite + recordStats)
类加载器泄漏(附加)M 列持续增长,最终 Metaspace OOMheap 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 就能反推代码。

第 6 站

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 值守。

"VisualVM 和 JDK 自带的 JConsole 有什么区别?"

同源不同代。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,四工具各管一段。

第 7 站

预防体系:让泄漏不发生,而不是难排查

排查是救火,预防是防火。泄漏的根因高度集中在"引用生命周期没人管",所以预防也高度模式化:编码规范管住手,架构手段兜住底,CI 验证兜住最后一条线。

编码规范:五条铁律

  1. ThreadLocal 必须 finally remove——set 与 remove 必须成对出现,写在同一个方法里,不依赖"框架会帮我清"。
  2. Closeable 必须 try-with-resources——Connection / Stream / Lock / Channel,凡是实现了 AutoCloseable 的,手动 close 一律视为坏味道。
  3. 本地缓存必须有界 + 有过期——maximumSize 与 expireAfterWrite 一个都不能少;无界 Map 出现在 static 字段里,Review 直接打回。
  4. 监听器 / 观察者必须成对注册、注销——addListener 的地方必须有对称的 removeListener(对象销毁钩子里)。
  5. 静态集合必须有淘汰机制——任何 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 多轮压测看基线)。排查能力决定你多快止血,预防体系决定你多久才流血。

第 8 站

总结:SOP 流程图 + 知识地图 + 面试模板

排查 SOP 全流程图

内存泄漏排查 SOP(全文主线) ① 趋势 jstat / 监控 ② 确认 三问定性 ③ 取证 jcmd dump ④ 定位(MAT) 四步走 ⑤ 根因 引用链→代码 ⑥ 修复 remove/TWR/有界 ⑦ 验证 锯齿恢复 ④ 定位 · MAT 四步 Leak Suspects 报告先自首(占比>90%) Histogram 按 retained 找老板 Dominator Tree 内存归谁管 Path to GC Roots 谁还拽着它 兜底手段:HeapDumpOnOutOfMemoryError(生产必配)· 两次 dump 对比(看增量)· OQL(精确查询)· JFR(谁在分配) 验证标准:Full GC 后老年代基线恢复稳定锯齿形,且持续观察一个完整业务周期
图 4 排查 SOP 七步:趋势 → 确认 → 取证 → 定位(MAT 四步)→ 根因 → 修复 → 验证,每步有固定工具与判断标准

知识地图

本文全部知识点(能默画出来就算内化):

  • 定义:泄漏 = 无用途对象仍被 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 六问。

面试回答模板

30 秒版(先说结论)

"内存泄漏是对象没用了还被引用链拽着,GC 收不掉。我按五步排查:趋势——jstat -gcutil 看 Full GC 后老年代残留是否逐级抬高;取证——jcmd GC.heap_dump,生产必配 HeapDumpOnOutOfMemoryError 兜底;定位——MAT 三把刀:Leak Suspects 看占比、Histogram 按 retained heap 排序找大户、Path to GC Roots 反查谁持有它;根因——九成是 ThreadLocal 没 remove、资源没 close、缓存无淘汰,dump 特征各不相同;修复后压测验证基线恢复锯齿形。"

3 分钟版(拿 ThreadLocal 案例完整走一遍)

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 · 评论