文章

【笔记】Caffeine 设计与实现

【笔记】Caffeine 设计与实现

1. 这篇笔记要解决什么

Caffeine 难读,不是因为某一个算法特别神秘,而是因为它把几组不同的问题叠在了同一个缓存条目上:

  • ConcurrentHashMap 保证 key/value 访问正确;
  • 用近似的访问记录决定谁该被淘汰;
  • 用顺序队列或时间轮发现过期条目;
  • 用缓冲区把请求路径与策略维护解耦;
  • 用代码生成避免让每个 Node 携带无用字段;
  • 同一套核心还要兼容同步值和 CompletableFuture

如果一开始只盯着某个队列、锁或生成类,很容易把不同层次的问题混在一起。本文围绕一个中心问题展开:

Caffeine 如何在保证缓存数据语义正确的前提下,允许淘汰和过期策略短暂不精确,从而换取高并发吞吐?

源码核验基线是本地 checkout 70c4e3fc2(位于 v3.2.4 之后),公开 API 语义同时对照 Caffeine 3.2.1 文档。Caffeine 3.x 要求 Java 11 及以上;Java 8 需要使用 2.x,不能直接套用本文核验的 3.x 实现细节。源码会继续变化,因此正文主要使用类名和方法名作为锚点,不依赖绝对行号。

2. 先建立整体模型

Caffeine 的核心可以压缩成两套状态和一条维护流水线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
请求线程
  │
  ├─ 直接读写 data: ConcurrentHashMap<Object, Node<K, V>>
  │      负责 key/value 是否存在以及对应什么值
  │
  ├─ 读事件 → readBuffer(允许丢失)
  └─ 写事件 → writeBuffer(不能随意丢失)
                       │
                       ▼
              maintenance()
                       │ evictionLock 串行保护
                       ├─ 回放读事件
                       ├─ 回放写事件
                       ├─ 回收弱/软引用
                       ├─ 处理过期
                       ├─ 执行容量淘汰
                       └─ 调整 Window/Main 比例

这两套状态的职责和一致性要求不同:

状态主要结构负责什么一致性要求
数据状态datakey 是否存在、当前 value 是什么请求路径必须立即正确
策略状态各种 deque、FrequencySketchTimerWheel谁最近访问、谁更热门、谁应过期或淘汰允许短暂滞后和少量误差

因此,源码注释里的 best-efforteventually consistent 主要描述策略状态。它们不表示缓存可能随意返回错误的 value。

这是理解后续所有细节的总钥匙:

Caffeine 把“数据是否正确”和“策略是否瞬时精确”拆开了。前者不能退让,后者可以用近似换性能。

3. 从 API 到两种存储内核

3.1 有界和无界不是两个容量数字

Caffeine builder 最终会根据配置选择不同内核:

1
2
3
4
5
6
没有容量、过期、引用强度等维护需求,也未配置自动 refreshAfterWrite
  → UnboundedLocalCache

存在 maximumSize / maximumWeight / expiration / weak reference 等需求,
或 LoadingCache 配置了 refreshAfterWrite
  → 生成的 BoundedLocalCache 子类

UnboundedLocalCache 基本就是对 ConcurrentHashMap 的缓存语义封装。它仍可处理统计、加载和显式调用 LoadingCache.refresh(key),但不需要 W-TinyLFU、过期队列、时间轮以及完整的策略维护体系。自动 refreshAfterWrite 需要记录 write time 并在命中时检查刷新条件,因此会选择 BoundedLocalCache

这里的 unbounded 是“不由缓存策略限制容量”,不是 JVM 内存无限。它仍可能因为进程内存耗尽而失败。

BoundedLocalCache 也不能简单翻译成“设置了最大条目数的 Cache”。只要某项配置要求维护 Node 生命周期或辅助索引,就可能进入这套有状态的内核。

3.2 同步、加载和异步是外层语义

可以从两个维度理解 Caffeine 的产品形态:

