Java 服务堆内存只涨不降:一次 Heap Dump 的对象定位

举报
shijiu_dev 发表于 2026/11/08 10:58:30 2026/11/08
【摘要】 线上 Java 服务 Old Gen 持续上涨、Full GC 后回收不掉,用 jcmd 转储加 MAT 定位到静态 Map 无淘汰策略导致的对象泄漏,附完整排查步骤与预防措施。

一、现象

某个 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 打开,重点看三处:

  1. Leak Suspects 报告里,retained heap 最大的支配者是谁;
  2. Histogram 视图按 retained size 排序,看是不是某个 Map 或 List 吃掉了绝大部分内存;
  3. 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 触发频率,只是把问题往后推。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。