堆转储
堆转储保存某一时刻的 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[] 很多时怎样定位来源?
先按支配者和通向 GC Roots 的路径分组,再确认业务用途。byte[] 可以来自以下几类对象:
- 字符串或类数据。
- 网络缓冲与压缩结果。
- 缓存值或请求上下文。
确定持有者后,再关联分配热点和业务键。这样既能知道数组由谁创建,也能知道为什么仍未释放。
9.3 线上可以连续采集多份转储吗?
技术上可以,但每次都会增加堆遍历、停顿和写盘成本。应先评估堆大小与磁盘余量,用监控、直方图或 JFR 确定增长时段。需要多份转储时,优先在摘流实例或可控副本上采集代表性快照。
10 · 面试题卡片
10.1 堆使用率持续升高,怎样判断缓存增长还是内存泄漏
出现公司:阿里、腾讯
考察重点:结合 GC 后存活占用、持有链与业务上限,区分预期缓存增长和无用对象滞留。
相关内容:泄漏、容量不足与分配峰值至修复方式。
参考回答
判断依据是稳定负载下的存活占用趋势,以及被保留对象是否仍在业务有效期内。
可以按以下三步检查:
- 比较多次 GC 后的存活占用,同时对齐缓存条目、队列长度和业务键数量。
- 用直方图找增长类型,必要时用堆转储中的支配树与 GC Roots 路径查持有者。
- 核对缓存上限、命中收益与过期策略。达到明确上限后稳定,属于预期工作集。失效条目仍被静态 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 成本。
不可达对象只是尚未回收,不能据此判断强引用泄漏。比较两份转储时要保持选项一致,才能区分真实增长与采集范围变化。