1
2
是否自动加载:Cache / LoadingCache
返回普通值还是 Future:同步 Cache / AsyncCache

同步加载缓存的 get(key) 在未命中时执行 loader,并合并同 key 的并发加载。异步缓存则把 CompletableFuture<V> 作为底层存储值:第一个请求原子放入 Future,后续相同 key 的请求复用同一个 Future。

异步缓存的价值不在本地命中。内存命中本来就很快,真正需要异步的是 miss 后面的 RPC、数据库、磁盘或昂贵计算。

4. Node 为什么这么复杂

4.1 一个 Node 同时服务多套数据结构

data 的 value 不是业务 value,而是 Node<K, V>

1
2
3
4
5
6
ConcurrentHashMap 中:Node 是 key/value 记录
Window deque 中:    Node 是双向链表节点
Probation deque 中: Node 是双向链表节点
Protected deque 中: Node 是双向链表节点
write-order deque 中:Node 仍是双向链表节点
TimerWheel 中:      Node 还是时间轮链表节点

“一个 Node 出现在多套结构里”不等于“同时出现在三个容量队列里”。Window、Probation、Protected 是同一维度的互斥区域,一个 live Node 在启用容量淘汰时只属于其中一个区域;同一个 Node 可以同时再属于 write-order 或 timer-wheel 这类不同维度的结构。

这也是 Node 必须保存队列状态的原因。仅凭“它在哪条链表里”判断归属,会要求遍历或依赖外部上下文;Node 上的 queue type 能让移动和删除直接知道应该操作哪套策略结构。

4.2 为什么需要 NodeFactory

不同配置需要的 Node 字段不同:

  • 强 key 或弱 key;
  • 强 value、弱 value或软 value;
  • 是否保存 access time;
  • 是否保存 write time;
  • 是否需要 access-order 指针;
  • 是否需要 write-order 指针;
  • 是否需要 variable-order 指针;
  • 是否记录 weight。

如果写一个包含所有字段的万能 Node,最简单,却会让每一条缓存记录承担全部内存成本。缓存可能拥有数百万条记录,几个多余引用和时间戳都会被放大。

Caffeine 用代码生成得到配置特化的 Node 类型。NodeFactory 根据 builder 配置选中正确实现,并负责创建 Node、lookup key 和 reference key。

生成层级中,某个基类会同时:

1
2
extends Node<K, V>
implements NodeFactory<K, V>

同一个生成类同时继承 Node、实现 NodeFactory。Caffeine 通过它的无参构造创建并缓存一个专门的 factory 实例,再由这个 factory 调用带参数构造,创建真正携带 key/value 与策略状态的 Node 实例。这样不必再为每种组合生成一个独立 Factory 类。它牺牲了一些面向对象的纯粹性,换来更少的类型、对象和分派成本。

4.3 Node 里的 key 与 CHM 的 key

data 的逻辑形态是:

1
2
3
keyReference → Node
                 ├─ keyReference
                 └─ value

强 key 场景下,两处通常指向同一个业务 key 对象。弱 key 场景下则需要引用包装和专门的 lookup key,以实现 identity 比较与 GC 回收。

Node 中保留 key 并不是毫无意义的重复。策略队列、过期扫描和移除通知拿到的是 Node,它们需要在不反向扫描 CHM 的情况下定位 key、判断状态并执行条件删除。

5. alive → retired → dead 到底表示什么

5.1 为什么从 Map 删除后还不能立刻消失

删除一个条目时,数据状态和策略状态不会原子地一起完成:

1
2
3
1. 从 data 删除 Node
2. 把 RemovalTask 写入 writeBuffer
3. maintenance 从各策略结构摘除 Node

步骤 1 完成后,请求已经不应再命中这个条目;但在步骤 3 之前,Node 仍可能留在某条 deque 或 TimerWheel 中。维护线程也可能正在遍历它。

因此 Node 需要显式生命周期:

