内存曲线像极了漏水的水龙头
订单服务上线两周,Old 区内存从 1.2G 缓慢爬到 3.8G,Full GC 后也只回一点,没真正回落。不是突崩,是慢性失血。这种"缓增长"最容易被忽视,因为单次看都正常,直到某天 Old 区满了频繁 Full GC,接口全抖。我用多次 dump 对比把它揪出来,过程值得记下来。
第一步:连续抓堆
别只抓一次,要不同时间点各抓一份,看哪些对象在涨:
jmap -dump:live,format=b,file=heap1.hprof <pid> # 第 1 天
jmap -dump:live,format=b,file=heap2.hprof <pid> # 第 3 天
jmap -dump:live,format=b,file=heap3.hprof <pid> # 第 7 天
用 MAT 的 Histogram 对比三份,发现 char[] 和 HashMap$Node 数量随天数线性增长,基本锁定"往某个 Map 里不断塞东西又不删"。单次 dump 看不出趋势,对比才有意义——这是查慢性泄漏和查急性 OOM 最大的区别。
第二步:支配树分析
在 MAT 打开 Dominator Tree,按 Retained Heap 排序,找到真正"留住"这些对象的根:
Top Dominators:
com.zixin.third.PriceCacheManager (retained 2.9 GB)
└─ static field `cache` : ConcurrentHashMap (2.8 GB, 1,430,000 entries)
└─ each entry: OrderPriceSnapshot
支配树的价值在于:它告诉你"如果释放这个对象,能连带回收多少内存",比浅堆更能定位真凶。这里 cache 字段是罪魁,它一个静态字段就吃掉了 2.8G,比业务对象本身还大。
第三步:根因是第三方库的静态集合
那个 PriceCacheManager 是我们引入的一个比价 SDK 内部的类,它用 static ConcurrentHashMap 做本地价格缓存,键是订单号,却只写不淘汰:
public class PriceCacheManager {
private static final Map<String, Snapshot> cache = new ConcurrentHashMap<>();
public static void put(String orderId, Snapshot s) {
cache.put(orderId, s); // 永远没有 remove
}
}
每笔订单进来就往里塞一条,永不清理,7 天堆了 143 万条。第三方库我们改不了源码,只能绕。这也提醒我们:引第三方组件要查它的缓存策略,别盲信。
解决方案
- 用 ByteBuddy 在启动期给该静态字段注入一个带 TTL 的包装 Map(WeakReference 加定时清理);
- 或者彻底去掉这个 SDK 的本地缓存,改为每次查远程价格服务,牺牲一点延迟换内存可控;
- 我们最终提了 issue 并临时用 agent 插桩加过期策略,内存回到平稳 1.1G,Full GC 从每天 20 次降到 0。
小结
慢性内存泄漏靠单次 dump 看不出名堂,必须"多次采样加支配树"才能定位到增长根。第三方库的静态集合是最常见的隐蔽泄漏源,引入 SDK 时一定确认它的缓存有没有淘汰机制。监控上要对 Old 区设趋势告警(比如 3 天涨 1G 就报警),别等 Full GC 频繁才反应。