【笔记】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 比例
这两套状态的职责和一致性要求不同:
| 状态 | 主要结构 | 负责什么 | 一致性要求 |
|---|---|---|---|
| 数据状态 | data | key 是否存在、当前 value 是什么 | 请求路径必须立即正确 |
| 策略状态 | 各种 deque、FrequencySketch、TimerWheel | 谁最近访问、谁更热门、谁应过期或淘汰 | 允许短暂滞后和少量误差 |
因此,源码注释里的 best-effort 和 eventually 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_KEY 和 DEAD_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 先分清“驻留位置”和“历史频率”
配置 maximumSize 或 maximumWeight 后,全部参与容量淘汰的 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 Cache | Caffeine |
|---|---|---|
| 主存储 | 自有 Segment + table | JDK ConcurrentHashMap |
| 写并发边界 | Segment lock | CHM bin + 策略单锁 |
| 访问策略 | Segment 内近似 LRU | Window 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. 最容易混淆的结论
evictionLock不保护 CHM,它保护的是跨 deque、weight、TimerWheel 等策略不变量。- readBuffer 有损,丢的是访问样本;writeBuffer 无损,维护的是 Node 生命周期和策略账本。
- Window 表示新近性,不表示低频;从 Window 出来的 candidate 仍要与 Main victim 竞争。
- 三条容量队列共同覆盖参与容量策略的 live Node;一个 Node 在该维度只属于一个区域。
FrequencySketch不保存每个 key 的精确次数,而是用 packed counter 保存会衰减的近似历史。retired不是一个业务 key,而是 Node 已退出 Map、尚未退出其他策略结构的中间态。- expiration 让旧值不可见,refresh 通常继续返回旧值并异步更新,两者语义不同。
cleanUp()是显式推进维护,不代表平时存在一条永久专属清理线程。- Scheduler 负责更及时地唤醒,Executor 承担实际任务;Caffeine 3.x 要求 Java 11 及以上,Java 8 应使用并单独核验 2.x。
- AsyncCache 的命中本身不需要异步,价值主要在 miss 后的加载链路和 Future 占位。
16. 一句话心智模型
Caffeine 用
ConcurrentHashMap维护必须立即正确的数据状态,用缓冲区把访问事件交给maintenance()批量处理,再以 Node 状态机、三段访问队列、FrequencySketch、顺序队列和 TimerWheel 维护允许短暂滞后的策略状态。
继续阅读源码时,可以始终追问三个问题:
- 当前代码修改的是数据状态,还是策略状态?
- 这个变化必须立刻精确,还是可以经由 buffer 延迟回放?
- 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 和同步视图适配。