状态含义
alive同时属于 data 和相应策略结构
retired已退出 data,仍等待从策略结构清除
dead数据与策略结构都已清理

retired 的字面含义就是“已经退休但尚未彻底注销”。它不是一种业务 key,也不是把 CHM 中的 key 改成字符串 retired

5.2 哨兵 key 为什么没有业务歧义

强 key Node 会用内部专用对象作为 RETIRED_STRONG_KEYDEAD_STRONG_KEY。判断依赖对象 identity,而不是业务 key 的 equals 或字符串内容。

所以即使用户真的把字符串 "retired" 当作 key,也不会与哨兵冲突。哨兵的作用是把生命周期编码进已有 key 字段,避免再给每个 Node 增加一个状态字段。

这又体现了 Caffeine 的一贯取舍:用更隐晦的状态编码减少单条记录内存,而不是追求最直观的对象模型。

6. 为什么 CHM 之外还需要 evictionLock

6.1 两把锁保护的是不同不变量

Java 8 之后这套 ConcurrentHashMap 的并发控制围绕 table 中的 bin 展开。get 主要通过 volatile/内存可见性机制无锁读取;普通写在目标 bin 上协调,扩容时多个线程还可以共同迁移 table。Caffeine 3.x 虽然运行在 Java 11 及以上,但沿用的是这套从 Java 8 开始形成的 CHM 架构。

它只能保护 Map 自身的不变量,无法保护 Caffeine 额外维护的这些关系:

  • Node 在 Window、Probation、Protected 之间只能有一个归属;
  • 各区域 weight 之和必须与预算一致;
  • deque 的前后指针必须完整;
  • TimerWheel 和 write-order queue 不能残留已死亡节点;
  • 淘汰、过期和引用回收不能重复通知。

因此 evictionLock 不是 CHM 锁的重复版本。它串行保护整个策略状态机。

6.2 为什么不在每次读取时直接拿这把锁

如果命中后立即移动 LRU 节点:

1
2
3
4
5
6
lock.lock();
try {
  moveToBack(node);
} finally {
  lock.unlock();
}

所有热门读取都会争用同一把全局策略锁。底层 CHM 再并发也没有意义。

Caffeine 的处理是让请求线程只记录事实:

1
“这个 Node 刚刚被访问了”

然后由维护过程在 evictionLock 内批量解释这些事实并更新策略。

7. readBuffer 与 writeBuffer 为什么一有损一无损

7.1 readBuffer 保存的是策略提示

命中读取最终会进入 afterRead()。它尝试把 Node 放入 readBuffer,维护阶段再由 drainReadBuffer() 回放到 onAccess()

1
2
3
4
5
命中
  → readBuffer.offer(node)
  → 稍后 drain
  → 更新频率
  → 调整相应 deque 中的位置

一次读记录丢失,只会造成:

  • FrequencySketch 少计一次;
  • Node 没有及时移动到队尾;
  • 淘汰或访问过期顺序略微不准确。

key/value 本身仍在 data 中,读取结果没有变错。因此 readBuffer 在竞争或容量压力下允许丢失记录。这里的“有损”是丢策略样本,不是丢缓存数据。

7.2 writeBuffer 保存的是必须执行的状态迁移

写入 data 后,Node 还必须加入或移出相关策略结构。writeBuffer 中的 Add、Update、Removal task 承担这些状态迁移:

1
2
3
AddTask     → 把新 Node 纳入策略结构和权重统计
UpdateTask  → 更新 weight、时间和队列位置
RemovalTask → 从策略结构摘除已经退出 data 的 Node

它不是单纯维护 write LRU。即使没有 expireAfterWrite,新增、更新和删除仍然可能需要同步容量策略、引用状态等。

写任务若被永久丢弃,会造成 Node 生命周期、链表和权重账本不一致。因此 writeBuffer 不能像 readBuffer 那样把失败当作普通采样误差。写路径会调度维护,必要时还会尝试协助推进积压任务。

7.3 batch 只是结果,不是完整目的

