如何分析堆快照中的“保留大小”快速定位最耗费内存的代码对象

什么是保留大小(Retained Size)?
说到内存分析,很多人第一反应是看对象自己有多大。但这里有个更关键的概念:保留大小。它衡量的不是对象自身占了多少字节,而是回答一个更实际的问题——如果把这个对象从内存里“连根拔起”,能顺带释放出多少空间?
举个例子,一个 HashMap 实例的 retained size 如果特别大,那事情就很有意思了。这意味着,不仅仅是这个 HashMap 对象本身,它里面装的所有键值对、支撑这些键值对的内部数组,甚至这些值对象下游引用的整个对象网络(比如一堆 String 或 ArrayList),全都因为它的存在而“活”着,GC 动不了它们。这才是真正卡住内存脖子的那个“关键先生”。
相比之下,浅层大小(shallow size)只算对象头和字段指针,太表面,容易误判;深层大小(deep size)倒是把引用链上的都算进来了,但它有个问题:会把那些被其他路径强引用的对象也算进去,干扰我们定位真正的“罪魁祸首”。
MAT 中按 Retained Size 排序时必须关掉“Keep unreachable objects”
打开 MAT 分析堆快照,直接按 Retained Heap 排序,你以为排在前面的就是“元凶”?先别急,这里有个常见的坑。
默认情况下,MAT 会勾选“Keep unreachable objects”选项。这个选项会让分析结果里混入大量已经不可达、本该被 GC 回收但还没来得及清理的对象。比如,某个大对象刚刚被程序置为 null,但 GC 还没跑,它就会出现在列表里。这类对象的 retained size 往往是 0 或者极小,但它们会挤占列表顶部的位置,把真正有问题的活对象给“淹没”了。
所以,正确的操作姿势是:先进入 Preferences → Memory Analyzer,找到 Keep unreachable objects 并取消勾选。做完这一步,再重新打开堆快照,点击 Retained Heap 列头进行排序。这时候,排在前 10 名的对象,才值得我们花时间深究。
看“支配树(Dominator Tree)”比看“直方图(Histogram)”更准
排查内存问题,很多人习惯先看直方图。但说实话,直方图主要按类名统计实例数和总 shallow size,对于定位泄漏点,帮助其实很有限。
真正的高手,会直接打开“支配树”。为什么?因为它强制体现了对象间“谁真正 hold 住谁”的唯一支配关系。在支配树里,每个节点的 retained size 就是它自己,加上所有被它唯一支配(即除了通过它,没有其他路径可达)的对象的总和。这直接对应了“删除它能释放多少内存”的核心问题。
操作路径很简单:Open Query Browser → Ja va → Dominator Tree。打开后,重点关注以下几类节点:
- 排在前列的,是非 JDK 的内部业务类,比如
com.example.service.CacheManager。 - 集合类(像
ArrayList、ConcurrentHashMap)的retained size如果远大于其自身的shallow size,说明它肚子里“装”了很多东西。 - 有时候,泄漏不是单个实例造成的,而是多个同类的实例“分头作案”。如果某个类的多个实例分散在不同路径,但它们的
retained size合计超过了堆的 15%,那也值得高度警惕。
点开对象后重点看 “Path to GC Roots” 里的“with all references”
找到 retained size 大的对象只是第一步。接下来要问:它为什么能活着?是谁在“保”它?
这时候,右键点击这个可疑对象,选择 Path to GC Roots → with all references。这条路径会清晰地告诉你,是什么引用链让它依然坚挺地留在堆里。
路径里透露的信息往往是破案的关键:
- 如果出现了
ja va.lang.Thread.localMap,那基本可以断定是 ThreadLocal 使用不当导致的内存泄漏。 - 如果路径指向了一个
static字段(比如MyClass.CACHE),那问题很可能出在静态缓存没有设置合理的淘汰机制上。 - 如果看到了
org.apache.catalina.loader.WebappClassLoader这类类加载器,那几乎可以锁定是 Web 应用热部署或卸载时,ClassLoader 泄漏的经典场景。
当然,也要注意过滤噪音。查看时可以排除 WeakReference 和 SoftReference 这类引用路径——它们本身不阻止 GC,通常不是优先处理的目标。
话说回来,最难的部分往往不是找到那个“大块头”,而是判断它该不该这么大。同一个 ConcurrentHashMap 实例,如果它是业务核心缓存,并且设计了完善的 LRU 和过期策略,那么 retained size 大是合理的,是功能需要。但如果发现它的 key 是不断新创建的 StringBuilder,value 里还挂着一堆未关闭的 InputStream,那它无疑就是内存泄漏的源头。所以,最终一定要结合代码的业务上下文来做判断,不能光盯着数字下结论。
