Java 服务堆内存只涨不降:一次 Heap Dump 的对象定位
一、现象
某个 Java 服务上线两周后,Old Gen 占用从 1.5G 缓慢爬到 3.2G,Full GC 之后只能回落到 2.6G 左右。随着业务量增长,这条曲线还在继续往上走。监控里 GC 平均耗时从 40ms 涨到 300ms,接口 P99 从 120ms 涨到 1.8s,但 CPU 曲线却始终不高。
这种"内存涨了、GC 又回收不掉、CPU 还不高"的组合,通常不是单纯的参数配置问题,而是有一批对象被某个长生命周期的结构一直强引用着,逃出了新生代。
这次故障的直接表现是接口大面积超时,但应用进程的 CPU 使用率始终在 30% 以内,扩容也没有效果:多起几个 Pod,每个 Pod 的堆曲线都一样。这两点合在一起,基本就排除了机器不够这个方向,把排查引向了对象生命周期。
二、先确认是不是"合理占用"
别一上来就 dump,先用最便宜的手段排除掉缓存设计导致的合理增长。
用 jstat 连着观察两代使用率和 Full GC 频率:
jstat -gcutil <pid> 1000观察各代使用率;jstat -gc <pid> 1000看每次 Full GC 之后老年代到底回落了多少。
如果 Full GC 后老年代仍然稳在高位,说明对象确实还活着,而不是还没到回收时机。GC 日志也要常开,用 -Xlog:gc*,safepoint 记录下来,后面分析晋升情况全靠它。
三、转储堆快照
容器环境里不要直接用 jmap,很容易把进程搞挂,也未必能拿到文件。更稳妥的是让 JVM 自己转储:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
或者在服务已经明显变慢但还没 OOM 的窗口期,用 jcmd 主动打一份:
jcmd <pid> GC.heap_dump /var/log/app/heapdump.hprof
四、用 MAT 找支配者
拿到 dump 后用 Eclipse MAT 打开,重点看三处:
- Leak Suspects 报告里,retained heap 最大的支配者是谁;
- Histogram 视图按 retained size 排序,看是不是某个 Map 或 List 吃掉了绝大部分内存;
- Dominator Tree 里找出到底是谁在支配着这批对象。
五、本次定位到的原因
dump 打开后答案很直接:代码里有一个静态本地缓存,用了静态 Map 却没有淘汰策略。
private static final Map<String, LocalCache> LOCAL_CACHE = new ConcurrentHashMap<>();
public LocalCache get(String key) {
return LOCAL_CACHE.computeIfAbsent(key, k -> new LocalCache(k));
}
key 里带了用户维度,基数随着 DAU 一起上涨;每个 LocalCache 内部又缓存了一份从下游拉回来的 JSON。单个对象不大,乘上基数之后就是几个 GB。因为被静态 Map 强引用,老年代自然永远回收不掉。
修复分两步。第一步把无界强引用换成有上限、会过期的缓存:
private static final Cache<String, LocalCache> LOCAL_CACHE =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
第二步补上容量监控,把缓存 size 和命中率暴露成指标,别让下次再靠 dump 去猜。
六、修复后如何验证
上线后先观察一周,同样用 jstat -gc 记录 Full GC 前后老年代的变化。预期是老年代在 Full GC 之后能回落到 1G 以内并保持平稳,而不是像之前那样逐日抬高。缓存的 size 指标也要确认稳定在上限附近,而不是长期贴着上限跑。
如果 size 长期顶在 maximumSize,说明淘汰速率和请求速率接近,后续还要考虑把 TTL 调小或者做多级缓存,否则只是把内存泄漏换成了高淘汰率。
七、预防措施
- 静态 Map、静态 List、单例持有的集合,都要能回答两个问题:最大条数是多少、什么时候淘汰。回答不了就是定时炸弹。
- 容器内存 limit 和 JVM 堆上限之间要留出余量,保证容器还有空间把 heap dump 写出去,别让转储本身把 Pod 触发 OOMKill。
- 给"Full GC 后老年代使用率"和"Full GC 频率"加告警,这样能在用户投诉之前发现趋势。
- 内存问题先用数据说话:先看 GC 日志和 dump,再谈调参。直接加堆、或者调高 Full GC 触发频率,只是把问题往后推。
- 点赞
- 收藏
- 关注作者
评论(0)