滥用本地缓存会导致什么JVM问题?如何解决?
面试场景
面试官:”你做过JVM调优吗?能举个例子吗?”
JVM调优问题的高分答案不是背参数,而是讲真实案例:发现了什么问题、怎么定位、怎么解决。
业务场景
某电商系统的商品详情页,为了提升响应速度,开发人员大量使用本地缓存(HashMap)存储商品信息。
问题现象:
- 系统运行一段时间后响应变慢
- GC日志显示Full GC频繁
- 最终OOM崩溃
问题分析
定位步骤
1. 查看GC日志
-Xloggc:/var/log/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
发现:
- Young GC频繁(每秒多次)
- Full GC间隔越来越短
- Full GC后老年代释放空间有限
2. dump堆内存分析
jmap -dump:format=b,file=heap.hprof <pid>
使用MAT(Memory Analyzer Tool)分析:
Dominator Tree显示:
HashMap$Node: 占用2.1GB (85%!)
└── ProductInfo对象: 数百万个
根因分析
// 问题代码
public class ProductCache {
private static Map<Long, ProductInfo> cache = new HashMap<>();
public ProductInfo getProduct(Long id) {
ProductInfo product = cache.get(id);
if (product == null) {
product = productDao.findById(id);
cache.put(id, product); // 只进不出!
}
return product;
}
}
问题:
- 无容量限制:缓存无限增长
- 无过期机制:数据永不清理
- 强引用持有:GC无法回收
解决方案
方案一:使用专业缓存框架
推荐使用 Caffeine(Spring Boot 2.x默认缓存):
@Configuration
public class CacheConfig {
@Bean
public Cache<Long, ProductInfo> productCache() {
return Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存数量
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 5分钟未访问过期
.recordStats() // 开启统计
.build();
}
}
使用:
@Service
public class ProductService {
@Autowired
private Cache<Long, ProductInfo> productCache;
public ProductInfo getProduct(Long id) {
return productCache.get(id, key -> productDao.findById(key));
}
}
方案二:使用弱引用(适合临时缓存)
private Map<Long, WeakReference<ProductInfo>> cache = new ConcurrentHashMap<>();
public ProductInfo getProduct(Long id) {
WeakReference<ProductInfo> ref = cache.get(id);
ProductInfo product = (ref != null) ? ref.get() : null;
if (product == null) {
product = productDao.findById(id);
cache.put(id, new WeakReference<>(product));
}
return product;
}
特点:内存不足时可被GC回收,但命中率可能较低。
方案三:添加定时清理任务
@Scheduled(fixedRate = 60000) // 每分钟执行
public void cleanExpiredCache() {
long now = System.currentTimeMillis();
cache.entrySet().removeIf(entry ->
now - entry.getValue().getCreateTime() > 600000 // 10分钟过期
);
}
Caffeine最佳实践
缓存策略对比
| 策略 | 说明 | 适用场景 |
|---|---|---|
| maximumSize | 基于数量淘汰 | 明确缓存条目数 |
| maximumWeight | 基于权重淘汰 | 条目大小不一 |
| expireAfterWrite | 写入后固定时间过期 | 数据定期刷新 |
| expireAfterAccess | 访问后固定时间过期 | 热点数据保留 |
| refreshAfterWrite | 写入后异步刷新 | 避免缓存击穿 |
完整配置示例
Cache<Long, ProductInfo> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 1分钟后异步刷新
.removalListener((key, value, cause) ->
log.info("缓存移除: key={}, cause={}", key, cause))
.recordStats()
.build(key -> productDao.findById(key)); // 自动加载
监控缓存指标
CacheStats stats = cache.stats();
log.info("命中率: {}", stats.hitRate());
log.info("加载次数: {}", stats.loadCount());
log.info("淘汰次数: {}", stats.evictionCount());
本地缓存 vs 分布式缓存
| 对比项 | 本地缓存 | Redis |
|---|---|---|
| 访问速度 | 纳秒级 | 毫秒级 |
| 数据一致性 | 多实例数据不一致 | 集中存储,一致 |
| 容量限制 | 受JVM堆大小限制 | 独立部署,容量大 |
| 适用场景 | 热点数据、变化少 | 共享数据、大容量 |
多级缓存架构
请求 → 本地缓存(Caffeine)
│ 未命中
↓
分布式缓存(Redis)
│ 未命中
↓
数据库
public ProductInfo getProduct(Long id) {
// L1: 本地缓存
ProductInfo product = localCache.getIfPresent(id);
if (product != null) return product;
// L2: Redis
product = redisTemplate.opsForValue().get("product:" + id);
if (product != null) {
localCache.put(id, product);
return product;
}
// L3: 数据库
product = productDao.findById(id);
redisTemplate.opsForValue().set("product:" + id, product, 1, TimeUnit.HOURS);
localCache.put(id, product);
return product;
}
JVM调优相关参数
堆内存设置
-Xms4g -Xmx4g # 堆大小
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:MetaspaceSize=256m # 元空间
GC监控
-Xloggc:/var/log/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M
面试答题框架
问题现象:运行一段时间后GC频繁,最终OOM
定位过程:
1. GC日志分析:Full GC频繁,老年代释放少
2. Heap Dump分析:发现HashMap占用85%内存
根因分析:自定义HashMap缓存无限增长
解决方案:
1. 使用Caffeine替代HashMap
2. 设置最大容量和过期时间
3. 监控缓存命中率
效果:GC恢复正常,内存稳定
总结
| 问题 | 原因 | 解决方案 |
|---|---|---|
| GC频繁 | 缓存对象太多 | 限制缓存最大容量 |
| Full GC | 老年代占满 | 设置过期时间,定期清理 |
| OOM | 无限增长 | 使用专业缓存框架 |
核心原则:本地缓存必须有容量限制和过期机制。