第4章:堆内存分析:堆内存结构、堆转储文件生成、堆使用趋势分析
内存泄漏排查,说白了就是跟堆内存打交道。
我做了这么多年性能优化,见过太多人一上来就抓堆转储,结果文件几个G,打开一看一脸懵。为什么?因为你不了解堆的结构,不知道看哪里。
这一章,我就带你把这几个核心问题理清楚。
4.1 堆内存结构:你得知道里面住了谁
JVM的堆,不是一块大平地。它分代管理,不同区域存放不同生命周期的对象。我个人习惯把堆想象成一个三层公寓:
- 年轻代(Young Generation):新对象刚出生都住这儿。里面又分Eden区和两个Survivor区。大部分对象活不过第一轮GC,直接“夭折”。
- 老年代(Old Generation):熬过多次GC的对象,会晋升到这里。内存泄漏的“重灾区”就在这。我遇到过很多案例,老年代持续增长,GC都回收不掉,那就是泄漏了。
- 元空间(Metaspace):存类的元数据。以前叫永久代,现在改到本地内存了。这里泄漏比较少见,但一旦发生,排查起来很头疼。
核心要点:内存泄漏的典型特征,就是老年代对象只增不减。年轻代GC频繁,但老年代纹丝不动,甚至持续上涨。
你想想看,如果老年代的对象数量曲线一直往上走,而GC日志里Full GC又回收不掉,那基本可以断定——有对象被“遗忘”在堆里了。
4.2 堆转储文件生成:什么时候抓?怎么抓?
堆转储(Heap Dump),就是堆内存的快照。它能告诉你每个对象是谁、占多大、被谁引用。
但这里有个坑:抓的时机不对,等于白抓。
我曾经帮一个团队排查线上OOM,他们每次都在服务重启后抓dump,结果堆里干干净净,啥也没抓到。为什么?因为OOM后JVM自己就退出了,你抓的是重启后的新堆。
避坑指南:我曾经在OOM发生后才去抓dump,结果发现堆里全是空对象。正确的做法是:在OOM发生前,或者刚发生但JVM还没退出时,立刻触发dump。
生成堆转储,常用的方式有这几种:
| 方式 | 命令/参数 | 适用场景 |
|---|---|---|
| 自动触发 | -XX:+HeapDumpOnOutOfMemoryError |
生产环境必备,OOM时自动生成 |
| jmap手动 | jmap -dump:live,format=b,file=heap.hprof <pid> |
线上怀疑泄漏时,手动抓取 |
| jcmd | jcmd <pid> GC.heap_dump heap.hprof |
JDK 8+推荐,比jmap更安全 |
| JMX/MBean | 通过VisualVM或JConsole触发 | 本地开发调试 |
我的建议:生产环境一定要加上 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath=/path/to/dumps。这样OOM发生时,JVM会自动把堆内存“拍个照”保存下来。别问我为什么强调这个——我见过太多线上事故后,连个dump都没有的惨案。
另外,jmap -dump:live 这个参数要注意。它只dump存活对象,会触发一次Full GC。如果你的服务不能承受GC停顿,就别用这个。用 jmap -dump:all 或者jcmd,它们不触发GC。
4.3 堆使用趋势分析:别只看一个点,要看一条线
单次堆转储只能告诉你“现在堆里有什么”。但内存泄漏是动态过程,你得看趋势。
我个人习惯用 GC日志 + 监控工具 来画堆使用曲线。
举个例子,你打开GC日志,看到这样的信息:
2024-01-15T10:00:00.123+0800: [GC (Allocation Failure) [PSYoungGen: 51200K->1024K(61440K)] 102400K->51224K(204800K), 0.0123456 secs]
2024-01-15T10:05:00.456+0800: [GC (Allocation Failure) [PSYoungGen: 51200K->1024K(61440K)] 102400K->61424K(204800K), 0.0134567 secs]
2024-01-15T10:10:00.789+0800: [GC (Allocation Failure) [PSYoungGen: 51200K->1024K(61440K)] 102400K->71624K(204800K), 0.0145678 secs]
注意看每次GC后的堆占用:51224K → 61424K → 71624K。每次GC后,堆使用量都在增加。年轻代回收得很干净(1024K),但老年代在持续增长。这就是典型的泄漏信号。
关键判断标准:如果每次GC后,堆使用量(老年代)都回不到前一次的水平,而是稳步上升,那基本可以判定存在内存泄漏。
我常用的趋势分析工具有:
- GCViewer:把GC日志拖进去,直接看堆使用曲线。我最喜欢它的“老年代使用量”曲线,一眼就能看出有没有泄漏。
- jstat:命令行工具,实时查看堆各代使用情况。
jstat -gcutil <pid> 1000 10每秒输出一次,连续10次。 - Prometheus + Grafana:生产环境标配。把JVM指标暴露出来,画成折线图。我习惯把老年代使用率、GC频率、GC耗时放在同一个面板上。
嗯,这里要注意:趋势分析不能只看一两个小时。我遇到过一些泄漏,对象增长非常缓慢,一天才涨几百MB。这种你得看一周甚至一个月的趋势图。
4.4 实战:从趋势到定位
假设你通过趋势分析,确认了老年代在持续增长。下一步就是抓堆转储,然后分析。
我个人习惯的流程是这样的:
- 确认泄漏:通过GC日志或监控,确认老年代使用量持续上升,且Full GC无法回收。
- 抓取dump:在堆使用量较高但还没OOM时,用jcmd抓一次dump。如果已经OOM了,就用自动生成的dump。
- 分析dump:用MAT或JProfiler打开。先看 Histogram(直方图),按对象大小排序。重点关注那些占用内存大、实例数多的类。
- 找GC Root:选中可疑对象,查看它的引用链。泄漏的对象通常被某个长生命周期的对象(如ThreadLocal、静态集合、缓存)引用着,导致无法回收。
一个小技巧:在MAT里,用 Leak Suspects(泄漏嫌疑)功能。它会自动帮你分析出最可能泄漏的对象。我刚开始用MAT时,就是靠这个功能快速上手的。
举个例子,我曾经排查过一个缓存泄漏问题。堆转储里发现一个HashMap占了几百MB,里面全是用户会话数据。顺着引用链一看,是一个静态的ConcurrentHashMap,只往里put,从不remove。这就是典型的“缓存没有过期策略”导致的泄漏。
修复方案很简单:改用Guava Cache或Caffeine,设置过期时间。或者手动在业务逻辑里清理。
4.5 总结一下
堆内存分析,说白了就是三步:看结构 → 抓快照 → 看趋势。
- 结构:知道年轻代、老年代、元空间各自管什么。
- 快照:在正确的时间抓dump,别等OOM了才后悔。
- 趋势:用GC日志和监控工具,画出堆使用曲线。持续上升就是泄漏。
下一章,我会带你深入MAT,手把手分析堆转储文件。到时候咱们看看,那些泄漏的对象到底藏在哪里。