第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 实战:从趋势到定位

假设你通过趋势分析,确认了老年代在持续增长。下一步就是抓堆转储,然后分析。

我个人习惯的流程是这样的:

  1. 确认泄漏:通过GC日志或监控,确认老年代使用量持续上升,且Full GC无法回收。
  2. 抓取dump:在堆使用量较高但还没OOM时,用jcmd抓一次dump。如果已经OOM了,就用自动生成的dump。
  3. 分析dump:用MAT或JProfiler打开。先看 Histogram(直方图),按对象大小排序。重点关注那些占用内存大、实例数多的类。
  4. 找GC Root:选中可疑对象,查看它的引用链。泄漏的对象通常被某个长生命周期的对象(如ThreadLocal、静态集合、缓存)引用着,导致无法回收。

一个小技巧:在MAT里,用 Leak Suspects(泄漏嫌疑)功能。它会自动帮你分析出最可能泄漏的对象。我刚开始用MAT时,就是靠这个功能快速上手的。

举个例子,我曾经排查过一个缓存泄漏问题。堆转储里发现一个HashMap占了几百MB,里面全是用户会话数据。顺着引用链一看,是一个静态的ConcurrentHashMap,只往里put,从不remove。这就是典型的“缓存没有过期策略”导致的泄漏。

修复方案很简单:改用Guava Cache或Caffeine,设置过期时间。或者手动在业务逻辑里清理。

4.5 总结一下

堆内存分析,说白了就是三步:看结构 → 抓快照 → 看趋势

  • 结构:知道年轻代、老年代、元空间各自管什么。
  • 快照:在正确的时间抓dump,别等OOM了才后悔。
  • 趋势:用GC日志和监控工具,画出堆使用曲线。持续上升就是泄漏。

下一章,我会带你深入MAT,手把手分析堆转储文件。到时候咱们看看,那些泄漏的对象到底藏在哪里。