相信大家对Caffeine都不陌生,Caffeine 作为高性能的 Java 缓存库,被广泛应用于各种 Java 框架、中间件、数据库访问层等场景,大部分高并发的项目都用到Caffeine本地缓存,以提升响应速度、降低数据库压力、优化高并发性能。下面我们深入了解下Caffeine......
前言
在高并发系统中,缓存是提升性能的核心组件之一。Caffeine 作为一款高性能 Java 缓存库,凭借其 Window-TinyLFU 淘汰算法 和 卓越的并发性能,成为许多系统的首选。本文将深入解析 Caffeine 的设计原理、核心架构、关键 API,并通过一个高并发场景的实战案例,展示其应用方法。
一、Caffeine 的核心原理
- 淘汰策略:Window-TinyLFU,Caffeine 采用 Window-TinyLFU 算法,结合了 LRU(最近最少使用)和 LFU(最不经常使用)的优点: 滑动窗口分区: 缓存分为一个 主区域(保护高频数据)和一个 窗口区域(接纳新数据),避免突发流量污染缓存。 频率素描(Count-Min Sketch): 以极低的内存开销统计数据访问频率,替代传统 LFU 的哈希计数。
- 高性能并发设计 分段锁机制: 写操作分段加锁,减少线程竞争。 无锁读优化: 通过 AtomicReference 实现并发读的高性能。
关键数据结构设计
// 频率素描 (Count-Min Sketch)
class CountMinSketch {
long[][] matrix = new long[4][]; // 4个哈希函数
void increment(key) {
for (int i = 0; i < 4; i++) {
matrix[i][hash(i, key)]++;
}
}
}
// 存储节点 (Node)
class Node<K,V> {
K key;
V value;
volatile long accessTime; // 访问时间,CAS更新
int frequency; // TinyLFU计数
Node<K,V> prev, next; // LRU链表指针
}
下面我给出读请求和写请求两种模块内部工作交互
- 用户调用 cache.get("key")
- 查 ConcurrentHashMap → 命中?返回数据&频率+1 : 继续
- 未命中 → 调用 CacheLoader.load("A") 加载数据
- 数据加载后:
- 写入 ConcurrentHashMap
- 更新 Count-Min Sketch(频率+1)
- 返回数据
-
调用 cache.put(key, value)
-
锁获取: 根据 key.hashCode() 计算对应的 Segment 锁(默认16个分段)
-
锁内操作:
步骤1: 检查 Key 是否已存在:存在 → 覆盖旧值;不存在 → 进入 Window-TinyLFU 淘汰逻辑
步骤2: 检查 Window 区域是否已满:未满 → 直接写入 Window 区;已满 → 触发淘汰流程: 对比新数据与 Window 中最旧数据的频率素描计数。 新数据频率更高 → 淘汰旧数据,新数据进入 Main 区。 旧数据频率更高 → 丢弃新数据。
-
更新元数据: 更新 Count-Min Sketch 频率计数 记录访问时间戳(LRU 逻辑)
-
释放锁: 完成写入
下面给出伪代码
// 伪代码: TinyLFU淘汰
//1. 缓存达到 maximumSize
//2. 检查 Window 区域:
// - 新数据进入 Window,若满则淘汰最旧数据
//3. 检查 Main 区域:
// - 对比频率素描,淘汰低频数据
// - 频率相近时,淘汰 LRU 数据
if (window.isFull()) {
Candidate newEntry = new Candidate(key, value);
Candidate victim = window.getOldestEntry();
if (frequencySketch.compare(newEntry, victim) > 0) {
mainRegion.admit(newEntry); // 新数据胜出
window.evict(victim); // 淘汰旧数据
}
}
二、Caffeine 的架构分层
模块 | 功能描述 |
---|---|
Cache Interface | 定义缓存的核心 API(get、put、invalidate 等)。 |
Eviction Policy | 实现 Window-TinyLFU 算法,管理缓存淘汰逻辑。 |
Concurrency Control | 通过分段锁和 CAS 操作保证线程安全。 |
Data Storage | 基于 ConcurrentHashMap 存储缓存条目,支持高效查找。 |
三、Caffeine 重点方法详解
1. 初始化方法:Caffeine.newBuilder()
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目数
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后过期
.refreshAfterWrite(1, TimeUnit.MINUTES) // 自动刷新(非阻塞)
.weakKeys() // Key弱引用(GC敏感)
.weakValues() // Value弱引用
.recordStats() // 开启命中率统计
.removalListener((key, value, cause) ->
System.out.println("移除原因: " + cause)) // 淘汰监听
.build();
关键参数:
- maximumSize:触发淘汰的条目数(非字节大小,需结合 weigher 使用)。
- expireAfterWrite vs expireAfterAccess: Write:严格一致性,适合配置类数据。 Access:更高命中率,适合热点数据。
- refreshAfterWrite:异步刷新(旧值仍可用),避免缓存雪崩。
2. 数据写入:put(K key, V value)
cache.put("key", new Object());
底层逻辑:
- 计算 key.hashCode() 确定所属 Segment(默认16个分段)。
- 获取分段锁(ReentrantLock),保证线程安全。
- 执行写入: - 若 Key 存在,直接覆盖。 - 若 Key 不存在,进入 Window-TinyLFU 淘汰流程:
if (window.isFull()) {
if (frequencySketch.compare(newData, oldestData) > 0) {
mainRegion.admit(newData); // 新数据胜出
}
}
- 更新 Count-Min Sketch 频率统计 (无锁CAS操作)。
- 释放 分段锁。
3. 数据读取:get(K key, Function loader)
Object value = cache.get("key", k -> loadFromDB(k));
执行流程:
- 无锁读:尝试从 ConcurrentHashMap 获取值(get() 为原子操作)。
- 缓存未命中: 同步调用 loader.apply(key) 加载数据(同一 Key 并发时仅加载一次)并写入缓存并更新频率素描。
- 缓存命中:更新 LRU 访问时间戳,返回缓存值。
高并发优化: 使用 AsyncLoadingCache 避免阻塞:
AsyncLoadingCache<String, Object> asyncCache = Caffeine.newBuilder()
.buildAsync(key -> loadFromDB(key));
CompletableFuture<Object> future = asyncCache.get("key");
4. 数据淘汰:invalidate(key) 与自动淘汰
cache.invalidate("key"); // 主动移除单个Key
cache.invalidateAll(); // 清空缓存
自动淘汰触发条件:
- 基于大小(maximumSize):触发 Window-TinyLFU 淘汰赛。
- 基于时间(expireAfter*):后台线程定期清理(ForkJoinPool)。
- 基于引用(weakKeys/weakValues):依赖 GC 回收。
// 淘汰监听:我们可以监听不同淘汰触发条件做不同的处理。比如当缓存数据不是因为手工删除和超出容量限制而被删除的情况,就需要通知业务处理器做相应处理
.removalListener((key, value, cause) -> {
// cause: EXPLICIT(手动移除), SIZE(容量淘汰), EXPIRED(过期)...
if(cause != RemovalCause.EXPLICIT && cause != RemovalCause.REPLACED && cause != RemovalCause.SIZE)
listener.notifyExpired(busId, (String)key);
})
5.统计与监控:recordStats()
recordStats()启用缓存统计功能,记录以下核心指标: CacheStats { hitCount; // 缓存命中次数 missCount; // 缓存未命中次数 loadSuccessCount; // 成功加载新值的次数 loadFailureCount; // 加载失败次数 totalLoadTime; // 总加载耗时(纳秒) evictionCount; // 因容量或过期导致的淘汰总数 evictionWeight; // 淘汰条目的总权重(仅在使用weigher时有效) }
CacheStats stats = cache.stats();
System.out.printf("命中率: %.1f%%, 加载次数: %d, 淘汰数: %d",
stats.hitRate() * 100,
stats.loadCount(),
stats.evictionCount());
关键指标:
- hitRate():缓存命中率(0~1)。
- loadSuccessCount():成功加载次数。
- totalLoadTime():总加载耗时(纳秒)。
6. 异步方法:refresh(key)
cache.refresh("key"); // 异步重新加载值(旧值仍可用)
与 expire 的区别: expire:立即失效,请求需等待新值加载。 refresh:后台加载,旧值可继续服务(更平滑)。
源码性能优化技巧
频率素描(Count-Min Sketch): 使用 4 个 long[][] 矩阵统计频率,误差率 <1%。比传统 LFU 节省 90% 内存。 分段锁优化: 默认 16 个 Segment,并发写性能接近 O(1)。 无锁读路径: 所有读操作完全无锁(volatile 变量 + AtomicReference)。
四、高并发场景实战:商品信息缓存
场景: 每秒万级请求的读请求(商品查询,架构查询),要求 99.9% 的缓存命中率。防止缓存击穿、雪崩,支持平滑过期刷新。
import com.github.benmanes.caffeine.cache.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
public class ProductCache {
// 缓存实例 (单例模式)
private static final LoadingCache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(10000) // 容量控制
.expireAfterWrite(30, TimeUnit.MINUTES) // 基础过期时间
.refreshAfterWrite(5, TimeUnit.MINUTES) // 自动刷新防雪崩
.executor(Executors.newFixedThreadPool(4)) // 刷新线程池
.recordStats() // 监控
.build(new ProductLoader()); // 自定义加载逻辑
// 防击穿锁 (Key级别细粒度锁)
private static final ConcurrentMap<Long, AtomicBoolean> loadingLocks = new ConcurrentHashMap<>();
// 缓存加载器 (核心防击穿逻辑)
private static class ProductLoader implements CacheLoader<Long, Product> {
@Override
public Product load(Long productId) throws Exception {
// 模拟数据库查询
return fetchFromDB(productId);
}
@Override
public Product reload(Long productId, Product oldValue) throws Exception {
// 异步刷新时触发 (防雪崩关键)
return fetchFromDB(productId);
}
private Product fetchFromDB(Long productId) {
// 1. 获取Key专属锁 (防击穿)
AtomicBoolean lock = loadingLocks.computeIfAbsent(productId, k -> new AtomicBoolean(false));
while (!lock.compareAndSet(false, true)) {
Thread.yield(); // 自旋等待
}
try {
// 2. 再次检查缓存 (可能其他线程已加载)
Product cached = cache.getIfPresent(productId);
if (cached != null) return cached;
// 3. 模拟数据库查询 (核心业务逻辑)
System.out.println("Loading from DB: " + productId);
Thread.sleep(100); // 模拟IO延迟
return new Product(productId, "Product-" + productId, ThreadLocalRandom.current().nextInt(100));
} catch (Exception e) {
throw new RuntimeException("DB error", e);
} finally {
lock.set(false); // 释放锁
loadingLocks.remove(productId); // 防止内存泄漏
}
}
}
// 商品类
public static class Product {
private final Long id;
private final String name;
private final int stock;
public Product(Long id, String name, int stock) {
this.id = id;
this.name = name;
this.stock = stock;
}
}
// 获取商品 (对外接口)
public static Product getProduct(Long productId) {
return cache.get(productId);
}
// 测试
public static void main(String[] args) throws InterruptedException {
// 模拟并发请求
ExecutorService pool = Executors.newFixedThreadPool(20);
for (int i = 0; i < 100; i++) {
long productId = i % 10; // 10个商品模拟热点
pool.execute(() -> {
Product p = getProduct(productId);
System.out.println("Get: " + p.name);
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
// 打印统计
System.out.println("缓存命中率: " + cache.stats().hitRate());
}
}
设计思路 防击穿: 使用 Caffeine 的 CacheLoader + 同步锁(loadingLocks 的 AtomicBoolean CAS 操作),保证单个 Key 只加载一次 防雪崩: refreshAfterWrite 异步刷新 + 随机过期时间 热点保护: 基于 maximumSize 和 Window-TinyLFU 自动管理热点数据
实际大型项目中考虑到本地内存的局限性,大多数场景使用Caffeine和Redis多级缓存架构
总结
我们在使用Caffine时,根据内存压力调整 maximumSize,避免频繁淘汰。Caffeine 通过创新的 Window-TinyLFU 算法 和 精细化并发控制,在缓存命中率和吞吐量之间取得平衡。本文的实战案例展示了如何通过异步加载、自动刷新等机制,构建稳定高效的缓存层。
适用场景: 读多写少、高并发、低延迟需求的业务(如电商、社交平台)。