两个 buffer 都产生批处理收益,但更重要的作用是划分并发边界:

1
2
请求路径:并发修改 data,快速记录事件
维护路径:持 evictionLock,串行修改策略结构

如果只把它们理解为“攒一批再执行,减少函数调用”,就会漏掉它们对锁竞争和一致性模型的贡献。

8. maintenance 是统一维护入口,但不是永久后台线程

8.1 maintenance 收口了哪些工作

maintenance() 在持有 evictionLock 时按顺序执行:

1
2
3
4
5
6
7
drainReadBuffer()
drainWriteBuffer()
drainKeyReferences()
drainValueReferences()
expireEntries()
evictEntries()
climb()

从策略视角看,缓存维护基本收口于此。个别请求路径仍会立即完成数据层判断和原子 Map 操作,但跨结构的批量修正由这里统一协调。

8.2 它通常在哪里执行

读写发现需要维护时会调用 scheduleDrainBuffers(),后者通常把 PerformCleanupTask 提交给 builder 配置的 Executor。默认 executor 通常是 ForkJoinPool.commonPool()

这不等于“Caffeine 永远有一条后台线程定时扫描缓存”:

  • 没有任务时不会有专属线程持续遍历;
  • 维护通常由写操作或偶发读取触发;
  • cleanUp() 可以由调用方显式执行;
  • 显式配置 Scheduler 后,可以更及时地唤醒时间过期维护。

Scheduler.systemScheduler() 基于 CompletableFuture.delayedExecutor 使用 JVM 共享的延迟调度设施,再把实际任务交给配置的 executor。它不是 Caffeine 私有的常驻调度线程。这里讨论的是 Caffeine 3.x;若应用仍运行在 Java 8,必须使用 Caffeine 2.x,并按对应版本重新核验 Scheduler 行为。

8.3 commonPool 会不会积压

有可能。风险不是 Caffeine 创建无限维护线程,而是默认 executor 与应用中的其他 common-pool 任务共享资源:

  • 其他长耗时或阻塞任务可能延迟维护;
  • 移除监听器、异步加载和维护任务可能彼此影响;
  • 维护延迟会让过期实体和策略状态在物理上滞留更久。

Caffeine 用 drain status 合并重复调度,避免每次读写都无界提交一个 cleanup task。但合并不能解决共享线程池本身被阻塞的问题。

高负载场景应根据应用的执行模型评估独立 executor,并确保异步 loader 自己也有并发上限。AsyncCache 的 same-key single-flight 只能合并相同 key,不能阻止大量不同 key 同时压向下游。

9. W-TinyLFU:三条 LRU 队列加一个频率准入器

9.1 先分清“驻留位置”和“历史频率”

配置 maximumSizemaximumWeight 后,全部参与容量淘汰的 live Node 会被分配到三个互斥区域之一:

1
2
3
4
5
6
7
Window:新条目的短期观察区,内部按 LRU 排列

Main:长期空间
  ├─ Probation:观察区,内部按 LRU 排列
  └─ Protected:保护区,内部按 LRU 排列

FrequencySketch:独立保存近期历史访问频率的近似值

这三条 deque 不是三个完整缓存副本,也不是一个 Node 同时存在三处。它们共同分配固定的总容量预算。

可以把算法理解为:

三条 LRU 队列负责“当前条目放在哪里”,FrequencySketch 负责“候选者是否值得留下”。

因此 W-TinyLFU 不是简单 LFU,也不是每次都找全局最低频条目。

9.2 Window 里的条目是高频还是低频

都可能。Window 表达的是“新近进入”,不是频率等级。

一个刚进入缓存的新热点没有历史频率。如果直接拿它与长期热门条目比较,它可能还没来得及积累计数就被拒绝。Window 给新条目一个短暂驻留期,使突发热点有机会被再次访问。

Window 超预算后,队头条目成为 candidate。它不是因为“已经证明低频”才被挤出,只是因为它在 Window 中最久没有再次排到后面。

