Caffeine 并发设计深度解析(面试向)
一、为什么摒弃分段锁(Segment Lock)
ConcurrentHashMap (JDK7) 分段锁的问题:
- 锁粒度仍然较粗(一个 Segment 锁住一批桶)
- 每个 Segment 是独立 HashTable,内存开销大
- 写竞争集中在 Segment 上,扩展性受限
- 缓存场景下,读远多于写,且每次读都可能触发统计/LRU 更新,分段锁会成为瓶颈
Caffeine 借鉴了 JDK8 ConcurrentHashMap 的思路(实际底层就是基于 CHM),并在缓存语义层做了进一步优化。
二、Caffeine 的核心并发设计
1. 数据存储层:CAS + synchronized(细粒度锁)
- 底层
BoundedLocalCache继承自类 ConcurrentHashMap 的结构 - CAS 操作 :节点的状态字段(如
writeTime、accessTime、weight)使用VarHandle/Unsafe进行 CAS 更新,无锁 - synchronized 锁单个桶头节点:仅在哈希冲突链表/红黑树修改时才加锁,锁粒度 = 单个 bin
- 读操作完全无锁(依赖 volatile 可见性)
2. 读写缓冲区(核心创新)
ReadBuffer(多个 RingBuffer,类似 Striped 设计):
- 每次
get()后需要更新 LRU/LFU 频率,若直接改链表会引发竞争 - 解决方案:线程哈希到不同的 RingBuffer(默认 4 × NCPU 向上取2幂)
- 使用 CAS 写入 ring buffer,有损(满了就丢弃,不影响正确性)
- 异步批量 drain 到 LRU 队列
WriteBuffer(MpscGrowableArrayQueue):
- 多生产者单消费者无锁队列
- 写操作必须保证不丢失,所以是无损的
- 同样异步 drain
3. 异步维护:drainStatus 状态机
通过 CAS 维护 drainStatus(IDLE/REQUIRED/PROCESSING_TO_IDLE/PROCESSING_TO_REQUIRED)
- 只有一个线程能成为 drainer(
tryLock语义) - 其他线程提交完任务直接返回,不阻塞读写主路径
4. 频率统计:FrequencySketch(W-TinyLFU)
- Count-Min Sketch 的 4-bit 计数器
- 通过位运算 + CAS 更新计数,无锁
三、面试高频问答
Q1:Caffeine 比 Guava Cache 快在哪里?
- Guava 用分段锁 + 同步执行 LRU 维护 → 读路径上有锁竞争
- Caffeine 把维护操作异步化(RingBuffer 缓冲 + 后台 drain)→ 读路径几乎无锁
Q2:为什么 ReadBuffer 可以有损?
- 它只用于更新访问频率/LRU 顺序,丢一两个事件不影响正确性,仅影响淘汰精度
- 换来极致性能
Q3:synchronized 在哪里用?
- 仅在修改 bin 头节点�链表/树)时使用,且是 JDK8 优化后的偏向→轻量→重量级锁路径,竞争少时几乎零开销
Q4:CAS 用在哪些地方?
- 节点状态字段更新、ringBuffer 写入索引、drainStatus 切换、FrequencySketch 计数
Q5:与 ConcurrentHashMap 的关系?
- Caffeine 的 BoundedLocalCache 借鉴 CHM 的 bin 结构和锁策略,但额外叠加了缓存语义层的异步维护机制
四、总结一句话
Caffeine = CHM 的细粒度桶锁(CAS + synchronized bin) + 有损 ReadBuffer / 无损 WriteBuffer 异步化维护 + W-TinyLFU 淘汰,让读写主路径几乎无锁竞争,把维护开销摊销到后台单线程。
FrequencySketch 与 W-TinyLFU 详解
一、为什么需要频率统计?
传统 LRU 的致命缺陷:只看"最近",不看"频次"
经典反例(缓存污染):
- 一次性扫描大量冷数据(如全表扫描)会把热点数据全挤出缓存
- 例如:热点 key A 被访问 1000 次,但突然来一批只访问 1 次的冷 key,A 就被淘汰了
解决思路 :淘汰时同时考虑 访问频率 (LFU) 和 最近访问 (LRU) → TinyLFU / W-TinyLFU
二、FrequencySketch 是什么
它是 Caffeine 实现的 Count-Min Sketch (CM-Sketch) 变种,用极小内存近似统计每个 key 的访问频率。
朴素 LFU 的问题
- 给每个 key 维护一个计数器 → 1 万个 key 就要 1 万个 long
- 内存开销大、且 key 已经被淘汰后计数器还占空间
Count-Min Sketch 的核心思想
用一个固定大小的二维计数矩阵 + 多个哈希函数:
-
key 经过 4 个不同哈希函数,映射到 4 个槽位
-
每次访问,4 个槽位都 +1
-
查询频率时,取这 4 个槽位的 最小值(Min)作为估计值
-
因为哈希冲突只会让计数偏大,取 min 能逼近真实值
hash1 → [槽位a] +1key → hash2 → [槽位b] +1
hash3 → [槽位c] +1
hash4 → [槽位d] +1频率估计 = min(a, b, c, d)
三、Caffeine 的工程优化(精髓所在)
1. 4-bit 计数器(而不是 int/long)
- 每个计数器只用 4 bit,最大值 = 15
- 1 个 long (64 bit) 可以装 16 个计数器
- 内存占用极低:1 万 entry 缓存 ≈ 几十 KB
为什么 15 够用?→ 配合下面的"老化机制"
2. 饱和计数 (Saturation)
- 计数到 15 就不再增加
- 避免热点 key 计数无限膨胀,且让新热点有机会"翻身"
3. Aging / Reset(老化机制)------ 关键
- 每当总访问次数达到阈值(默认 = 容量的 10 倍),所有计数器右移 1 位(除以 2)
- 作用:
- 让历史频率"衰减",适应访问模式变化
- 避免老热点永远霸占缓存
- 对应论文里的 "Freshness Mechanism"
4. CAS 无锁更新
// 伪代码
long old = table[index];
long updated = old + (1L << offset); // 对应 4-bit 段 +1
UNSAFE.compareAndSwapLong(table, index, old, updated);
- 多线程并发更新计数器全程无锁
- 失败重试,竞争极小(哈希分散)
5. 块级哈希 (Block Hash)
- 4 个哈希值落在 同一个 64-byte cache line 内(4 个 long = 32 byte,一个 block = 8 个 long)
- CPU 缓存友好,一次内存加载完成 4 次查询
- 这是 Caffeine 比传统 CM-Sketch 快很多的原因之一
四、W-TinyLFU 整体淘汰流程
W-TinyLFU = Window LRU + Main (SLRU) + TinyLFU 准入过滤器
新数据 → [Window LRU (1%)] ──淘汰候选→ ┐
├─ TinyLFU 比较频率 → 胜者进 Main
Main SLRU (99%) ──淘汰候选→ ──┘
├─ Probation (20%)
└─ Protected (80%)
三个区域
| 区域 | 占比 | 算法 | 作用 | |------|-----|------|-----| | Window | 1% | LRU | 缓冲新入 key,应对突发流量 | | Probation | 80%×20% | LRU | 试用区,低频区 | | Protected | 80%×80% | LRU | 保护区,高频区 |
准入决策(核心)
当 Window 满了,要把"Window 候选"放进 Main:
- 从 Main 的 Probation 取出 LRU 端的"受害者 victim"
- 用 FrequencySketch 比较两者频率
- candidate 频率 > victim → 替换
- candidate 频率 ≤ victim → 拒绝(保留老的)
- 这就是 TinyLFU 的"准入过滤"思想
五、面试高频问答
Q1:FrequencySketch 内存开销?
- 4-bit × (容量 × 10) 大约几十 KB 级别,几乎可忽略
Q2:为什么取 min?
- CM-Sketch 因哈希冲突计数只会偏大不会偏小,取 min 是无偏下界估计
Q3:为什么要老化?
- 适应热点漂移,防止"僵尸热点",配合 4-bit 饱和避免溢出
Q4:W-TinyLFU 解决了什么?
- 解决纯 LFU 对突发流量不友好(新 key 频率为 0 进不来)→ 用 Window 兜底
- 解决纯 LRU 的缓存污染(一次性扫描挤掉热点)→ 用 TinyLFU 准入过滤
Q5:为什么 Caffeine 命中率高?
- W-TinyLFU 论文证明:在多种工作负载下命中率接近最优 (Belady),显著优于 LRU/LFU/ARC
一句话总结
FrequencySketch = 4-bit Count-Min Sketch + 老化 + CAS 无锁 + cache-line 友好 ,以极小内存代价提供准确的频率估计;W-TinyLFU = Window LRU + SLRU + 频率准入,兼顾突发流量与长期热点,是 Caffeine 高命中率的核心。
已详细讲解 FrequencySketch 与 W-TinyLFU:
FrequencySketch(频率草图):
- 基于 Count-Min Sketch,用 4 个哈希函数映射到 4 个槽位,取 min 作为频率估计
- 4-bit 计数器(1 long 装 16 个),内存极小
- 饱和到 15 不再累加;总计数达阈值时全部右移 1 位(老化)
- CAS 无锁更新;4 个哈希落同一 cache line(块哈希),CPU 友好
W-TinyLFU 淘汰:
- Window(1%) LRU + Main SLRU(99% = Probation 20% + Protected 80%)
- 准入过滤:Window 候选 vs Main victim,频率高者胜出
- 解决 LRU 缓存污染 + LFU 对突发流量不友好的问题
核心面试点:4-bit 饱和、老化机制、CAS 无锁、块哈希、准入过滤思想、为什么命中率接近 Belady 最优。