JVM 内存持续增长与 Full GC 频繁
一、故障场景
某 Spring Boot 服务运行几个小时后,接口响应时间明显变慢。
线上监控现象:
- JVM 堆内存使用量持续增长。
- Full GC 越来越频繁。
- Full GC 后,堆内存仍然很高。
- 接口响应时间从 100ms 上升到 3~5 秒。
- CPU 使用率从 30% 上升到 85%。
- 重启服务后恢复正常,但几个小时后再次出现。
- 业务请求量没有明显增长,最近也没有发布新版本。
二、初步分析
1. Full GC 后内存仍然很高,说明什么?
说明大量对象在 GC 后仍然存活,或者存在较大的存活对象集合,需要进一步分析。
常见原因:
- 内存泄漏:对象已经没有业务价值,但仍被引用,无法回收。
- 缓存无限增长:缓存没有容量限制或清理机制。
- 对象生命周期管理不合理:任务结束后,对象仍然被长期持有。
- 正常业务数据量较大:系统确实需要保留大量对象。
注意:Full GC 后内存高,不等于一定存在内存泄漏。
2. 为什么不能直接扩容?
假设 JVM 堆内存从 4GB 扩大到 8GB,如果根因是对象持续积累,扩容只能延缓故障发生。
正确思路:
- 确认存活对象是否持续增长。
- 找出占用内存最多的对象。
- 分析对象为什么无法被回收。
- 根据业务生命周期修复代码或缓存设计。
- 评估是否需要调整堆内存大小。
三、生产环境排查流程
第一步:分析 GC 日志
重点关注:
- GC 发生频率是否持续增加。
- 每次 Full GC 回收了多少内存。
- Full GC 后的存活堆内存是否持续增长。
- GC 暂停时间是否越来越长。
例如:
| 时间 | Full GC 后堆内存 |
|---|---|
| 10:00 | 1.2GB |
| 11:00 | 2.0GB |
| 12:00 | 2.8GB |
| 13:00 | 3.5GB |
如果业务请求量基本不变,但 GC 后的存活堆内存持续增长,就需要重点调查对象积累问题。
注意:单次数据不足以证明内存泄漏,应结合长期趋势和对象分析。
第二步:生成并分析 Heap Dump
Heap Dump 是 JVM 某一时刻的堆内存快照,可用于分析对象分布、引用关系和内存占用。
重点查看:
- 对象实例数量。
- 对象的 Shallow Heap。
- 对象的 Retained Heap。
- Dominator Tree。
- GC Roots 引用链。
常用工具:Eclipse MAT。
生产环境生成 Heap Dump 可能引起明显停顿,并占用大量磁盘空间,需要提前评估风险。
第三步:定位异常对象
假设 MAT 显示:
OrderContext实例数量特别多。- 占用堆内存的 60%。
- Full GC 后仍有大量实例存活。
此时不能直接认定OrderContext存在内存泄漏。
需要继续检查:
- 这些对象是否符合正常业务规模?
- 对象数量是否随运行时间持续增长?
- 哪些对象或容器一直持有它们?
- 对象对应的业务任务是否已经结束?
- 对象是否有合理的清理机制?
第四步:分析 GC Roots 引用链
例如:
GC Root ↓ OrderService.contextCache(静态 Map) ↓ Map.Entry ↓ OrderContext ↓ 订单相关对象这说明OrderContext被静态 Map 持有,因此 GC 无法回收这些对象。
静态 Map 本身不一定有问题。关键是检查:
- 缓存是否有容量限制?
- 订单处理结束后,是否移除了对应对象?
- 异常路径是否遗漏清理?
- 是否存在并发情况下重复写入或清理失败?
- 缓存中的对象是否已经失去业务价值?
判断内存泄漏的关键证据:对象持续增长、已经不再需要却长期存活,并且引用链能解释对象为什么无法回收。
四、常见排查工具
| 工具 | 用途 |
|---|---|
| GC 日志 | 分析 GC 频率、暂停时间和回收效果 |
jcmd | 获取 JVM 信息、生成 Heap Dump 等 |
| Eclipse MAT | 分析对象数量、Retained Heap、Dominator Tree 和引用链 |
| Arthas | 查看 Java 运行状态、线程及类相关信息 |
五、实战案例:ConcurrentHashMap 导致对象持续增长
1. 问题代码设计
假设服务使用一个静态 Map 保存订单上下文:
privatestaticfinalConcurrentHashMap<Long,OrderContext>contextMap=newConcurrentHashMap<>();订单开始处理时,将上下文放入 Map:
contextMap.put(orderId,context);但订单处理完成后,没有移除对应对象。
随着订单不断增加:
订单 1 → OrderContext 订单 2 → OrderContext 订单 3 → OrderContext ...静态 Map 一直持有这些对象,导致它们无法被 GC 回收。
2. 修复思路
如果订单处理完成后不再需要上下文,应及时移除:
try{processOrder(orderId);}finally{contextMap.remove(orderId);}这只是简化示例。真实业务中,必须根据任务状态、失败重试和并发处理方式设计清理时机。
3. 异常路径处理
不能只在成功路径清理。
例如:
try{processOrder(orderId);}catch(Exceptione){// 记录异常,并根据业务设计处理重试或失败状态throwe;}finally{// 确认上下文不再被需要后,才执行清理}如果失败后仍需要重试,应先明确上下文是否需要保留或持久化,不能直接删除必要的数据。
4. 并发安全
如果多个线程可能同时处理同一个订单,需要防止:
- 一个线程清理上下文,另一个线程仍在使用。
- 已完成的任务被其他线程重新写入 Map。
- 失败重试与清理操作相互竞争。
需要明确任务状态、并发控制和上下文所有权。
六、为什么不能简单地换成 Redis?
把 JVM 内存中的 Map 换成 Redis,只是改变了数据存储位置。
如果数据持续写入且没有过期、淘汰或清理机制,Redis 同样可能出现内存持续增长。
应根据数据性质选择存储方式:
| 数据类型 | 处理思路 |
|---|---|
| 正在处理的订单上下文 | 在有效业务生命周期内保留 |
| 已处理完成的临时上下文 | 不再需要时及时清理 |
| 短期缓存数据 | 根据业务要求设置容量限制和过期策略 |
| 必须长期保存的业务数据 | 持久化到数据库等合适的存储中 |
注意:不能不加区分地为所有对象设置 TTL,否则可能导致仍在处理的业务数据被提前清理。
七、完整故障处理思路
发现 Full GC 频繁、GC 后内存仍然很高 ↓ 分析 GC 日志,观察存活堆内存趋势 ↓ 生成并分析 Heap Dump ↓ 找出占用内存最多的对象类型 ↓ 查看 GC Roots 和对象引用链 ↓ 判断是正常存活对象、缓存增长还是生命周期管理问题 ↓ 修复引用持有、缓存淘汰或对象清理逻辑 ↓ 验证 GC 后内存、对象数量和接口延迟是否恢复八、核心知识总结
- Full GC 后内存仍然很高,需要调查存活对象,但不能直接断定内存泄漏。
- CPU 高可能是频繁 GC 导致的结果,而不一定是最初的根因。
- Heap Dump 可以帮助定位大量对象及其引用关系。
- GC Roots 引用链可以解释对象为什么无法被回收。
- 静态集合、缓存、监听器和任务队列都可能长期持有对象。
- 业务处理结束后,应释放不再需要的对象引用。
- 异常路径、重试机制和并发操作都必须纳入生命周期管理。
- 扩容只能增加容量,不能替代根因修复。
- 更换 Redis 也不能自动解决无限增长问题。
一句话记忆:先看 GC 趋势,再找大对象,沿引用链找持有者,最后根据业务生命周期决定如何清理。