9.3 candidate 和 victim 怎么竞争

Window 的 candidate 会尝试进入 Main。Main 中的 victim 通常来自 Probation 的淘汰端:

1
2
3
4
5
candidate frequency > victim frequency
  → 接纳 candidate,淘汰 victim

candidate frequency <= victim frequency
  → 拒绝 candidate,保留 victim

admit() 比较 FrequencySketch 给出的近似频率。源码还保留很小的随机接纳概率,用来缓解攻击者通过哈希碰撞污染 sketch 后让缓存永远无法换血的问题。

源码为了统一 Main 的淘汰流程,会先把 Window candidate 移到 Probation 的 MRU 端,再执行准入比较。candidate 赢得比较就留在 Probation;输掉则立即从 Probation 淘汰。因此,“进入 Probation”是实现上的先行状态迁移,“获得 Main 的长期保留资格”才取决于准入结果。

9.4 Probation 与 Protected 为什么还要分开

新进入 Main 的条目位于 Probation。它在 Probation 再次被访问后,会晋升到 Protected:

1
2
Probation hit
  → move to Protected tail

Protected 也有容量预算。超预算时,它的 LRU 节点会降级回 Probation,而不是立刻淘汰:

1
2
3
Protected overflow
  → demote oldest protected entry
  → Probation tail

这形成 Segmented LRU:Probation 过滤只偶尔访问一次的条目,Protected 给反复访问的稳定热点更强保护。

9.5 三个区域各自有容量吗

有,但容量以 weight 预算表达,不一定等于节点数量:

  • windowMaximum:Window 预算;
  • mainProtectedMaximum:Protected 预算;
  • Main 的其余部分由 Probation 使用;
  • 三者合起来受全局 maximum 约束。

maximumSize 可以看成每个 Node weight 为 1。maximumWeight + weigher 则让不同 Node 消耗不同预算。weight 只表示容量成本,不直接作为淘汰优先级;候选与受害者的准入比较仍看访问频率。

9.6 为什么还要动态调整 Window

不同负载适合不同 Window 比例:

  • 突发、近期性强的负载需要更大 Window;
  • 长期稳定热点需要更大 Main。

Caffeine 的 hill climbing 根据采样周期内的命中率变化调整 Window/Main 的预算比例。它调整的是固定总容量内部的配额,不是改变用户设置的最大容量。

10. FrequencySketch 为什么像 Bloom Filter 又不是 Bloom Filter

10.1 它解决的是“保留多少历史”

如果给每个 key 保存精确访问次数:

  • 需要与 key 数量线性相关的额外空间;
  • 每次命中都要竞争更新精确计数;
  • 曾经热门但已经冷却的 key 会永久占据优势。

FrequencySketch 使用 Count-Min Sketch 的思路,用固定大小的压缩计数器近似回答:

1
这个 key 最近大概访问过几次?

这些计数器服务于 TinyLFU 的频率判断,但不构成一套传统 LFU 淘汰队列。Caffeine 不会扫描全部条目,再直接淘汰计数最低的那个。计数值主要用在准入阶段:

1
2
3
4
Window 中被挤出的 candidate
  → 查询 candidate 的近似频率
  → 查询 Main Probation 中 victim 的近似频率
  → 比较两者,决定谁获得 Main 的位置

因此,三条 LRU deque 与 FrequencySketch 的分工不同:deque 维护当前驻留区域和新近顺序,计数器提供近期访问频率,TinyLFU 再利用频率比较完成准入决策。

它与 Bloom Filter 的相似点是都通过多个哈希位置换取空间效率,并允许哈希碰撞。区别是 Bloom Filter 估计“是否存在”,FrequencySketch 估计经过饱和与衰减的近期频率。哈希碰撞倾向于把计数抬高,取四路最小值用于减轻这种高估;由于 Caffeine 还会丢弃部分读样本、限制计数上限并周期性减半,所以这里不应把结果理解成严格的数学上界或下界。

