跳到主要内容
约 9 分钟3 张卡

堆转储

堆转储保存某一时刻的 Java 堆对象和引用关系,用来查找谁占用内存、谁还在持有对象。本文先区分泄漏与正常容量需求,再介绍采集成本和分析视图,最后说明怎样验证修复。单份转储记录的是一个时刻,判断增长速度还需要多时点监控和业务数据。

1. 面试核心​

先记住这几条,再读正文
  • 泄漏判断。 稳定负载下,多次 GC 后的存活占用持续增长是泄漏线索,对象是否无用还要按业务生命周期判断。卡 · 泄漏、容量不足与分配峰值
  • 类直方图。 GC.class_histogram 按类统计实例数量和对象自身占用,适合定位数量异常或持续增长的类型。
  • 转储范围。 JDK 21 的 GC.heap_dump 默认请求 Full GC,添加 -all 则包含不可达对象并省去该请求。卡 · 存活对象与全量对象
  • 浅大小与保留大小。 浅大小只计算对象自身,保留大小还包含因它失去可达性而可一并回收的对象。卡 · Histogram 与 Dominator Tree
  • 持有关系。 Dominator Tree 展示对象的支配关系,通向 GC Roots 的路径解释对象为什么仍被持有。

2. 泄漏、容量不足与分配峰值​

堆占用上升只说明空间正在被使用,处理前要先判断这些对象是否仍然有用。常见情况分为三类:

  • 泄漏:业务已经无用的对象仍被强引用持有,存活占用随时间增长。
  • 容量不足:对象都仍有用途,但稳定运行必需的工作集超过可用堆。
  • 分配峰值:短时间创建大量对象,GC 或堆容量调整来不及提供空间。

普通 used heap,也就是当前堆占用,会随着分配自然上升。因此,要对齐稳定负载,比较多次 GC 后的存活占用,而不是只看回收前的曲线。

同时检查缓存容量、过期时间和队列长度。有限缓存逐步填满后稳定,属于预期工作集增长。已经过期的条目持续滞留,则说明对象的生命周期存在问题。

3. 类直方图​

类直方图按类型汇总对象数量和自身占用,适合先确定哪些类型在增长。可以使用以下命令采集:

jcmd <pid> GC.class_histogram > histogram.txt

