一、前言:为什么"手写缓存"会翻车?
很多开发者在项目初期为了"简单",直接用 ConcurrentHashMap 实现本地缓存:
java
private static final Map<String, Object> cache = new ConcurrentHashMap<>();
看似没问题,但上线后却遭遇:
- ❌ 内存持续增长,最终 OOM
- ❌ 热点数据无法自动刷新,导致脏读
- ❌ 缓存击穿打垮数据库
真正的 JVM 进程缓存,必须具备:容量控制 + 自动过期 + 淘汰策略!
本文将带你从错误示范 → 正确实现 → 生产级方案,彻底掌握 JVM 进程缓存的实现之道。
二、错误示范:手写缓存的三大致命缺陷
缺陷 1️⃣:无限增长 → OOM
java
// 危险!无上限缓存
Map<Long, User> userCache = new ConcurrentHashMap<>();
userCache.put(userId, user); // 永远不删,内存迟早爆
缺陷 2️⃣:无过期机制 → 脏数据
java
// 用户修改了昵称,但缓存还是旧的
String name = userCache.get(1001L); // 返回 "张三"(实际已改名)
缺陷 3️⃣:无并发控制 → 缓存击穿
java
public User getUser(Long id) {
User user = cache.get(id);
if (user == null) {
user = loadFromDB(id); // 多个线程同时查 DB!
cache.put(id, user);
}
return user;
}
💥 结果:高并发下,数据库被瞬间打垮!
三、正确姿势 1:基于 Guava Cache(过渡方案)
Google 的 Guava 提供了基础缓存能力:
java
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.maximumSize(10_000) // 最大 1 万个条目
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入 10 分钟后过期
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return userMapper.selectById(Long.parseLong(key));
}
});
// 使用:自动回源,线程安全
User user = cache.get("1001");
✅ 优点 :自动加载、线程安全、支持过期
⚠️ 缺点:性能一般,已停止活跃维护
四、正确姿势 2:使用 Caffeine(推荐!)
Caffeine 是目前 Java 本地缓存的黄金标准,由 Guava 原班人马打造,性能更强、功能更全。
4.1 基础实现:手动存取
java
Cache<String, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 开启统计
.build();
// 存
userCache.put("1001", user);
// 取
User user = userCache.getIfPresent("1001");
4.2 高级实现:自动加载(推荐!)
java
LoadingCache<Long, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(userId -> {
log.info("缓存未命中,从 DB 加载用户: {}", userId);
return userMapper.selectById(userId); // 自动回源
});
// 一行代码搞定缓存+DB 查询
User user = userCache.get(1001L);
✅ 优势:
- 线程安全:多线程并发 get 不会重复查 DB
- 高性能:TinyLFU 算法,命中率更高
- 低开销:异步清理,无锁设计
五、生产级实现:Spring Boot 集成 Caffeine
5.1 添加依赖
XML
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
5.2 配置缓存 Bean
java
@Configuration
public class LocalCacheConfig {
@Bean("userCache")
public LoadingCache<Long, User> userCache(UserMapper userMapper) {
return Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(15, TimeUnit.MINUTES)
.recordStats()
.build(userId -> userMapper.selectById(userId));
}
}
5.3 在 Service 中使用
java
@Service
public class UserService {
@Autowired
@Qualifier("userCache")
private LoadingCache<Long, User> userCache;
public User getUser(Long id) {
try {
return userCache.get(id);
} catch (Exception e) {
log.error("加载用户缓存失败", e);
throw new RuntimeException("获取用户失败");
}
}
// 更新时清除缓存
public void updateUser(User user) {
userMapper.update(user);
userCache.invalidate(user.getId()); // 主动失效
}
}
六、关键避坑指南
🔒 坑 1:缓存雪崩(大量 key 同时过期)
-
现象:Redis 或 DB 瞬间被打垮
-
解决方案 :TTL 加随机值
java.expireAfterWrite(10 + new Random().nextInt(5), TimeUnit.MINUTES)
🕳️ 坑 2:缓存穿透(查不存在的数据)
- 现象:恶意请求反复打 DB
- 解决方案 :
- 对 null 结果也缓存(TTL 短,如 1 分钟)
- 使用布隆过滤器预判
🔄 坑 3:数据不一致
- 现象:更新 DB 后,本地缓存仍是旧值
- 解决方案 :
- 短 TTL:本地缓存 TTL << Redis TTL
- 主动失效 :更新时调用
invalidate(key) - 接受短暂不一致(本地缓存天然局限)
💥 坑 4:内存溢出(OOM)
- 预防措施 :
- 必须设置
maximumSize - 监控
cache.estimatedSize() - 避免缓存大对象(如图片、长文本)
- 必须设置
七、性能对比:手写 vs Caffeine
| 方案 | 10 万次 get 耗时 | 内存占用 | 是否防击穿 | 是否自动过期 |
|---|---|---|---|---|
ConcurrentHashMap |
80ms | 持续增长 | ❌ | ❌ |
| Guava Cache | 120ms | 稳定 | ✅ | ✅ |
| Caffeine | 60ms | 更低 | ✅ | ✅ |
📊 结论:Caffeine 不仅更快,还更省内存!
八、监控与运维
8.1 查看缓存统计
java
CacheStats stats = userCache.stats();
log.info("命中率: {}, 加载次数: {}",
String.format("%.2f%%", stats.hitRate() * 100),
stats.loadCount());
8.2 接入 Micrometer(Spring Boot)
java
@Bean
public CacheMetricsRegistrar caffeineCacheMetrics() {
return new CaffeineCacheMetricsRegistrar();
}
然后通过 /actuator/metrics 查看 cache.gets, cache.puts 等指标。
九、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!