做Java后端开发,缓存这东西迟早要碰。一提到缓存,很多人第一反应就是Redis,但其实有些场景用本地缓存就够了,还省了一次网络IO。今天就聊聊Java里四种常见的本地缓存方案,从最简单的到最强大的,看完你就知道该选哪个了。
一、为什么需要本地缓存?
先说个真实场景。
前阵子线上出了一次连接池耗尽的事故,排查发现一个定时任务切面类里,每次定时任务执行前都要Feign远程调用网关服务获取版本号。一分钟6个定时任务,一小时就是360次远程调用,就为了拿一个几乎不变的版本字符串。
这种数据特点很明确:读多写少、变更频率极低、对实时性要求不高。完全适合放本地缓存里,何必每次都走网络?
本地缓存的核心优势就一个字:快。数据直接放在JVM内存里,读取速度是纳秒级的,比Redis快一个数量级。当然代价是占用应用内存,而且多实例之间数据不共享。所以它适合缓存的典型场景包括:
- 配置信息(版本号、功能开关)
- 字典数据(省市联动、枚举值)
- 计算结果(排行榜、统计摘要)
- 热点数据(高频读取的基础数据)
二、方案一:volatile 变量(最轻量)
这是JDK自带的方案,零依赖,适合缓存单个值。
实现原理
volatile 关键字保证了变量的可见性:一个线程修改了volatile变量,其他线程能立即看到最新值。配合时间戳做TTL过期,就是一个完整的缓存方案。
代码示例
java
public class VersionCache {
// 用volatile保证多线程下的可见性
// 一个线程更新了缓存,其他线程能立刻读到最新值
private volatile String cachedVersion = null;
// 记录上次缓存的时间戳,用于判断是否过期
private volatile long lastCacheTime = 0L;
// 缓存有效期:5分钟
private static final long CACHE_TTL = 5 * 60 * 1000L;
/**
* 获取版本号(带本地缓存)
* 5分钟内直接返回缓存,过期后重新加载
*/
public String getVersion() {
long now = System.currentTimeMillis();
// 缓存未过期,直接返回(命中缓存的最快路径)
if (cachedVersion != null && (now - lastCacheTime) < CACHE_TTL) {
return cachedVersion;
}
// 缓存过期或为空,重新加载
// 这里可以换成从数据库、Redis、远程接口获取
String freshVersion = loadFromRemote();
if (freshVersion != null) {
cachedVersion = freshVersion;
lastCacheTime = now;
}
// 即使远程加载失败,也返回旧的缓存值(容错降级)
return cachedVersion;
}
private String loadFromRemote() {
// 模拟远程调用
return "1.72.0";
}
}
适用场景
- 只需要缓存一个值(比如配置项、版本号)
- 不需要LRU淘汰(数据就一个,淘汰没意义)
- 不想引入额外依赖
优缺点
优点:
- 零依赖,JDK自带
- 代码极简,一眼能看懂
- 读取速度最快(直接读字段,1纳秒级)
缺点:
- ⚠️ 两个volatile字段没有原子性 :
cachedVersion和lastCacheTime是两个独立变量,写操作之间可能被其他线程打断,导致短暂的数据不一致 - 只能缓存单个值
- 没有命中率统计
- 需要自己管理过期逻辑
小贴士:如果多线程并发写同一个volatile变量,可能会有覆盖问题。但对于"读多写少"的缓存场景,偶尔覆盖不影响正确性,最多多查一次远程接口。
三、方案二:volatile + 不可变对象封装(推荐)
方案一的致命问题是两个volatile字段没有原子性。一个线程可能读到新的version但旧的时间戳,导致误判缓存过期。
解决方案有两种:
- 加
synchronized+ 双重检查锁(DCL)→ 代码复杂,有锁开销 - 用不可变对象封装 → 代码简洁,无锁,原子性天然保证
推荐方案2,这是Java缓存的最佳实践之一。
实现原理
把多个字段封装成一个不可变对象 (所有字段用final修饰),然后用单个 volatile 引用指向它。这样读写操作都是对单个引用的操作,天然原子。
typescript
方案一(两个volatile字段): 方案二(不可变对象封装):
读:version + timestamp 读:cacheEntry(一次读)
↓ 两次读,非原子 ↓ 一次读,原子
写:version, timestamp 写:cacheEntry = new Entry(...)
↓ 两次写,非原子 ↓ 一次写,原子
代码示例
java
public class VersionCacheV2 {
// 缓存有效期:5分钟
private static final long CACHE_TTL = 5 * 60 * 1000L;
/**
* 缓存条目(不可变对象)
* 将version和timestamp封装在一起,用单个volatile引用保证读写原子性
*
* 关键点:
* 1. 所有字段用final修饰,对象创建后不可变
* 2. 整体替换引用,而不是修改字段值
* 3. 读取时拿到的是完整的快照,不会出现半更新状态
*/
private static final class CacheEntry {
final String version; // 缓存的版本号
final long timestamp; // 缓存时间戳
CacheEntry(String version, long timestamp) {
this.version = version;
this.timestamp = timestamp;
}
}
// 单个volatile引用,读写天然原子
private volatile CacheEntry cacheEntry = null;
/**
* 获取版本号(带本地缓存)
*
* 与方案一的区别:
* - 读操作:一次引用读取,原子性有保障
* - 写操作:整体替换引用,原子性有保障
* - 无需synchronized,无锁竞争
*/
public String getVersion() {
long now = System.currentTimeMillis();
// 一次读操作,原子性有保障
// 拿到的是CacheEntry的完整快照,不会出现version新但timestamp旧的半更新状态
CacheEntry entry = cacheEntry;
if (entry != null && (now - entry.timestamp) < CACHE_TTL) {
return entry.version; // 命中缓存
}
// 缓存过期或为空,重新加载
String freshVersion = loadFromRemote();
if (freshVersion != null) {
// 一次写操作,原子性有保障(整体替换引用)
// 不修改旧对象,而是创建新对象替换引用
cacheEntry = new CacheEntry(freshVersion, now);
}
// 远程加载失败时返回旧缓存值(容错降级)
return cacheEntry != null ? cacheEntry.version : null;
}
private String loadFromRemote() {
return "1.72.0";
}
}
为什么不加 synchronized?
很多人第一反应是:缓存过期时多个线程同时刷新怎么办?要不要加锁防止"惊群效应"?
先看惊群效应的触发条件:
| 条件 | 说明 |
|---|---|
| 并发量大 | 几十上百个线程同时发现缓存过期 |
| 后端抗并发能力弱 | 后端服务无法承受并发请求 |
| 缓存重建代价高 | 加载缓存需要执行复杂SQL或调用付费API |
再看实际场景:
typescript
定时任务调度线程池:只有 2 个线程
缓存过期瞬间,最多 2 个线程同时发现过期
→ 最多 2 次冗余远程调用
→ 后端完全能承受
结论:2个线程的"惊群"根本不算惊群。加锁的收益是每5分钟节省最多1次冗余调用,代价是代码复杂度增加和锁竞争开销,不值得。
三种方案对比
| 维度 | 两个volatile字段 | synchronized + DCL | 不可变对象封装 |
|---|---|---|---|
| 读写原子性 | ❌ 无 | ✅ 有 | ✅ 有 |
| 是否加锁 | 不加锁 | 加锁 | 不加锁 |
| 性能 | 最好 | 稍差(锁开销) | 最好 |
| 代码复杂度 | 简单 | 复杂(DCL易写错) | 简单 |
| 惊群效应 | 最多2次冗余调用 | 完全消除 | 最多2次冗余调用 |
适用场景
- 缓存单个值,但需要保证读写原子性
- 不想引入额外依赖
- 不想用锁(避免锁竞争和DCL的复杂性)
小贴士 :不可变对象 + volatile引用 是Java并发编程的经典模式,JDK内部的
String、BigDecimal都是不可变对象。这种模式既保证了线程安全,又避免了锁的开销。
四、方案三:ConcurrentHashMap(能存多个值)
当需要缓存多个key-value时,ConcurrentHashMap 是最自然的选择。它在JDK里内置,线程安全,使用简单。
实现原理
ConcurrentHashMap 采用分段锁(JDK 8之后改为CAS + synchronized),读操作完全无锁,写操作只锁住单个桶,并发性能很好。配合定时清理过期key,就能实现一个多值本地缓存。
代码示例
java
public class LocalCacheManager {
// 缓存容器:ConcurrentHashMap保证线程安全
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
// 默认过期时间:10分钟
private static final long DEFAULT_TTL = 10 * 60 * 1000L;
/**
* 缓存条目,包含值和过期时间
*/
private static class CacheEntry {
Object value; // 缓存的值
long expireTime; // 过期时间戳
CacheEntry(Object value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
/**
* 写入缓存
*/
public void put(String key, Object value) {
put(key, value, DEFAULT_TTL);
}
public void put(String key, Object value, long ttl) {
cache.put(key, new CacheEntry(value, ttl));
}
/**
* 读取缓存
* 使用computeIfPresent做原子操作:读的同时检查过期
*/
public <T> T get(String key, Class<T> clazz) {
CacheEntry entry = cache.get(key);
if (entry == null) {
return null; // 缓存不存在
}
if (entry.isExpired()) {
cache.remove(key); // 惰性删除:读取时发现过期才删
return null;
}
return clazz.cast(entry.value);
}
/**
* 主动清理所有过期key
* 建议配合定时任务调用
*/
public void cleanExpired() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
}
使用起来也很直观:
java
LocalCacheManager cache = new LocalCacheManager();
// 写入缓存
cache.put("user:1001", "张三");
cache.put("config:timeout", 3000, 60 * 1000L); // 1分钟过期
// 读取缓存
String userName = cache.get("user:1001", String.class);
适用场景
- 需要缓存多个key-value对
- key的数量可控(不会无限增长)
- 不需要复杂的淘汰策略
优缺点
优点:
- JDK内置,零依赖
- 线程安全,读写性能好
- 灵活度高,可以自定义过期策略
缺点:
- 没有LRU淘汰,如果key无限增长会OOM
- 没有命中率统计
- 过期清理需要自己实现(惰性删除 + 定时清理)
避坑指南 :ConcurrentHashMap的
size()在并发场景下不一定准确。如果需要精确的缓存数量,考虑用AtomicLong单独计数。
五、方案四:Caffeine(功能最强大)
Caffeine是Spring Boot默认的本地缓存框架,前身是Guava Cache。如果你需要专业的本地缓存能力,选它就对了。
实现原理
Caffeine 使用 W-TinyLFU 算法做淘汰策略,比传统的LRU命中率更高。它支持三种过期策略:基于容量、基于时间(写入后过期/访问后过期)、基于引用(弱引用/软引用)。还内置了异步刷新、统计信息等功能。
代码示例
java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
public class CaffeineCacheManager {
/**
* 构建Caffeine缓存
*/
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最大缓存条目数:1000
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期
.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后10分钟过期(选配)
.recordStats() // 开启统计(命中率等)
.build();
/**
* 从缓存获取,不存在则自动加载
* Caffeine的get方法支持原子性加载,避免缓存穿透时的并发问题
*/
public String get(String key) {
return cache.get(key, k -> {
// 缓存不存在时,自动执行这个加载逻辑
// Caffeine保证同一个key只会有一个线程执行加载
return loadFromRemote(k);
});
}
/**
* 手动写入缓存
*/
public void put(String key, String value) {
cache.put(key, value);
}
/**
* 手动失效缓存
*/
public void invalidate(String key) {
cache.invalidate(key);
}
/**
* 打印缓存统计信息
*/
public void printStats() {
com.github.benmanes.caffeine.cache.stats.CacheStats stats = cache.stats();
System.out.println("命中率: " + stats.hitRate());
System.out.println("命中次数: " + stats.hitCount());
System.out.println("加载次数: " + stats.loadCount());
System.out.println("平均加载时间: " + stats.averageLoadPenalty() + "ms");
}
private String loadFromRemote(String key) {
// 模拟远程加载
return "value_" + key;
}
}
使用方式:
java
CaffeineCacheManager manager = new CaffeineCacheManager();
// 自动加载:缓存不存在时调用loadFromRemote
String value = manager.get("user:1001");
// 查看命中率
manager.printStats();
// 输出:
// 命中率: 0.85
// 命中次数: 850
// 加载次数: 150
// 平均加载时间: 2.3ms
Spring Boot集成方式
如果你用的是Spring Boot,Caffeine的集成更简单:
java
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> userCache() {
return Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
}
}
// 直接注入使用
@Service
public class UserService {
@Autowired
private Cache<String, Object> userCache;
public User getUser(String userId) {
return (User) userCache.get(userId, id -> {
return userMapper.selectById(id); // 缓存不存在时查数据库
});
}
}
适用场景
- 缓存key数量大,需要LRU淘汰
- 需要命中率统计来评估缓存效果
- 需要异步刷新(过期后后台刷新,不阻塞调用方)
- Spring Boot项目(天然集成)
优缺点
优点:
- W-TinyLFU算法,命中率比LRU高
- 功能丰富:容量淘汰、时间过期、引用淘汰、异步刷新、统计
- API设计优雅,使用简单
- Spring Boot生态支持好
缺点:
- 需要引入依赖(
com.github.ben-manes.caffeine:caffeine) - 对于简单场景来说有点"杀鸡用牛刀"
六、四种方案横向对比
| 维度 | volatile变量 | volatile+不可变对象 | ConcurrentHashMap | Caffeine |
|---|---|---|---|---|
| 依赖 | 无 | 无 | 无 | 需引入Caffeine |
| 适用场景 | 单值缓存 | 单值缓存(需原子性) | 多值缓存,key可控 | 多值缓存,key量大 |
| 读写原子性 | ❌ 无 | ✅ 有 | ✅ 有 | ✅ 有 |
| 容量限制 | 无(就一个值) | 无(就一个值) | 无(需自己管) | 支持maximumSize |
| 过期策略 | 需自己实现TTL | 需自己实现TTL | 需自己实现TTL | 内置TTL/LRU/引用 |
| 淘汰算法 | 无 | 无 | 无 | W-TinyLFU |
| 命中率统计 | 无 | 无 | 无 | 支持 |
| 异步刷新 | 无 | 无 | 无 | 支持 |
| 性能 | 最高(1ns) | 最高(1ns) | 很高(10ns) | 高(10ns) |
| 代码量 | 最少 | 少 | 中等 | 最少(API简洁) |
| 学习成本 | 最低 | 低 | 低 | 低 |
七、实战案例:如何选型?
回到开头那个真实场景:定时任务切面需要缓存网关版本号。这种场景该怎么选?
分析需求:
- 只缓存一个String值
- 5分钟过期就行
- 不需要淘汰策略(就一个值)
- 不想给公共模块加依赖
- 需要保证读写原子性(多线程并发访问)
选型过程:
| 方案 | 是否合适 | 原因 |
|---|---|---|
| volatile变量 | ❌ | 两个volatile字段没有原子性 |
| synchronized + DCL | ❌ | 代码复杂,2个线程的"惊群"不值得加锁 |
| volatile+不可变对象 | ✅ 选中 | 原子性有保障,无锁,代码简洁 |
| ConcurrentHashMap | ❌ | 杀鸡用牛刀,只有一个key |
| Caffeine | ❌ | 杀鸡用牛刀,还要加依赖 |
结论:选 volatile + 不可变对象封装。这是"够用就好"原则的体现。
再看另一个场景:系统里有500个医生的排班信息需要缓存,key是医生ID,数据会更新,需要LRU淘汰。
结论:选 Caffeine。多值缓存 + 需要淘汰策略,ConcurrentHashMap搞不定(key会无限增长导致OOM)。
最后看一个中间场景:缓存10个系统配置项,key固定不会增长。
结论:选 ConcurrentHashMap。key数量固定,不需要淘汰策略,JDK内置够用了。
八、选型决策树
typescript
需要缓存什么?
│
├── 单个值
│ ├── 需要读写原子性?
│ │ ├── 是 → 方案二:volatile + 不可变对象封装 ✅推荐
│ │ └── 否 → 方案一:volatile变量
│ │
│ └── 不在意原子性 → 方案一:volatile变量
│
├── 多个值
│ ├── key数量可控且固定?
│ │ ├── 是 → 方案三:ConcurrentHashMap
│ │ └── 否 → 需要淘汰策略
│ │ ├── 是 → 方案四:Caffeine ✅推荐
│ │ └── 否 → 方案三:ConcurrentHashMap(需自己实现清理)
│ │
│ ├── 需要命中率统计?
│ │ ├── 是 → 方案四:Caffeine
│ │ └── 否 → 方案三:ConcurrentHashMap
│ │
│ └── Spring Boot项目?
│ ├── 是 → 方案四:Caffeine(天然集成)
│ └── 否 → 按需选择
九、避坑指南
坑1:缓存穿透
问题:查询一个不存在的key,每次都穿透到后端。
解决方案:缓存空值,设置短TTL。
java
// Caffeine示例:缓存空值
cache.get(key, k -> {
String val = db.query(k);
return val != null ? val : ""; // 空值也缓存
});
坑2:缓存雪崩
问题:大量key同时过期,请求全部打到后端。
解决方案:TTL加随机偏移。
java
// 过期时间加随机偏移,避免同时过期
long ttl = baseTTL + ThreadLocalRandom.current().nextLong(60 * 1000L); // 0~60秒随机
坑3:本地缓存数据不一致
问题:多实例部署时,各实例本地缓存数据不同步。
解决方案:本地缓存只缓存变更频率低的数据;或者用Redis Pub/Sub通知各实例刷新。
坑4:ConcurrentHashMap内存泄漏
问题:只put不remove,key无限增长最终OOM。
解决方案:必须配合过期清理,或者直接用Caffeine。
java
// 定时清理过期key(示例)
@Scheduled(fixedRate = 5 * 60 * 1000L)
public void cleanExpired() {
cacheMap.entrySet().removeIf(e -> e.getValue().isExpired());
}
坑5:volatile不保证原子性
问题:两个volatile字段(如version + timestamp)的写操作不是原子的,多线程下可能出现半更新状态。
解决方案:用不可变对象封装,单个volatile引用保证原子性。
java
// 错误写法:两个volatile字段,非原子
private volatile String version;
private volatile long timestamp;
// 写操作之间可能被打断
version = newVal;
timestamp = System.currentTimeMillis();
// 正确写法:不可变对象 + 单个volatile引用
private static class CacheEntry {
final String version;
final long timestamp;
CacheEntry(String v, long t) { version = v; timestamp = t; }
}
private volatile CacheEntry cacheEntry;
// 一次写操作,原子性有保障
cacheEntry = new CacheEntry(newVal, System.currentTimeMillis());
坑6:惊群效应的误判
问题:过度担心惊群效应,给本不需要加锁的场景加了synchronized。
判断标准:
| 条件 | 建议加锁? |
|---|---|
| 并发量 < 10 且后端能承受 | ❌ 不需要 |
| 并发量 > 100 且后端脆弱 | ✅ 需要 |
| 缓存重建代价极高(复杂SQL/付费API) | ✅ 需要 |
| 缓存重建代价低(简单查询) | ❌ 不需要 |
记住:加锁不是免费的,synchronized会带来线程阻塞和上下文切换开销。只有收益明显大于成本时才加锁。
十、总结
选型没有绝对的对错,关键是看场景:
- 缓存单个值,不在意原子性 → volatile 变量
- 缓存单个值,需要原子性 → volatile + 不可变对象封装(推荐)
- 缓存多个值,key数量可控 → ConcurrentHashMap
- 缓存多个值,key数量大,需要专业能力 → Caffeine
记住一个原则:够用就好,不要过度设计。技术选型的本质是权衡,在满足需求的前提下,选择最简单的方案往往是最优解。
如果你正在用Spring Boot,且项目里已经引入了Caffeine,那就直接用Caffeine,统一技术栈。如果是公共基础模块,尽量保持零依赖,volatile或ConcurrentHashMap就够了。
关于原子性问题,记住一句话:能用不可变对象解决的,就不要用锁。不可变对象 + volatile引用是Java并发编程的经典模式,既保证了线程安全,又避免了锁的开销。