Java

Heap Dump 抓到了,第一轮分析最该先排除哪几类对象?

Heap Dump 到手后,第一轮最容易被 `byte[]`、`String` 和各种大集合带偏。真正该先做的不是立刻定罪,而是结合抓取时机、MAT 的持有链和线程现场,把一批假嫌疑尽快排掉。

  • Java
  • Heap Dump
  • MAT
  • JVM
  • 内存排查
15 分钟阅读

Heap Dump 到手时,最容易产生一种错觉:证据终于齐了,接下来只要找“最大对象”就行。

我第一次在值班里意识到这条路有多容易浪费时间,是在价格计算服务那次内存事故里。

当时我们已经拿到一份 3GB 多的 Dump,MAT 一打开,前排全是熟面孔:

  • byte[]
  • String
  • HashMap$Node
  • ArrayList
  • 一堆业务 DTO

群里马上有人说:“看吧,字符串炸了。”

但最后真正的根因,根本不是“字符串太多”,而是一个按活动维度缓存快照、又没有及时淘汰的静态持有链。

也就是说,第一轮分析最值钱的事,不是立刻定罪,而是先把一批最容易误导人的大户排掉。

这篇就拿那份 Dump 讲清楚:为什么我要先锁定 Dump 的抓取时机,再用 MAT 排掉几类常见假嫌疑,最后才沿真正异常的持有链往上追。

第一轮不是判案,而是先把搜索面砍小

那次服务出问题前,大家已经知道它在涨堆,但还没进 OOM。真正决定抓 Dump 的,是 Full GC 后基线还是回不去。

这里有个细节特别重要:Dump 不是在最乱的时候抓的,而是在一次 Full GC 刚结束后抓的。

这个时间点直接决定了后面哪些对象该优先排掉。

因为如果 Dump 抓在:

  • 导出任务中途
  • 大批量接口正在执行时
  • Full GC 之前

那你看到的大量对象里,会混进很多“此刻还活着,但本来就快结束了”的临时结果。

那次不是。我们有两个现场锚点:

  • 抓 Dump 前刚做过一次 Full GC
  • 导出和批量回填任务都已经停掉

这意味着:

  • 还留在堆里的对象,更值得怀疑长期持有
  • “只是快照时间点凑巧大”的误导项,可以先压低

所以第一轮分析真正先问的,不是“谁最大”,而是:

这份 Dump 里的大对象,到底是还没来得及死,还是本来就不该活到现在。

我先做的第一轮排除:别把表现层对象当元凶

MAT 里最容易让人上头的,就是前排那一串基础对象:

  • byte[]
  • char[]
  • String
  • Object[]

它们当然占内存,但很多时候只是表现层,不是持有根。

那次如果顺着“字符串太多”往下冲,最多只能知道内存最终长成了字符串和字节数组,不会知道是谁让这些东西一直活着

所以我第一轮做的不是逐个看这些对象,而是直接切去 Dominator Tree,问一个更像工程问题的问题:

  • 谁在支配这些 byte[]
  • 哪个容器或业务对象把它们捆成了一整坨

结果很快就发现,大量 Stringbyte[] 最终都挂在两类承载对象下面:

  • 缓存快照 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

这条链之所以值得继续追,不是因为它名字业务味重,而是因为它同时满足了三件事:

  1. Dump 抓在 Full GC 后,它还在
  2. 它挂在静态单例和 Spring 常驻对象上
  3. 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[]StringObject[]
  • 有明确边界、行为正常的缓存和容器
  • 挂在活跃线程上的临时结果对象
  • 只是被根容器承载的 DTO 副本

真正该留下来的,是那些同时满足下面条件的对象:

  • Full GC 后还在
  • 路径能走到静态对象、单例、线程复用容器这类常驻根
  • retained heap 大,而且会随时间继续增长

这也是为什么 Heap Dump 第一轮分析,不该写成“看到什么对象就怀疑什么对象”的 checklist。

它更像一次删减:先把假嫌疑一批批砍掉,最后把时间留给真正异常的持有链。

如果你现在还没分清这份 Dump 抓在什么时点、抓之前有没有 Full GC、抓的时候线程都在干什么,那比继续盯着对象名本身更值得先补。因为真正会把人带偏的,通常不是 MAT,而是脱离现场去看 MAT。