Heap Dump 抓到了,第一轮分析最该先排除哪几类对象?
Heap Dump 到手后,第一轮最容易被 `byte[]`、`String` 和各种大集合带偏。真正该先做的不是立刻定罪,而是结合抓取时机、MAT 的持有链和线程现场,把一批假嫌疑尽快排掉。
Heap Dump 到手时,最容易产生一种错觉:证据终于齐了,接下来只要找“最大对象”就行。
我第一次在值班里意识到这条路有多容易浪费时间,是在价格计算服务那次内存事故里。
当时我们已经拿到一份 3GB 多的 Dump,MAT 一打开,前排全是熟面孔:
byte[]StringHashMap$NodeArrayList- 一堆业务 DTO
群里马上有人说:“看吧,字符串炸了。”
但最后真正的根因,根本不是“字符串太多”,而是一个按活动维度缓存快照、又没有及时淘汰的静态持有链。
也就是说,第一轮分析最值钱的事,不是立刻定罪,而是先把一批最容易误导人的大户排掉。
这篇就拿那份 Dump 讲清楚:为什么我要先锁定 Dump 的抓取时机,再用 MAT 排掉几类常见假嫌疑,最后才沿真正异常的持有链往上追。
第一轮不是判案,而是先把搜索面砍小
那次服务出问题前,大家已经知道它在涨堆,但还没进 OOM。真正决定抓 Dump 的,是 Full GC 后基线还是回不去。
这里有个细节特别重要:Dump 不是在最乱的时候抓的,而是在一次 Full GC 刚结束后抓的。
这个时间点直接决定了后面哪些对象该优先排掉。
因为如果 Dump 抓在:
- 导出任务中途
- 大批量接口正在执行时
- Full GC 之前
那你看到的大量对象里,会混进很多“此刻还活着,但本来就快结束了”的临时结果。
那次不是。我们有两个现场锚点:
- 抓 Dump 前刚做过一次 Full GC
- 导出和批量回填任务都已经停掉
这意味着:
- 还留在堆里的对象,更值得怀疑长期持有
- “只是快照时间点凑巧大”的误导项,可以先压低
所以第一轮分析真正先问的,不是“谁最大”,而是:
这份 Dump 里的大对象,到底是还没来得及死,还是本来就不该活到现在。
我先做的第一轮排除:别把表现层对象当元凶
MAT 里最容易让人上头的,就是前排那一串基础对象:
byte[]char[]StringObject[]
它们当然占内存,但很多时候只是表现层,不是持有根。
那次如果顺着“字符串太多”往下冲,最多只能知道内存最终长成了字符串和字节数组,不会知道是谁让这些东西一直活着。
所以我第一轮做的不是逐个看这些对象,而是直接切去 Dominator Tree,问一个更像工程问题的问题:
- 谁在支配这些
byte[] - 哪个容器或业务对象把它们捆成了一整坨
结果很快就发现,大量 String 和 byte[] 最终都挂在两类承载对象下面:
- 缓存快照
PromotionSnapshot - 结果容器
ConcurrentHashMap
这一步的意义很大:基础对象只是肉,真正要找的是骨架。
第二轮排除:先把“正常但很大”的容器和副本踢出去
真正难的不是看到大集合,而是判断这个集合是不是本来就应该在这。
那次我们先排掉了三类看起来很大、但继续深挖价值不高的对象组。
1. 已知有边界的正常缓存
MAT 里先能看到两个本地缓存占了不少空间,但结合代码和运行时配置后,很快就排掉了:
- 一个是 Caffeine 热点缓存,明确有
maximumSize - 一个是 Redis 客户端结果缓存,按连接和请求窗口自然淘汰
为什么这两类先排?
因为它们虽然大,但三件事说得通:
- 有明确边界
- 命中率和业务量对得上
Path to GC Roots走出来的持有关系符合设计预期
第一轮分析里,这种对象不是完全不看,而是不要把主要时间砸进去。
2. 活跃线程正在处理的临时结果
我们把线程栈和 Dump 时间点对了一次。那时活跃线程里没有大导出、报表、批同步等长事务,所以“活跃线程正在生成临时大结果”这条线基本可以先压下去。
这一步很重要,因为只看 Dump 很容易误判;把线程栈叠上去以后,你才能知道这些对象到底挂在常驻根上,还是挂在一个马上就会结束的线程局部变量上。
3. 单纯重复出现的 DTO 副本
Histogram 里有一批 PriceRuleDTO 数量很多,看起来也挺吓人。但沿引用链往上追以后发现,它们大多只是被 PromotionSnapshot 承载的结果对象,不是最终根。
这类对象在第一轮里常常像噪音:数量很多、名字显眼、看起来很像“业务对象泄漏”,但你真正要找的是谁把这些副本收进了一个不该无限长寿的容器里。
真正该留下来的嫌疑,是“持有关系不对”的对象
到这里,第一轮该砍掉的已经砍得差不多了,接下来才轮到真正值得深挖的对象组。
那次留到最后的嫌疑非常集中:
PromotionSnapshot retained heap: 2.4 GB
-> ConcurrentHashMap<Long, PromotionSnapshot>
-> static PromotionRuleEngine.snapshotCache
这条链之所以值得继续追,不是因为它名字业务味重,而是因为它同时满足了三件事:
- Dump 抓在 Full GC 后,它还在
- 它挂在静态单例和 Spring 常驻对象上
- retained heap 足够大,而且键数量还在跟活动数一起增长
继续做 Path to GC Roots,路线就非常干净:
ApplicationContext
-> PromotionRuleEngine
-> snapshotCache
-> PromotionSnapshot
看到这里,第一轮分析已经完成它最重要的任务了:
byte[]和String不是根- 正常有边界的缓存不是根
- 活跃线程临时对象不是根
- 真正该追的,是一个不该无限增长的常驻缓存
为什么第一轮必须把抓取时机也带上
很多 Dump 分析会在这里出问题:工具用得都对,但没把抓取时机带进来。
那次如果 Dump 是在活动批量刷新进行中抓的,同样一批 PromotionSnapshot 可能就没有这么高的嫌疑。因为它可能只是任务中途暂存,还不能直接定性。
但我们那次手里有额外三条证据,让判断能继续收:
- Full GC 后抓的 Dump
- 活跃大任务已经停了
- 第二天同时段再抓一份对比 Dump,
snapshotCache不处理就继续涨
也就是说,Dump 不是孤证。MAT 里看到的大对象,只有和时间点、线程现场、第二份对比快照连起来,工程意义才出来。
那次最后怎么验证,第一轮没有排错方向
我们最后给 snapshotCache 补了两层约束:
- 按活动版本失效旧快照
- 加上容量上限,避免活动数量一多就整批常驻
动作以后,我们没有只看“内存降了没”,而是做了两次更硬的验证:
验证 1:第二份 Dump 里,主嫌疑对象组明显消失
同样在 Full GC 后抓第二份 Dump,PromotionSnapshot 的 retained heap 从 2.4GB 掉到了不到 300MB。
验证 2:Histogram 里那些表层大户还在,但已经不再可疑
byte[]、String 当然还会存在,业务照样要跑。但它们不再被一条异常的静态持有链捆成大块常驻对象了。
这一步很能说明问题:你不是把大对象全部消灭了,而是把不合理的持有关系拆掉了。
所以,Heap Dump 第一轮最该先排掉什么
如果把那次经验压成一句话,我会记成:
先排“只是看起来大”的,再追“为什么还活着”的。
具体到第一轮,我自己最先排的通常是:
- 纯表现层的
byte[]、String、Object[] - 有明确边界、行为正常的缓存和容器
- 挂在活跃线程上的临时结果对象
- 只是被根容器承载的 DTO 副本
真正该留下来的,是那些同时满足下面条件的对象:
- Full GC 后还在
- 路径能走到静态对象、单例、线程复用容器这类常驻根
- retained heap 大,而且会随时间继续增长
这也是为什么 Heap Dump 第一轮分析,不该写成“看到什么对象就怀疑什么对象”的 checklist。
它更像一次删减:先把假嫌疑一批批砍掉,最后把时间留给真正异常的持有链。
如果你现在还没分清这份 Dump 抓在什么时点、抓之前有没有 Full GC、抓的时候线程都在干什么,那比继续盯着对象名本身更值得先补。因为真正会把人带偏的,通常不是 MAT,而是脱离现场去看 MAT。