Java内存泄漏排查实践
Java 堆内存泄漏指:对象本可被 GC 回收,却因仍被GC Root 强引用住而无法释放,导致堆 Used 持续上升,最终OutOfMemoryError: Java heap space。排查主线是:先确认是不是堆泄漏 → 再定位谁占着内存 → 最后看引用链为何删不掉。G1、JNI 等只在「现象对照」里点到为止,不展开机制教学。
速览
- 典型信号:老年代 Used 只升不降;Full GC / Mixed GC 后回收很少;内存曲线「阶梯上升」。
- 先观测:
jstat -gcutil、GC 日志、监控jvm_memory_used_bytes。- 再取证:
jmap生成hprof→Eclipse MAT看 Leak Suspects / Dominator / GC Roots。- 易漏点:无界 Map/Cache、ThreadLocal 未 remove、监听器未注销、资源未关闭。
- 堆外:Heap 不涨但进程 RSS 涨 → 可能是JNI / DirectByteBuffer等,需另查 NMT,不是本篇主线。
目录
一、概念与辨认
- 1. 什么是堆内存泄漏
- 2. 泄漏 vs 非泄漏
- 3. 堆泄漏 vs 堆外泄漏(含 JNI)
二、排查手段
- 4. 线上观测:指标与命令
- 5. GC 日志与内存曲线
- 6. 生成堆快照 hprof
- 7. 用 MAT 分析 hprof
三、模式与流程
- 8. 常见泄漏模式
- 9. 推荐排查顺序
- 10. 速查卡
1. 什么是堆内存泄漏
| 概念 | 说明 |
|---|---|
| 泄漏对象 | 业务上已不再使用,但仍有引用链连到 GC Root |
| 表现 | 堆Used单调涨或阶梯涨;GC 后降幅越来越小 |
| 终点 | java.lang.OutOfMemoryError: Java heap space |
正常:请求结束 → 临时对象无引用 → Young GC 回收 → Used 回落(锯齿) 泄漏:对象被 static Map / ThreadLocal 等挂住 → 每次 GC 都还在 → Old 越堆越多Retained Heap(MAT 里)= 若该对象消失,连带能释放的堆总量——分析泄漏时优先看这个,而不是对象自身大小(Shallow Heap)。
2. 泄漏 vs 非泄漏
很多「内存一直很高」不是泄漏,先排除再 dump。
2.1 更像泄漏
| 现象 | 含义 |
|---|---|
| Old / 堆 Used只升不降 | GC 收不回来 |
| Full GC 后 Old 仍很高 | 强引用常驻 |
| 运行越久越慢,最终 OOM | 经典时间维泄漏 |
| 重启后正常,跑几天又恶化 | 与业务量/时间相关 |
| 某类业务对象实例数随 QPS线性涨 | 集合未清理 |
2.2 常见「假泄漏」
| 现象 | 可能原因 |
|---|---|
| 启动后内存持续上涨一段时间 | 冷启动预热(类加载、Bean、连接池、JIT);中型 Spring 服务常见5~15 分钟才进入平台期 |
| Used 高但稳定 | 有界缓存、单例、连接池——设计如此 |
| Old GC 多 | 不一定是泄漏:堆偏小、大对象多、晋升快等(见下节一句对照) |
| Committed 大、Used 不涨 | JVM 已向 OS 预留堆,不等于泄漏 |
与 GC 现象的一句对照(不展开 G1 原理):健康应用多为Young GC 频繁、Old/Mixed GC 偶发;若长期Old/Mixed GC 很密且回收后 Used 仍下不来,应按泄漏优先排查,而非理解为「Old 本来就要经常 GC」。
2.3 内存曲线怎么读
锯齿(升→降→升) → 通常正常 阶梯上升(每波更高) → 可疑,建议 dump 对比 近似直线向上 → 高概率泄漏3. 堆泄漏 vs 堆外泄漏(含 JNI)
本篇主线是Java 堆(Heap)。若堆指标正常但进程RSS(常驻内存)一直涨,要怀疑堆外 / Native路径。
| 现象 | 优先方向 |
|---|---|
| Old 涨,GC 回收不掉 | Java 堆泄漏→ 本文流程 |
| Heap Used 稳定,RSS 涨 | JNI / Native、DirectMemory、线程栈等 |
OutOfMemoryError: Direct buffer memory | 堆外 DirectByteBuffer |
OutOfMemoryError: unable to create native thread | 线程 / 非堆 |
JNI 相关泄漏(仅列类型,不展开写法):
- JNI局部/全局引用未释放 → 对应 Java 对象无法回收,堆上可能异常、Old GC 频繁。
ByteBuffer.allocateDirect等堆外内存未释放 → Heap 不大,RSS 很大。- 本地 C/C++
malloc未free→ RSS 涨,MAT 看不出。
堆外粗查:jcmd <pid> VM.native_memory summary(需启动参数-XX:NativeMemoryTracking=summary)。Java 堆泄漏仍以 hprof + MAT 为准。
4. 线上观测:指标与命令
4.1 jstat(最快)
jstat-gcutil<pid>1000| 列 | 关注泄漏时 |
|---|---|
| O(Old) | 是否持续升高 |
| FGC / GFGC | Full GC 是否变密 |
| FGCT | Full GC 总耗时是否飙升 |
O 列:绝对值不重要(Old 60% 未必有问题),是否随时间单调上升、Full GC 后是否回落才是关键。
jstat-gc<pid>1000结合OU(Old Used)与OC(Old Capacity)看老年代是否顶满。
4.2 jcmd 堆概况
jcmd<pid>GC.heap_info关注:
committed = … # JVM 已向 OS 申请的堆 used = … # 对象实际占用关系:Used ≤ Committed ≤ Max(Xmx)。
Committed 大、Used 稳定→ 未必泄漏;Used 持续涨→ 才重点查。
4.3 监控指标(Prometheus / Micrometer)
| 指标 | 用途 |
|---|---|
jvm_memory_used_bytes{area="heap"} | 堆 Used 趋势 |
jvm_memory_committed_bytes{area="heap"} | 是否触顶扩容 |
jvm_gc_pause_seconds | GC 停顿 |
| 进程 RSS | 与 Heap 对照,判断堆外 |
4.4 快速看对象分布
jmap-histo:live<pid>|head-30关注实例数异常多的HashMap$Node、byte[]、String、业务 DTO 等。
仅作线索,定案仍需hprof + MAT。
5. GC 日志与内存曲线
5.1 建议开启(JDK 9+)
-Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heap-oom.hprofOOM 时自动留hprof,避免事后无现场。
5.2 日志里看什么
| 关注点 | 泄漏倾向 |
|---|---|
| Full GC / Mixed GC 后Old 回收比例 | 多次 <10% → 高度可疑 |
| GC 后 Old Used是否一次比一次高 | 阶梯上升 → 可疑 |
| 仅启动阶段 GC 密集,稳定后好转 | 可能预热,对比曲线时间轴 |
不必死记 GC 算法名称;看「GC 之后内存有没有真正掉下来」即可。
6. 生成堆快照 hprof
.hprof= 某一时刻整个 Java 堆的对象快照(含引用关系),是 MAT 分析的输入。
6.1 手动 dump(低峰操作)
jmap -dump:live,format=b,file=heap-$(date+%Y%m%d-%H%M).hprof<pid>live:先触发 Full GC,再 dump仍被引用的对象(查泄漏常用)。- 会STW,生产选低峰;文件大小通常接近当前堆 Committed。
若 dump 会导致过长 STW,可优先等OOM 自动 dump;容器环境需预留足够磁盘(dump 体积 ≈ 当前堆 Committed)。
6.2 OOM 自动 dump(强烈推荐)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heap.hprof6.3 其他方式
| 方式 | 说明 |
|---|---|
| Arthas | heapdump /tmp/heap.hprof |
| VisualVM / JMX | 图形界面触发 |
| 对比两次 dump | 间隔 30min~数小时,看谁一直在涨 |
7. 用 MAT 分析 hprof
工具:Eclipse MAT(Memory Analyzer)。大堆 dump 需调大MemoryAnalyzer.ini的-Xmx(建议 ≥ dump 大小的 1.5 倍)。
打开:File → Open Heap Dump → 选*.hprof→ 可选Leak Suspects Report。
7.1 四步流程
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | Reports →Leak Suspects | 自动给出嫌疑对象与占比 |
| 2 | Dominator Tree,按 Retained Heap 排序 | 找「支配」大块内存的入口 |
| 3 | Histogram,按类过滤业务包名 | 看哪类对象实例数异常 |
| 4 | 右键实例 →Path To GC Roots→ exclude weak/soft/phantom | 看谁强引用着它 |
7.2 看到什么算「破案」
| GC Root 链末端 | 常见原因 |
|---|---|
static字段上的 Map/List | 静态集合只增不减 |
Thread→ThreadLocalMap | ThreadLocal 未remove()(线程池场景高发) |
| Spring 单例 Bean 持有大集合 | 缓存未设上限/过期 |
| 监听器注册未注销 | SDK / UI / 消息回调 |
若 GC Root 链只到
main/ Spring 容器 Bean,且 Retained 与业务预期一致,可能是正常单例或有界缓存,不一定是泄漏(与 2.2 呼应)。
7.3 MAT 不能回答的
- GC 为什么频繁(看 GC 日志)
- CPU 热点(profiler)
- JNI / 堆外泄漏(看 NMT、RSS)
8. 常见泄漏模式
| 模式 | 典型代码/场景 | 排查提示 |
|---|---|---|
| 无界 Map/Cache | static Map、Guava 无maximumSize/expire | Histogram 里 Map 节点暴涨 |
| ThreadLocal + 线程池 | set后未remove | Path to GC Roots 经 ThreadLocalMap |
| 监听器未注销 | 注册回调、Netty/MQ 监听器 | 单例或 static 持有 |
| 资源未关闭 | Stream、Connection 被对象间接引用 | 常伴随连接池/leak 检测 |
| Session / 请求上下文堆积 | 全局 Map 以 sessionId 为 key 不删 | 与在线用户数相关 |
| 误用「缓存」 | 以为有上限实际无过期 | Retained 大但业务称「缓存设计」——需产品确认是否泄漏 |
9. 推荐排查顺序
1. 确认现象 jstat / 监控:Old、Heap Used 是否持续涨?Full GC 后是否回收? 2. 排除假泄漏 是否仍在冷启动窗口?是否本来就有大缓存? 3. 开 GC 日志 + OOM 自动 dump 保留 gc.log;配置 HeapDumpOnOutOfMemoryError 4. jmap -histo:live(线索) 看是否有异常突出的类 5. 低峰 jmap -dump:live → hprof 必要时间隔一段时间 dump 第二次对比 6. MAT:Leak Suspects → Dominator → Histogram → GC Roots 定位类 + 引用链 → 回到代码 7. 若 Heap 不涨、RSS 涨 转堆外 / JNI 路径(NMT),不在本篇展开| 阶段 | 产出 |
|---|---|
| 观测 | 「是不是堆在漏」 |
| hprof | 「谁占着内存」 |
| MAT | 「为什么删不掉」 |
| 修代码 | remove / 限流 / 过期 / 注销 / try-with-resources |
10. 速查卡
┌─────────────────────────────────────────────────────────┐ │ 信号: Old/Heap Used 只升不降;Full GC 后回收 <10% │ │ 观测: jstat -gcutil | jcmd GC.heap_info | GC 日志 │ │ 取证: jmap -dump:live → heap.hprof → Eclipse MAT │ │ MAT: Leak Suspects → Dominator → Path to GC Roots │ │ 高发: static Map | ThreadLocal | 无界 Cache | 监听器 │ ├─────────────────────────────────────────────────────────┤ │ Heap 不涨、RSS 涨 → 查堆外/JNI/DirectBuffer(非本篇主线)│ └─────────────────────────────────────────────────────────┘建议 JVM 参数(生产基线):
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heap-oom.hprof -Xlog:gc*:file=gc.log:time,level,tags落地注意
jmap -dump:live与jmap -histo:live都会触发 Full GC,勿在高峰频繁执行。- 分析以Retained Heap为准;Shallow 大可能是被别的大对象引用。
- 修复后做压测 + 长时间观察Old/Used 曲线,确认阶梯上升消失。
- 堆外、JNI、本地库问题需NMT / 代码审查 / 原生工具,不能单靠 MAT。
本篇不适用:
- Metaspace OOM、CodeCache、栈溢出(
StackOverflowError)——需另查类加载、JIT、线程栈深度。 - 纯堆外泄漏定位——需 NMT /
pmap/ ASan 等,见 第 3 节 分流。
一句话:Java 堆泄漏排查 =Used 持续涨时抓hprof,用MAT找Retained 最大对象和GC Root 引用链;先排除预热与缓存设计,再区分堆内(MAT)与堆外/JNI(RSS + NMT)。