分类:架构与高并发 · 作者:管理员 · 发布于 2026-09-29 · 阅读 0 · 标签:Spring Boot / 内存泄漏 / JVM调优 / MAT / Arthas

1. 场景:上线两周,内存悄然爬升

一个典型的 Spring Boot 微服务,上线两周后通过 top 观察到 RSS 从 512MB 缓慢涨到 1.8GB。重启后回落,但三天后又回到高位。这是典型的内存泄漏信号——GC 无法回收的对象在不断积累。

本文以一次真实修复过程为例,从现象定位到工具分析再到代码修复,完整走一遍内存泄漏排查流程。

2. 第一反应:确认是不是 GC 配置问题

先排除 JVM 堆参数过小导致的频繁 Full GC 误判。查看启动参数:

ps -ef | grep java
# 输出显示:-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200

堆空间 1GB,G1 收集器,参数基本合理。接下来抓取堆转储做定量分析。

3. 第一轮:jmap 抓堆 + MAT 定位

用 jmap 导出堆转储(切勿在 Full GC 期间执行,会触发 STW):

# 先通过 jps 拿到 PID
jps -l
# PID=12345 app-backend-1.0.0.jar

# 导出堆转储(生产慎用,暂停应用约 10-30 秒)
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345

将 heap.hprof 下载到本地,用 Eclipse MAT(Memory Analyzer Tool)打开。

MAT 关键操作

  • Leak Suspects Report:MAT 自动分析最可能的泄漏点
  • Dominator Tree:按保留堆大小排序,一眼看出哪个对象占了最多空间
  • GC Roots 路径:右键对象 → Merge Shortest Paths to GC Roots → exclude all phantom/weak/soft references,看到底被谁引用着

本次分析结果显示:org.springframework.cache.concurrent.ConcurrentMapCacheManager 内部持有一个不断膨胀的 Map,其中 key 为 List<String> 类型,value 为完整的数据库查询结果。

4. 根因:本地缓存(Caffeine)的 key 无限发散

代码中使用了 Spring Cache + Caffeine 做本地缓存:

@Cacheable(value = "productList", key = "#query.toString()")
public List<ProductVO> queryProducts(ProductQuery query) {
    return productMapper.selectByCondition(query);
}

问题所在:

  • #query.toString() 每次生成的 key 包含了随机分页偏移量(如 ProductQuery(page=1,size=20,category=1,ts=1628000000))
  • 前端每次请求的 page 和 ts 都不同,导致 key 无限增长,Caffeine 虽然设了最大条目数,但旧条目未被及时驱逐
  • 此外,@Cacheable 默认用 ConcurrentHashMap 做后备存储,原本的 Caffeine 缓存策略没完全生效

5. 修复方案

方案 A:限制 key 维度,只缓存关键条件

@Cacheable(value = "productList", key = "#query.cacheKey()")
public List<ProductVO> queryProducts(ProductQuery query) {
    return productMapper.selectByCondition(query);
}

// ProductQuery 中增加 cacheKey 方法
public String cacheKey() {
    // 只取分类、关键词、排序方式,忽略分页和时间戳
    return category + ":" + keyword + ":" + sortBy;
}

这样一来,缓存 key 的数量 = 分类数 × 关键词数 × 排序方式数,可控在千级别。

方案 B:明确 Caffeine 失效策略

@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(500)      // 最多 500 个条目
        .expireAfterWrite(10, TimeUnit.MINUTES)   // 写入后 10 分钟过期
        .recordStats());       // 开启统计,方便监控
    return manager;
}

设置 maximumSize 和 expireAfterWrite 双重兜底,即使 key 发散也不会无限膨胀。

6. 复检:Arthas 在线验证

修复后部署新版本,用 Arthas 观察缓存变化:

# 启动 Arthas
java -jar arthas-boot.jar 12345

# 查看 CacheManager 中的缓存统计
ognl '@cacheManager@getCache("productList").getNativeCache().stats()'

# 输出:hitCount=892, missCount=43, evictionCount=12
# evictionCount=12 说明旧缓存正在被正常驱逐

同时用 dashboard 命令观察堆内存:

# Arthas dashboard
dashboard -i 2000
# 观察老年代占用在连续 30 分钟内稳定在 220MB-280MB,不再增长

确认内存泄漏已修复。

7. 预防措施:建立内存监控体系

不要等到 OOM 才排查。建议在 Spring Boot 项目中集成以下监控:

  • Actuator + Micrometer:暴露 /actuator/metrics/jvm.memory.used,接入 Prometheus + Grafana
  • 缓存命中率告警:Caffeine 的 recordStats() 配合 Micrometer 导出 hitRate、evictionCount,命中率骤降时告警
  • 定时堆转储:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/,自动保留案发现场
  • Arthas 脚本化:写一个定时脚本 memory_check.sh,低于可用内存阈值时触发堆转储

8. 踩坑总结

  1. 缓存 key 发散是 Spring Boot 内存泄漏最高频原因之一,尤其是在滥用 toString() 或含随机参数的 key 时
  2. jmap -dump:live 会让 Full GC 停掉应用,生产环境应配合 jcmd 或 arthas heapdump 减少影响
  3. MAT 的 Leak Suspects 不一定准 —— 它只按保留堆大小排序,需要用 Dominator Tree + GC Roots 手动验证
  4. 本地缓存不是银弹:没有 maximumSize + expireAfterWrite 双兜底的本地缓存就是内存泄漏
  5. 排查时先用 jstat -gcutil PID 5s 观察 GC 频率和耗时趋势,再决定是否抓堆转储