10.2 long 数组、block、slot 和计数器是什么关系

Caffeine 把多个 4-bit 饱和计数器打包在 long[] table 中:

1
2
一个 long = 64 bit = 16 个 4-bit counter
counter 取值范围 = 0..15

理解源码时不要把 table 想成普通二维矩阵。一次查询先由 hash 选择一个 block,再通过四组混合后的 hash 在这个 block 中找到四个 counter 位置。

几个术语可以这样对应:

术语含义
table index选中了 long[] 中哪个机器字
slot选中了该 long 内哪个 4-bit counter
block为同一个 key 的多路计数提供局部的一组机器字
frequency四个 counter 的最小值

取最小值是 Count-Min Sketch 的关键:碰撞只会把计数抬高,不会把某一路变低;四路中的最小值通常比任意一路更接近真实频率。

10.3 为什么实现看起来特别复杂

如果只追求易读,可以写成四个独立 int 数组和四次普通取模。但 Caffeine 同时追求:

  • 极小的每 key 历史成本;
  • 让一次访问涉及的计数器集中在少数 cache line;
  • 用位运算并行定位和更新 packed counter;
  • 让计数饱和,避免无限增长;
  • 定期把全部计数减半,遗忘陈旧历史。

复杂度主要来自存储布局和 CPU 局部性优化,不是 TinyLFU 的决策规则本身复杂。

计数减半后,过去的热点会逐渐失去优势。这正是名称中 Tiny 的另一层含义:它保存的是紧凑、衰减的历史摘要,而不是完整访问日志。

11. 过期为什么有多套结构

11.1 expiration、eviction 和 refresh 不是一回事

机制触发原因读取旧值结果
容量淘汰超过 size/weight 预算淘汰后不可见为其他条目腾空间
时间过期access/write/自定义期限到达到期后不可见等待物理清理
refresh写入后达到刷新期限且发生合适访问通常仍返回旧值异步加载成功后替换

refreshAfterWrite 不是 TTL。达到 refresh 时间不会让旧值立即不可见。第一个发现条目可刷新的请求会同步调用 AsyncCacheLoader.asyncReload 取得 Future;若 Future 尚未完成,本次请求继续返回旧值,刷新失败时通常也保留旧值。默认 loader 通常使用配置的 executor 执行加载,自定义异步 loader 则可以采用自己的执行模型。

11.2 固定 access/write 过期为什么只看队头

expireAfterWrite 使用 write-order deque。越早写入的条目越早到期,因此从队头开始检查即可;遇到尚未过期的 Node 后,后面的更年轻条目通常也不用继续检查。

expireAfterAccess 需要访问顺序。启用容量淘汰时,Window、Probation、Protected 三条 deque 本来就共同覆盖全部 live Node,并各自按访问顺序排列,因此过期扫描必须检查三条队列的头部。

这不是“额外还缺一条全局 access queue”,而是复用容量策略已经维护的三条访问顺序链。若没有容量淘汰但需要 access expiration,生成配置可以提供相应的访问顺序结构。

11.3 可变过期为什么需要 TimerWheel

自定义 Expiry 允许每个条目拥有不同期限:

1
2
A 先写入,1 小时后过期
B 后写入,5 秒后过期

此时写入顺序无法推出过期顺序。TimerWheel 按预计过期时间把 Node 放入分层槽位,维护时推进时间轮并处理到期槽,避免每次扫描全部条目或维护昂贵的全局精确排序。

所谓 O(1) 更准确地理解为单次调度、移动和渐进维护具有常数级或均摊常数级成本,不是所有到期条目都能在没有任何触发的情况下凭空准时删除。

12. 加载、刷新与 AsyncCache

12.1 同 key 加载合并解决什么

热点 key 失效时,如果每个 miss 都独立访问下游,会形成缓存击穿。加载缓存需要把状态从“没有值”扩展成:

