一、场景:服务每6小时自动重启
在我们的订单系统中,OrderQueryService 负责查询用户的订单列表。为了提升查询性能,开发团队在服务中引入了一个基于 HashMap 的本地缓存。
@Service
public class OrderQueryService {
@Autowired
private OrderMapper orderMapper;
// 本地缓存:key是用户ID,value是订单列表
private Map<String, List<Order>> orderCache = new HashMap<>();
public List<Order> getUserOrders(Long userId) {
String key = "user:" + userId;
// 先查缓存
if (orderCache.containsKey(key)) {
return orderCache.get(key);
}
// 缓存没有,查数据库
List<Order> orders = orderMapper.selectByUserId(userId);
// 放入缓存
orderCache.put(key, orders);
return orders;
}
}
这段代码的设计特点如下:
- 使用
HashMap 作为本地缓存容器。
- 缓存键(Key)为用户ID,值(Value)为该用户的订单列表。
- 未设置缓存条目的过期时间。
- 未限制缓存的最大容量。
问题现象随之而来:
- 服务运行大约6小时后,堆内存使用率会逐渐攀升至90%以上。
- 垃圾回收(GC)变得异常频繁,且Full GC后内存回收效果微乎其微。
- 最终,服务因内存溢出(OOM)被系统终止,并自动重启。
二、踩坑:盲目调优GC参数
面对服务周期性重启,我的第一反应是 JVM垃圾回收配置不当。
第一次尝试:扩大堆内存
# JVM启动参数
java -Xms2g -Xmx4g -jar order-service.jar
结果:服务重启的间隔从6小时延长到了8小时,但问题依旧。
第二次尝试:更换GC算法
# 尝试G1 GC
java -XX:+UseG1GC -Xms2g -Xmx4g -jar order-service.jar
# 尝试ZGC
java -XX:+UseZGC -Xms2g -Xmx4g -jar order-service.jar
结果:重启周期有所波动,但无法从根本上避免。
第三次尝试:精细调优GC参数
# G1 GC调优
java -XX:+UseG1GC -Xms2g -Xmx4g -XX:MaxGCPauseMillis=100 -jar order-service.jar
结果:GC停顿时间确实缩短了,但内存占用率仍在持续上涨。
此时,使用 jstat 工具查看GC状态,发现了关键线索:
$ jstat -gcutil <pid> 1000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 98.12 85.45 92.34 92.56 89.78 1234 45.678 56 123.456 169.134
数据显示,老年代(O)使用率高达92.34%,并发生了56次Full GC,耗时123秒。这清晰地表明:Full GC无法有效回收内存,存在持续被引用的对象,这是典型的内存泄漏迹象,而非单纯的JVM内存管理参数问题。
三、排查:使用MAT分析内存堆转储
第一步:生成内存堆转储文件
# 查找Java进程ID
jps | grep order-service
12345 order-service.jar
# 生成堆转储文件
jmap -dump:format=b,file=order.hprof 12345
# 若进程响应缓慢,可只转储存活对象
jmap -dump:live,format=b,file=order-live.hprof 12345
第二步:使用Eclipse MAT工具分析
将生成的 order.hprof 文件在MAT中打开,按以下步骤分析:
- 查看 Dominator Tree(支配树):发现一个
java.util.HashMap 实例独占了约80%(2.5GB)的堆内存。
- 查看 Histogram(直方图):
java.util.HashMap$Node 实例数量约5000万。
com.example.entity.Order 实例数量约1亿。
- 查看 GC Roots引用链:该
HashMap 被 OrderQueryService 引用,而 OrderQueryService 本身又被Spring容器持有。因此,这个 HashMap 永远无法被垃圾回收。
MAT分析结果摘要:
Class Name | Objects | Shallow Heap | Retained Heap
---------------------------------------------------------
java.util.HashMap | 1 | 48 | 2.5GB
|- java.util.HashMap$Node[]| 1 | 16MB | 2.5GB
|- com.example.entity.Order| 100,000,000 | 2.4GB | 2.4GB
真相大白:
orderCache 这个 HashMap 中积累了约5000万个键值对(Node)。
- 每个
Node 包含一个 Order 对象,而 Order 对象内部又关联着 List<Item> 等更多对象。
- 总对象数过亿,导致堆内存被耗尽。
四、根因分析:无限增长的HashMap
问题根源在于缓存代码的设计缺陷:
// 本地缓存:没有大小限制,没有过期时间
private Map<String, List<Order>> orderCache = new HashMap<>();
public List<Order> getUserOrders(Long userId) {
String key = "user:" + userId;
if (orderCache.containsKey(key)) {
return orderCache.get(key);
}
List<Order> orders = orderMapper.selectByUserId(userId);
orderCache.put(key, orders); // 致命操作:只PUT,永不REMOVE
return orders;
}
内存增长过程:
- 假设系统有100万用户,每个用户平均有50笔订单。
- 每次查询都会执行
put 操作,而缓存既无过期策略,也无容量上限。
- 服务运行6小时后,可能缓存了数千万条记录。
简化的内存估算:
- 单个
Order 对象(含字段、对象头等)约200字节。
- 5000万对象 * 200字节 ≈ 10GB。
- 实际占用2.5GB,是因为
HashMap 负载因子、用户订单分布不均以及JVM的对象指针压缩等优化所致。
五、解决方案:使用Caffeine实现可控缓存
第一步:引入Caffeine依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.1</version>
</dependency>
第二步:重构缓存实现
使用功能完善的 Caffeine缓存 替代原始的 HashMap。
@Service
public class OrderQueryService {
// 使用Caffeine构建缓存
private Cache<String, List<Order>> orderCache = Caffeine.newBuilder()
.maximumSize(1000) // 关键:限制最大条目数
.expireAfterWrite(10, TimeUnit.MINUTES) // 关键:设置写入后过期时间
.recordStats() // 开启统计功能
.build();
public List<Order> getUserOrders(Long userId) {
String key = "user:" + userId;
// 获取缓存,不存在则返回null
List<Order> orders = orderCache.getIfPresent(key);
if (orders != null) {
return orders;
}
// 缓存未命中,查询数据库
orders = orderMapper.selectByUserId(userId);
// 存入缓存
orderCache.put(key, orders);
return orders;
}
// 获取缓存统计信息,用于监控
public CacheStats getCacheStats() {
return orderCache.stats();
}
}
Caffeine缓存的核心优势:
- 自动淘汰:基于
maximumSize(数量)或expireAfterWrite(时间)自动清理旧条目。
- 线程安全:底层基于高性能的
ConcurrentHashMap。
- 丰富统计:提供命中率、加载时间、淘汰数量等指标。
- 卓越性能:其访问性能在业界领先。
第三步:添加缓存监控
@Component
public class CacheMetricsReporter {
@Autowired
private OrderQueryService orderQueryService;
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void reportCacheMetrics() {
CacheStats stats = orderQueryService.getCacheStats();
// 将指标输出到日志或上报至监控系统
System.out.println("缓存命中率:" + stats.hitRate());
System.out.println("缓存淘汰次数:" + stats.evictionCount());
}
}
六、进阶优化:缓存预热与异步刷新
缓存预热:在服务启动时加载热点数据,避免冷启动时的缓存穿透。
@Service
public class OrderQueryService {
// ... 缓存初始化同上 ...
@PostConstruct
public void preloadCache() {
List<Long> hotUserIds = Arrays.asList(1L, 2L, 3L, 4L, 5L); // 假设的热点用户ID
for (Long userId : hotUserIds) {
List<Order> orders = orderMapper.selectByUserId(userId);
orderCache.put("user:" + userId, orders);
}
}
// 使用get方法,提供缓存加载逻辑
public List<Order> getUserOrders(Long userId) {
return orderCache.get("user:" + userId, key -> {
// 当缓存未命中时,自动调用此逻辑加载数据
return orderMapper.selectByUserId(userId);
});
}
}
缓存异步刷新:在条目过期前主动刷新,保证数据可用性同时避免请求阻塞。
@Service
public class OrderQueryService {
private LoadingCache<String, List<Order>> orderCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(5, TimeUnit.MINUTES) // 写入5分钟后触发刷新
.build(new CacheLoader<String, List<Order>>() {
@Override
public List<Order> load(String key) {
// 同步加载数据
Long userId = Long.parseLong(key.split(":")[1]);
return orderMapper.selectByUserId(userId);
}
@Override
public List<Order> reload(String key, List<Order> oldValue) {
// 异步重新加载数据(默认fork自commonPool)
return load(key);
}
});
// 调用方式不变
public List<Order> getUserOrders(Long userId) {
return orderCache.get("user:" + userId);
}
}
七、建立监控与告警体系
集成Spring Boot Actuator暴露指标
# application.yml
management:
endpoints:
web:
exposure:
include: metrics,health
metrics:
tags:
application: order-service
查看缓存相关指标
# 获取缓存指标
curl http://localhost:8080/actuator/metrics/cache.gets
curl http://localhost:8080/actuator/metrics/cache.misses
配置Prometheus告警规则
# prometheus_alerts.yml
groups:
- name: cache_alerts
rules:
- alert: CacheHitRateLow
expr: avg(rate(cache_gets_total[5m])) / avg(rate(cache_gets_total[5m])) < 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "应用 {{ $labels.app }} 的缓存命中率持续低于50%"
总结:内存泄漏排查与预防纲要
本次排查经历揭示了从现象到根因的完整链路,以下是总结的关键经验:
1. 标准排查流程
- 第一步:确认现象。监控显示内存持续增长、GC频繁且无效。
- 第二步:dump内存。使用
jmap 生成堆转储文件。
- 第三步:MAT分析。遵循“支配树 → 直方图 → 引用链”的路径,定位泄露源头。
2. 常见的内存泄漏场景及对策
| 场景 |
原因 |
解决方案 |
| 缓存无限增长 |
使用 HashMap 等集合未设置限制 |
改用 Caffeine/Guava Cache,设定大小和过期时间 |
| 线程池未关闭 |
创建后未调用 shutdown() |
使用 try-with-resources 或注册 shutdownHook |
| 监听器未移除 |
注册后未反注册 |
使用弱引用或确保生命周期结束时移除 |
| 静态集合滥用 |
静态 Map/List 持续添加元素 |
定期清理或使用有界集合 |
| 连接未关闭 |
数据库、网络连接未释放 |
务必使用 try-with-resources 语句 |
3. 编码规范与监控告警
- 编码规范:对缓存、静态集合、资源连接等,必须显式设定边界(大小、时间)。
- 监控告警:建立针对堆内存使用率、老年代使用率、Full GC频率的告警阈值(如超过80%)。
最终结论:面对内存泄漏,排查可依靠“堆转储分析”利器,而预防则重在编码时秉持“有限资源”意识,为缓存等组件明确设定边界。从 HashMap 到 Caffeine 的升级,不仅是工具的更换,更是这种设计思维的体现。