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