1
没有值 / 正在加载 / 已有值 / 加载失败

同步 Caffeine 依赖 Map 原子操作协调创建与加载。现代 CHM 的 compute 围绕目标 bin 保证原子性,因此慢同步 loader 可能延长该 bin 上其他更新的等待时间。这里的 bin 是 CHM table 的一个槽及其链表或树结构,不等同于 Guava 的 Segment。

Guava LocalCache 以 Segment 为显式并发分区,并用 LoadingValueReference 表达加载占位;Caffeine 则建立在 Java 8 之后 CHM 的 bin 级协调之上。两者都能做 single-flight,但并发边界和占位表达不同。

12.2 AsyncCache 为什么把 Future 当 value

异步模式的底层近似为:

1
LocalCache<K, CompletableFuture<V>>

第一次 miss 快速把 Future 放进 CHM,真正加载在 executor 或异步客户端中继续。其他请求得到同一个 Future,可以挂接回调而不必阻塞平台线程。

这个选择很实用:

  • Future 天然是加载占位符;
  • 同 key 请求自然合并;
  • 淘汰、过期和策略内核可以继续复用。

但它也带来明显的抽象债务:

  • isAsync 分支渗入同步内核;
  • 未完成 Future 需要特殊时间语义;
  • Future 完成后要重新计算 weight 和 expiration;
  • 同步视图必须定义如何观察未完成或失败的 Future;
  • weak/soft value 等能力无法直接正交组合。

所以更准确的评价不是“随手在同步 Cache 外套了一层异步 API”,而是:

Future-as-value 是一个成立的并发模型;为了复用高性能同步内核,Caffeine 接受了类型和生命周期表达上的补丁感。

12.3 什么时候值得用 AsyncCache

适合:

  • 应用运行在 Netty、WebFlux 等不能阻塞事件循环的模型中;
  • loader 本身返回 Future 或 CompletionStage;
  • miss 会调用 RPC、数据库、Redis 或磁盘;
  • 需要并行组合多个 key 的加载;
  • 希望 Future 占位尽快退出 CHM 原子区间。

不适合仅仅因为“异步听起来更快”而使用。若只是本地 Map 命中、loader 很短或整个应用都是同步阻塞模型,普通 Cache 通常更简单。

Async 也不等于背压。大量不同 key 同时 miss,仍可能制造海量 Future 和下游请求,必须另外配置有界 executor、连接池、超时和并发限制。

13. 把一次访问串起来

13.1 命中读取

1
2
3
4
5
6
7
get(key)
  → data.get(lookupKey)
  → 检查 Node 是否 alive、value 是否可见、是否过期
  → 返回 value
  → afterRead(node)
      → 尝试写 readBuffer
      → 必要时 scheduleDrainBuffers()

正确性判断发生在请求路径,策略更新可以延后。

13.2 新增或更新

1
2
3
4
5
6
7
put / compute
  → 在 data 上完成原子 Map 操作
  → 创建或更新 Node
  → 把 AddTask / UpdateTask 写入 writeBuffer
  → 调度 maintenance
  → 在 evictionLock 内更新各策略结构
  → 过期或超预算时条件删除 data 中的对应 Node

这里必须使用条件式删除或替换。维护阶段看到的 Node 可能已经过时,不能因为旧任务晚到就误删并发写入的新值。

13.3 删除

1
2
3
4
5
从 data 条件删除
  → Node: alive → retired
  → RemovalTask 进入 writeBuffer
  → maintenance 从 deque / TimerWheel 摘除
  → Node: retired → dead

这条状态链把 Map 的即时正确性与策略结构的延迟清理连接起来。

14. 与 Guava Cache 的关键差异

两者解决的业务问题相似:并发存储、加载、容量限制、时间过期、引用回收和通知。真正不同的是内部并发与淘汰模型:

