
做Java开发的同学,大概率都遇到过这样的场景:系统里有些配置数据、字典表或者高频计算结果,每次都查数据库实在太耗时,用 Redis 又觉得“杀鸡用牛刀”——网络传输的开销反而比查询本身还大。这时候,本地缓存就该登场了!
本地缓存直接把数据存在应用进程里,读写速度飞快,特别适合数据量小、变更不频繁的场景。而在Java生态里,Guava Cache 和 Caffeine 是最常用的两个本地缓存库。
一、先搞懂:本地缓存适合啥场景?
在聊具体库之前,先明确一下本地缓存的适用范围,避免用错地方反而踩坑:
- 静态配置 / 元数据:比如系统的规则参数、接口白名单、地区编码映射表,这些数据几乎不怎么变;
- 小体量高频访问数据:比如字典表(性别、学历、订单状态等),数据量小但查询频繁;
- 复杂计算结果缓存:比如统计报表的计算结果、公式推导结果,避免重复计算浪费 CPU;
- 临时数据:比如验证码、临时 token,只在当前请求或短时间内有效,不需要跨服务共享。
这里要特别提醒一句:如果需要跨服务共享缓存、数据量很大,或者要求高可用,还是得选 Redis 这类分布式缓存。本地缓存是“单机私有”的,服务重启就丢数据,集群部署时还可能出现数据不一致的问题,这点一定要注意!
二、Guava Cache
Guava 是 Google 开源的工具类库,Cache 模块是它的核心功能之一,多年来一直在很多旧项目中发光发热。它的优点是 API 简洁、稳定可靠,虽然性能不是最顶尖,但胜在成熟易用。
2.1 快速上手:基本用法示例
首先得引入依赖(Maven):
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
Guava Cache 的核心是 Cache 和 LoadingCache 两个类,先看最基础的 Cache 用法:
public void basicGuavaCacheDemo() throws ExecutionException {
// 构建缓存实例,设置核心参数
Cache<String, String> cache = CacheBuilder.newBuilder()
.initialCapacity(100) // 初始容量,避免频繁扩容
.maximumSize(1000) // 最大容量,满了会触发淘汰
.expireAfterWrite(3, TimeUnit.MINUTES) // 写入后3分钟过期
.concurrencyLevel(5) // 最大并发写入线程数,避免线程竞争
.build();
// 1. 写入缓存
cache.put("user:1001", "张三");
// 2. 获取缓存:存在则返回,不存在则执行Callable逻辑(加载后存入缓存)
String user1 = cache.get("user:1001", () -> "默认用户");
String user2 = cache.get("user:1002", () -> {
// 这里可以写查库、调接口的逻辑,比如从数据库查询用户信息
System.out.println("缓存未命中,查询数据库...");
return "李四"; // 模拟查库结果
});
// 3. 其他常用操作
String user3 = cache.getIfPresent("user:1001"); // 不存在返回null
cache.invalidate("user:1001"); // 手动失效某个key
cache.cleanUp(); // 清除所有过期缓存
}
2.2 进阶用法:LoadingCache 自动加载
如果每次获取缓存时,未命中的加载逻辑都一样(比如都是查数据库),用 LoadingCache 会更方便——它可以统一配置加载逻辑,不用每次 get 都写 Callable:
// 构建LoadingCache,统一配置加载逻辑
LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(3, TimeUnit.MINUTES) // 写入后3分钟自动刷新
.build(new CacheLoader<String, String>() {
// 缓存未命中时,自动执行load方法加载数据(同步阻塞)
@Override
public String load(String key) throws Exception {
System.out.println("缓存未命中,执行加载逻辑:" + key);
return queryFromDb(key); // 查库逻辑
}
// 刷新缓存时执行(可重写实现异步刷新)
@Override
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
// 这里可以实现异步刷新,避免阻塞查询
return Futures.immediateFuture(queryFromDb(key));
}
});
// 使用时直接get,无需手动处理未命中逻辑
String user = loadingCache.get("user:1003");
2.3 开发必看:工程化优化方案
上面的基础用法虽然能跑起来,但在实际项目中还有两个问题要解决:
- 健壮性:如果查库时抛出异常,缓存会存入错误值,导致后续查询都拿到错误结果;
- 性能:
load 方法是同步阻塞的,缓存刷新时会影响接口响应速度。
所以需要做一些优化,用线程池实现异步刷新,同时增加异常兜底:
// 1. 创建线程池,用于异步刷新缓存
ListeningExecutorService executorService = MoreExecutors.listeningDecorator(
Executors.newFixedThreadPool(4) // 4个核心线程,可根据业务调整
);
// 2. 优化后的LoadingCache
LoadingCache<String, String> optimizedCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(3, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
try {
String data = queryFromDb(key);
// 数据校验,避免存入空值或错误数据
return StringUtils.isEmpty(data) ? "" : data;
} catch (Exception e) {
log.error("加载缓存失败,key:{}", key, e);
return ""; // 异常兜底,返回默认值
}
}
@Override
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
// 异步执行刷新逻辑,不阻塞用户请求
return executorService.submit(() -> {
try {
return queryFromDb(key);
} catch (Exception e) {
log.error("刷新缓存失败,key:{}", key, e);
return oldValue; // 刷新失败时,返回旧值,保证缓存可用
}
});
}
});
这个优化方案的核心亮点:
- 首次加载是同步的,但有异常兜底,不会存入错误数据;
- 缓存刷新是异步的,用户查询时不会被阻塞,体验更好;
- 刷新失败时返回旧值,避免缓存失效导致的雪崩问题。
三、Caffeine:后起之秀,性能天花板
Caffeine 是 Guava Cache 的“继任者”,由 Java 社区大神开发,专门针对 Guava 的性能短板做了优化。它采用了更先进的 W-TinyLFU 淘汰算法,在命中率、吞吐量和内存效率上都远超 Guava,现在已经成为 Spring Cache 的默认底层实现,新建项目优先选它准没错。
3.1 核心优势:W-TinyLFU 淘汰算法
为什么 Caffeine 性能这么强?关键在于它的淘汰算法。传统的缓存淘汰算法有明显短板:
- LRU(最近最久未使用):容易淘汰掉“短期没访问但长期高频”的条目(比如某个数据每天早上 9 点集中访问,其他时间没人用,LRU 可能会把它淘汰);
- LFU(访问频率最低):需要为每个条目维护精确的访问计数器,内存开销大,而且不适应突发流量。
而 W-TinyLFU 算法完美解决了这些问题:
- 用极小的内存开销(每个条目仅需 4 位)近似统计访问频率;
- 分“窗口缓存”和“主缓存”:窗口缓存吸收突发流量,主缓存保留长期高频条目;
- 无锁或细粒度锁设计,减少线程竞争,吞吐量更高。
简单说:同样的内存空间,Caffeine 能缓存更多有用的数据,命中率更高,性能自然更好。
3.2 快速上手:基本用法
先引入依赖(Maven):
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Caffeine 的 API 设计和 Guava 很相似,上手成本很低,先看基础 Cache 用法:
@Test
void basicCaffeineCacheDemo() {
// 构建缓存实例
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最大容量
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后5分钟过期(无访问则失效)
.build();
// 写入缓存
cache.put("product:2001", "iPhone 15");
// 获取缓存:存在返回值,不存在则执行lambda表达式加载
String product1 = cache.getIfPresent("product:2001"); // 存在,返回"iPhone 15"
String product2 = cache.get("product:2002", key -> {
// 模拟查库逻辑
System.out.println("缓存未命中,查询商品信息:" + key);
return "华为Mate 60";
});
System.out.println("product1: " + product1);
System.out.println("product2: " + product2);
}
3.3 进阶用法:LoadingCache 与异步支持
和 Guava 一样,Caffeine 也有 LoadingCache,用于统一配置加载逻辑:
// 构建LoadingCache
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000) // 下划线是分隔符,不影响数值,可读性更好
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 统一加载逻辑,比如查库、调接口
return queryProductFromDb(key);
}
});
// 使用时直接get,自动触发加载逻辑
String product = loadingCache.get("product:2003");
而 Caffeine 最亮眼的特性,是对异步操作的原生支持——不需要像 Guava 那样手动创建线程池,直接用 AsyncCache 和 AsyncLoadingCache 即可:
// 构建异步加载缓存
AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder()
.refreshAfterWrite(30, TimeUnit.SECONDS) // 写入30秒后自动刷新
.buildAsync(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 同步加载逻辑(会被自动封装为异步)
return key + ": " + queryProductFromDb(key);
}
});
// 异步写入缓存(返回CompletableFuture)
CompletableFuture<Void> putFuture = asyncLoadingCache.put("product:2004", CompletableFuture.supplyAsync(() -> {
// 模拟耗时加载过程(比如调用第三方接口)
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "加载失败";
}
return "小米14";
}));
// 异步获取缓存(返回CompletableFuture)
CompletableFuture<String> getFuture = asyncLoadingCache.get("product:2004");
// 处理结果(回调式,不阻塞主线程)
getFuture.thenAccept(product -> {
System.out.println("异步获取到商品:" + product);
}).exceptionally(e -> {
System.out.println("加载缓存失败:" + e.getMessage());
return "默认商品"; // 异常兜底
});
// 等待操作完成(实际开发中可根据业务选择是否阻塞)
CompletableFuture.allOf(putFuture, getFuture).join();
四、Guava vs Caffeine 该怎么选?
说了这么多,两个缓存库的核心差异和选型建议整理成表格,一目了然:
| 对比维度 |
Guava Cache |
Caffeine |
| 性能表现 |
良好,满足大部分场景 |
优秀,命中率、吞吐量远超 Guava |
| 淘汰算法 |
LRU 变体 |
W-TinyLFU(更先进,适应更多场景) |
| 异步支持 |
需手动配合线程池实现 |
原生支持 AsyncCache,API 更简洁 |
| 功能丰富度 |
基础功能齐全,无过多冗余 |
在 Guava 基础上优化,增加更多实用特性(如变量过期时间) |
| 兼容性 |
支持 Java 8+,适配所有旧项目 |
支持 Java 8+,Spring Boot 2.x + 默认集成 |
| 适用场景 |
旧项目维护、对性能要求不高的场景 |
新项目开发、高并发场景、对性能有要求的场景 |
实际开发建议:
- 新项目优先选 Caffeine:性能更强、API 更友好,还能和 Spring 生态无缝集成,后期维护成本低;
- 旧项目无需强制替换:如果项目中已经在用 Guava Cache,且没有性能瓶颈,没必要特意改成 Caffeine——稳定优先,避免引入不必要的风险;
- 高并发场景必选 Caffeine:比如秒杀系统、高频查询接口,Caffeine 的高命中率和低延迟能显著提升系统性能;
- 简单场景可考虑 ConcurrentHashMap:如果只是简单的键值对存储,不需要过期、淘汰功能,用 JDK 原生的 ConcurrentHashMap 更轻量,无需引入额外依赖。
选择哪种缓存,最终还是取决于你的具体业务场景和技术栈。希望这篇文章能帮你做出更明智的决策。关于后端架构和性能优化的更多讨论,欢迎访问云栈社区进行交流。