上周上线后,监控系统突然报警,某核心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有问题,而是程序逻辑忽略了生命周期管理。缓存不是垃圾桶,不能只进不出。尤其是涉及用户维度的数据,更要考虑并发、过期和上限。
下次看到内存涨不停,不妨先问问自己:有没有什么东西,一直在往内存里塞,却从来没想过要拿出来?