Caffeine 并发设计深度解析(面试向)

Caffeine 并发设计深度解析(面试向)

一、为什么摒弃分段锁(Segment Lock)

ConcurrentHashMap (JDK7) 分段锁的问题:

  • 锁粒度仍然较粗(一个 Segment 锁住一批桶)
  • 每个 Segment 是独立 HashTable,内存开销大
  • 写竞争集中在 Segment 上,扩展性受限
  • 缓存场景下,读远多于写,且每次读都可能触发统计/LRU 更新,分段锁会成为瓶颈

Caffeine 借鉴了 JDK8 ConcurrentHashMap 的思路(实际底层就是基于 CHM),并在缓存语义层做了进一步优化。


二、Caffeine 的核心并发设计

1. 数据存储层:CAS + synchronized(细粒度锁)

  • 底层 BoundedLocalCache 继承自类 ConcurrentHashMap 的结构
  • CAS 操作 :节点的状态字段(如 writeTimeaccessTimeweight)使用 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] +1

    key → 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:

  1. 从 Main 的 Probation 取出 LRU 端的"受害者 victim"
  2. FrequencySketch 比较两者频率
    • candidate 频率 > victim → 替换
    • candidate 频率 ≤ victim → 拒绝(保留老的)
  3. 这就是 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(频率草图)

  1. 基于 Count-Min Sketch,用 4 个哈希函数映射到 4 个槽位,取 min 作为频率估计
  2. 4-bit 计数器(1 long 装 16 个),内存极小
  3. 饱和到 15 不再累加;总计数达阈值时全部右移 1 位(老化)
  4. 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 最优。

相关推荐
晚风予卿云月15 小时前
【Linux】初步构建框架—虚拟地址空间(三)—进程与内存管理的解耦优势、深入理解vm_area_struct
linux·运维·服务器·面试
我是一颗柠檬15 小时前
【JDK8新特性】JDK8实战与面试高频考点汇总Day12
java·开发语言·后端·面试·职场和发展
Brilliantwxx15 小时前
【算法题】 面试级别的二叉树题目OJ复习(下)
数据结构·c++·算法·leetcode·面试·哈希算法·推荐算法
better_liang1 天前
每日Java面试场景题知识点之-消息队列MQ核心场景与实战
java·面试·kafka·消息队列·rabbitmq·rocketmq·mq
小江的记录本1 天前
【JVM虚拟机】垃圾回收GC:四种引用类型:强引用、软引用、弱引用、虚引用(附《思维导图》+《面试高频考点清单》)
java·jvm·spring boot·后端·python·spring·面试
better_liang1 天前
每日Java面试场景题知识点之-SpringBoot启动流程
java·面试·springboot·源码解析·启动流程
Raink老师1 天前
【AI面试临阵磨枪-69】如何设计一个支持百万级工具的 Agent 系统?如何快速路由与选择工具?
人工智能·面试·职场和发展
Raink老师1 天前
【AI面试临阵磨枪-77】音视频 + AI:实时字幕、翻译、降噪、虚拟人、多模态对话
人工智能·面试·音视频
洛水水1 天前
【力扣100题】53.最长回文子串
算法·leetcode·职场和发展