支配者树是堆快照中反映对象支配关系的逻辑结构:若A支配B,则所有GC Roots到B的路径必经A,释放A即可回收B及其整个支配子树;它能精准定位内存泄漏的“总开关”,如Retained Size异常高的业务类实例或未清理的静态缓存。

什么是支配者树,它和内存泄漏有什么关系
支配者树(Dominator Tree)不是堆快照里“对象数量最多”的那棵树,而是反映“谁真正持有内存不放”的因果链。一个对象 A 支配对象 B,意味着从 GC Roots 到 B 的每条路径都必须经过 A —— 换句话说,只要 A 还活着,B 就不可能被回收。这使得支配者树能直接暴露那些“拦在回收路上”的关键持有者。
实际排查中,你常会看到 java.util.HashMap 或 org.springframework.context.support.DefaultListableBeanFactory 在支配者树顶层占着大量 retained heap,但这不等于它们是问题代码——它们只是容器。真正的罪魁往往是某个业务类的静态集合、未注销的监听器、或线程局部变量里塞进去的超大缓存对象。
Chrome DevTools / Eclipse MAT 中怎么高效看支配者树
别一上来就点开“Retained Heap”排序。先做两件事:
- 在 Chrome 的 Memory 面板中,选中堆快照 → 点击
Dominators标签页(不是Allocation或Objects);Eclipse MAT 中则打开Dominator Tree视图 - 右键点击任意高 retained heap 的节点 → 选择
Go to object(Chrome)或Path to GC Roots(MAT),重点看“最短路径上第一个非系统类” - 过滤掉
java.*、sun.*、org.apache.*等框架/SDK 类,聚焦com.yourcompany.*或cn.xxx.*开头的包名
常见陷阱:把 ThreadLocalMap 当成根因。它确实 retain 很多,但你要顺着它的 value 字段往下钻,找到那个被塞进去的 LargeReportData 实例,再看是谁调用了 threadLocal.set(...) —— 那行代码才是该改的地方。
如何用支配者树反向定位到具体代码行
支配者树本身不带行号,但它能帮你把范围从“整个应用”缩小到“某几个类的某几个字段”。关键操作是交叉验证:
- 记下可疑支配者对象的 class 名和字段名(如
UserService.cache、ReportGenerator.context) - 在 IDE 全局搜索该字段的赋值点:
cache = new ConcurrentHashMap()、context = new HashMap(),尤其注意是否在静态块、单例初始化、或监听器注册时无条件创建 - 检查该字段是否被设计为长期持有(比如缓存没设过期、没做 size 控制),或是否在异步线程中被意外引用(如
CompletableFuture闭包捕获了大对象) - 如果字段类型是自定义类,继续看它的实例中哪个字段引用了大量数据(比如
ReportData.rows是个百万级List<string></string>)
示例:你在支配者树看到 com.example.report.ReportCache 占据 800MB retained heap,点开它发现 cache 字段是 ConcurrentHashMap<string reportdata></string>,而每个 ReportData 的 rawContent 字段是个 10MB 的 byte[]。这时立刻去查 ReportCache.put(...) 调用处 —— 很可能某次导出接口没做分页,直接把全量 DB 结果塞进了缓存。
为什么有时支配者树“看起来没问题”,但内存还是涨
支配者树只反映某一时刻的强引用快照,对以下情况不敏感:
- 弱引用/软引用对象(如
WeakHashMap的 value)不会出现在支配者树中,但若 GC 压力小,它们仍会长期驻留 - Native 内存(如
DirectByteBuffer底层分配的堆外内存)不计入 Java 堆支配关系,但会撑爆 RSS - 频繁创建又快速丢弃的小对象(如循环里 new String())虽不 dominator,但会加剧 GC 频率,间接拖慢应用并放大其他泄漏的影响
所以当支配者树找不到明显异常时,别只盯着它看。配合 jstat -gc 观察 YGC 频率、Old Gen 使用趋势,再用 jmap -histo 看对象实例数增长最快的类 —— 很多时候,问题不在“谁 hold 住了”,而在“谁造得太多又没及时断引用”。