直方图中的浅大小只计算对象自身,具体含义会在后文解释。它能帮助发现 byte[]、Map 节点或业务 DTO 的异常数量。DTO 指传递业务数据的对象。类名 [B 表示 byte[],但数组属于缓存还是请求内容,还需沿引用链判断。

直方图需要遍历堆,即使输出文件小,采集也会影响业务。JDK 21 默认的存活对象统计还会请求 GC。采集前用 jcmd <pid> help GC.class_histogram 确认选项,并选择业务能够接受的窗口。

4. 堆转储的成本​

直方图只能定位类型,完整堆转储才能保存具体对象和持有关系。下面的命令把快照写成 HPROF 文件:

jcmd <pid> GC.heap_dump /safe/path/heap.hprof

转储需要遍历堆、暂停应用并写入大量数据,堆越大,采集成本越值得评估。文件大小取决于实际对象与格式,磁盘空间要按堆规模提前预留。执行前确认以下五项准备:

  • 目标路径所在的持久存储有足够空闲空间。
  • 当前窗口允许采集带来的业务暂停。
  • 可摘流的实例先摘流,也就是暂时停止接收新请求,再执行采集。
  • HPROF 中包含对象字段的实际内容,例如密码、令牌或个人数据,需要限制访问。
  • 文件的下载、存储、传输、留存和销毁方式已经确定。

若启动时配置 -XX:+HeapDumpOnOutOfMemoryError,也应提前验证输出目录和磁盘容量。这样才能在 OOM 发生时留下可分析的快照。

4.1 存活对象与全量对象​

分析两份转储前,需要确认它们是否都只包含存活对象。JDK 21 的默认命令会请求 Full GC,再转储存活对象。添加 -all 后,转储还会包含不可达对象,并省去命令主动请求的 Full GC。堆遍历与写文件仍有停顿成本。

jcmd <pid> GC.heap_dump -all /safe/path/heap-all.hprof

-all 中的不可达对象只是尚未被回收,已经没有强引用持有路径,所以不属于强引用泄漏。比较两份转储时,应保持采集选项一致,避免把记录范围差异当作对象增长。

5. Histogram 与 Dominator Tree​

找到占用大的类型后,还要知道哪个对象在持续持有它们。MAT 是常用的堆分析工具,可以用以下四种视图逐步定位:

视图能回答的问题
Histogram某种类型有多少实例,对象自身总共占用多少空间?
Dominator Tree哪个对象独自保留了大量对象,释放它能带来多大收益?
Path to GC Roots对象沿哪些路径仍能连接到根,持有者是谁?
OQL哪些具体实例满足给定类型或字段条件?

**浅大小(shallow size)**只计算对象自身,引用指向的其他对象另算。**保留大小(retained size)**还计算因当前对象失去可达性而可一并回收的对象。要理解保留大小,需要先理解支配关系。

如果从 GC Roots 到 B 的每条路径都经过 A,就称 A 支配 B。也就是说,A 一旦不可达,B 也会失去所有来自根的路径。因此,支配树的父子关系表达的是必经关系,未必是直接字段引用。

例如,一个缓存管理器自身只有几百字节,却可以支配数 GB 的值对象或数组。如果另有绕过管理器的强引用路径,共享数组就不受它单独支配。删除缓存引用后,这些数组仍会被另一条路径保留。

定位时先看 retained size,再追踪 Path to GC Roots。常见持有者包括静态字段、线程、ThreadLocal、队列和类加载器。需要细分实例时,再用 OQL 按字段筛选,例如只查已经过期的缓存条目。

6. 常见泄漏来源​

泄漏经常发生在长生命周期对象持有短期数据的地方。沿引用链找到持有者后,可以按以下几类检查释放责任是否完整。

6.1 无界缓存和队列​

缓存缺少容量上限或有效过期机制时,新的业务键会持续积累。队列的消费速度长期低于生产速度时,也会保留越来越多的请求。两者都要检查数量上限,以及达到上限后的处理方式。

6.2 监听器和回调​

发布者通过监听器或回调持有订阅者。如果发布者长期存活,而订阅结束后未注销回调,原本短期使用的订阅者就会继续存活。因此,注册操作需要对应的注销责任。

6.3 ThreadLocal 与线程池​

线程池中的平台线程长期存活,任务结束并不会同时销毁线程。遗留在 ThreadLocalMap 中的值因此会继续被线程持有。任务应在 finally 中调用 remove(),让异常退出也能执行清理。

6.4 类加载器泄漏​

热部署结束后,旧加载器仍被线程或外部注册表持有,就会阻碍整组类和静态状态卸载。例如,需要检查 JDBC Driver 注册、MBean 注册和父加载器的静态集合。

具体卸载条件见 类加载器。

6.5 未完成任务​

Future、响应聚合器和重试队列会保留请求上下文。如果下游卡住,任务又没有超时或取消逻辑,这些上下文就会长期存活。因此,要同时检查任务完成条件和失败退出时的清理。

7. 多个时间点的对比​

一份转储能解释持有关系,多时点数据才能说明增长。建立趋势时,可以组合以下四类信息:

  • 周期性类直方图,用于比较类型的数量和浅大小,采集频率需考虑遍历堆的成本。
  • JFR Old Object Sample 与堆统计,用于查找长期存活样本及增长较快的类型。
  • GC 日志中的回收后存活占用,用于判断实际保留量是否上升。
  • 缓存条目、队列长度和业务键数量,用于判断增长是否符合预期。

先用这些数据缩小增长时段,再选择代表性时刻采集完整转储。Old Object Sample 只提供采样线索,遗漏的对象仍需结合完整堆分析。比较不同时间点时,要保持负载、采集选项与 GC 阶段一致,避免把负载差异当作泄漏。

8. 修复方式​

找到持有者后,修复需要落实谁负责结束对象的生命周期。可以围绕以下五个问题检查代码:

  • 谁创建对象,谁负责删除引用或关闭资源?
  • 数量、总字节数和存活时间分别允许达到多少?
  • 消费者变慢时,生产者怎样限速、背压或丢弃请求?背压指把消费能力不足反馈给生产方。
  • 缓存键是否有界,过期条目是否确实被删除?
  • 任务取消、超时和应用停止时,清理是否都会执行?

据此补齐缓存容量、队列上限和监听器注销等规则。临时调用 clear() 可以降低占用,但如果生产者仍持续加入对象,增长很快会重新出现。

修复后,用同等负载持续运行足够长时间,确认 GC 后存活占用与对象数量稳定。同时检查缓存命中率、请求结果和取消行为,避免内存下降却破坏业务。

9. 常见问题​

9.1 堆里最大的对象就是泄漏源吗?​

最大的对象可以属于合理工作集,例如有上限的缓存、类数据相关数组或当前大请求。判断是否泄漏,要结合保留大小、增长趋势和业务上限。真正需要修复的持有者,自身反而可以很小。

9.2 byte[] 很多时怎样定位来源?​

9.3 线上可以连续采集多份转储吗?​

10 · 面试题卡片​

10.1 堆使用率持续升高,怎样判断缓存增长还是内存泄漏​

出现公司:阿里、腾讯

考察重点:结合 GC 后存活占用、持有链与业务上限,区分预期缓存增长和无用对象滞留。

相关内容:泄漏、容量不足与分配峰值至修复方式。

参考回答

判断依据是稳定负载下的存活占用趋势,以及被保留对象是否仍在业务有效期内。

可以按以下三步检查:

  1. 比较多次 GC 后的存活占用,同时对齐缓存条目、队列长度和业务键数量。
  2. 用直方图找增长类型,必要时用堆转储中的支配树与 GC Roots 路径查持有者。
  3. 核对缓存上限、命中收益与过期策略。达到明确上限后稳定,属于预期工作集。失效条目仍被静态 Map、ThreadLocal 或未完成任务强引用,则属于泄漏。

修复后用同等长稳负载确认存活占用稳定,并验证业务结果。

10.2 堆转储分析时为什么既要看 shallow size,也要看 retained size​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分对象自身大小与独占保留的对象图,并说明共享引用怎样影响保留大小。

相关内容:Histogram 与 Dominator Tree。

参考回答

浅大小说明对象自身占多少空间,保留大小说明它失去可达性后能连带释放多少空间。

一个缓存管理器自身很小,却能独自保留大量值对象,因此只看浅大小会漏掉关键持有者。

如果某个元素还有绕过管理器的根路径,它就不计入管理器独占的保留大小。分析时先找保留大小突出的对象,再沿 GC Roots 路径确认持有来源。

10.3 GC.heap_dump 添加 -all 后,采集结果和成本有什么变化?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释 -all 怎样改变 Full GC 请求与对象范围,以及为什么仍有采集成本。

相关内容:存活对象与全量对象。

参考回答

JDK 21 的 GC.heap_dump -all 会把不可达对象也写入转储,并省去命令主动请求的 Full GC。

它仍要遍历堆和写文件,所以采集仍会带来停顿与 I/O 成本。

不可达对象只是尚未回收,不能据此判断强引用泄漏。比较两份转储时要保持选项一致,才能区分真实增长与采集范围变化。

11. 参考资料​