知易通
第二套高阶模板 · 更大气的阅读体验

堆内存不断增长?一个真实Java服务的排查记录

发布时间:2025-12-11 12:54:44 阅读:324 次

上周上线后,监控系统突然报警,某核心Java服务内存持续攀升,老年代几乎没怎么回收,GC日志里Full GC越来越频繁。重启能缓解,但几个小时后又回到原点——典型的堆内存不断增长问题。

现象观察

服务是基于Spring Boot的REST接口,处理用户行为日志的聚合任务。内存监控图显示,堆从启动时的800MB一路涨到4.2GB,而-Xmx设置的是4.5GB。每次YGC时间变长,应用响应明显卡顿。

导出堆转储文件(heap dump)用MAT工具分析,发现一个叫 UserSessionCache 的单例对象持有超过300万条 SessionEntry 实例,累计占用接近2.6GB。这明显不正常——按设计,活跃会话最多不应超过5万。

代码里的“隐形漏斗”

翻看代码,问题出在一段看似合理的缓存逻辑:

public class UserSessionCache {
    private static final Map<String, SessionEntry> cache 
        = new ConcurrentHashMap<>();

    public void addSession(String userId, SessionEntry entry) {
        cache.put(userId, entry);
    }
}

看起来没问题?但继续往下看调用处:

// 在每次请求中,不管是否已有会话,都新建并放入缓存
SessionEntry entry = new SessionEntry(userId, currentTime);
userSessionCache.addSession(userId, entry);

关键问题来了:没有清理机制,也没有过期策略。用户每发起一次请求,就往Map里塞一个新对象。虽然用了ConcurrentHashMap,线程安全,但没人负责删除旧的。时间一长,缓存就成了内存黑洞。

临时应对与长期修复

先在线上加了JVM参数 -XX:+HeapDumpOnOutOfMemoryError,防患于未然。然后紧急发布一个小版本,把 ConcurrentHashMap 换成 Guava Cache,加上写入后10分钟自动失效:

private static final LoadingCache<String, SessionEntry> cache 
    = CacheBuilder.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(100000)
        .build(new CacheLoader<String, SessionEntry>() {
            public SessionEntry load(String key) {
                return new SessionEntry(key, System.currentTimeMillis());
            }
        });

上线后观察,堆内存稳定在1.1GB左右,GC频率恢复正常,老年代也能被有效回收。再跑了一天,确认问题解决。

别让缓存变成负担

类似的问题在实际开发中太常见了。比如有人用静态List存订单快照,想着“查得快”,结果忘了清理;还有人把图片Base64字符串全扔进内存缓存,以为Redis贵就省着用。这些做法短期见效,长期埋雷。

堆内存不断增长,很多时候不是JVM有问题,而是程序逻辑忽略了生命周期管理。缓存不是垃圾桶,不能只进不出。尤其是涉及用户维度的数据,更要考虑并发、过期和上限。

下次看到内存涨不停,不妨先问问自己:有没有什么东西,一直在往内存里塞,却从来没想过要拿出来?