维度Guava CacheCaffeine
主存储自有 Segment + tableJDK ConcurrentHashMap
写并发边界Segment lockCHM bin + 策略单锁
访问策略Segment 内近似 LRUWindow TinyLFU
读事件缓冲recencyQueue有界 striped read buffer
写策略维护Segment 锁内搭便车writeBuffer + maintenance()
可变过期无同等核心结构分层 TimerWheel
异步值模型同步加载占位为主一等 AsyncCache / Future-as-value
类型特化手写 Entry 组合Node 与 Cache 子类代码生成

在没有配置 Scheduler 时,两者都高度依赖访问触发维护;所以不能只用“Guava 被动、Caffeine 主动”概括行为差异。Caffeine 的主要提升来自更现代的 CHM 基础、解耦的策略维护、W-TinyLFU 命中率以及更丰富的过期和异步能力。这里比较的是本文核验的 Caffeine 3.x;若要讨论 Java 8,必须切换到 Caffeine 2.x 源码基线。

15. 最容易混淆的结论

  1. evictionLock 不保护 CHM,它保护的是跨 deque、weight、TimerWheel 等策略不变量。
  2. readBuffer 有损,丢的是访问样本;writeBuffer 无损,维护的是 Node 生命周期和策略账本。
  3. Window 表示新近性,不表示低频;从 Window 出来的 candidate 仍要与 Main victim 竞争。
  4. 三条容量队列共同覆盖参与容量策略的 live Node;一个 Node 在该维度只属于一个区域。
  5. FrequencySketch 不保存每个 key 的精确次数,而是用 packed counter 保存会衰减的近似历史。
  6. retired 不是一个业务 key,而是 Node 已退出 Map、尚未退出其他策略结构的中间态。
  7. expiration 让旧值不可见,refresh 通常继续返回旧值并异步更新,两者语义不同。
  8. cleanUp() 是显式推进维护,不代表平时存在一条永久专属清理线程。
  9. Scheduler 负责更及时地唤醒,Executor 承担实际任务;Caffeine 3.x 要求 Java 11 及以上,Java 8 应使用并单独核验 2.x。
  10. AsyncCache 的命中本身不需要异步,价值主要在 miss 后的加载链路和 Future 占位。

16. 一句话心智模型

Caffeine 用 ConcurrentHashMap 维护必须立即正确的数据状态,用缓冲区把访问事件交给 maintenance() 批量处理,再以 Node 状态机、三段访问队列、FrequencySketch、顺序队列和 TimerWheel 维护允许短暂滞后的策略状态。

继续阅读源码时,可以始终追问三个问题:

  1. 当前代码修改的是数据状态,还是策略状态?
  2. 这个变化必须立刻精确,还是可以经由 buffer 延迟回放?
  3. Node 同时被哪些结构引用,它正处于 alive、retired 还是 dead?

只要这三点没有混淆,BoundedLocalCache 中看似纠缠的锁、队列、哨兵和状态迁移就能重新落回同一套设计逻辑。

17. 源码复习索引

  • Caffeine:builder 配置、默认 executor、Scheduler 和公开行为边界。
  • LocalCacheFactory:根据配置选择并实例化生成的有界缓存类型。
  • BoundedLocalCache:数据状态与策略状态协作的核心。
  • UnboundedLocalCache:不需要策略维护时的简化路径。
  • Node / NodeFactory:条目能力、生命周期和配置特化。
  • BoundedLocalCache.afterRead:命中后的有损事件记录。
  • BoundedLocalCache.scheduleDrainBuffers:维护任务的合并与调度。
  • BoundedLocalCache.maintenance:策略维护总入口。
  • BoundedLocalCache.evictEntries / admit:Window candidate 与 Main victim 的竞争。
  • BoundedLocalCache.onAccess:三条访问队列之间的移动。
  • FrequencySketch.frequency / increment:近似频率查询与 packed counter 更新。
  • TimerWheel:可变过期的分层调度。
  • LocalAsyncCache:Future-as-value、single-flight 和同步视图适配。
本文由作者按照 CC BY 4.0 进行授权