滥用本地缓存会导致什么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;
    }
}

问题

  1. 无容量限制:缓存无限增长
  2. 无过期机制:数据永不清理
  3. 强引用持有: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 无限增长 使用专业缓存框架

核心原则:本地缓存必须有容量限制过期机制