Caffeine 源码详解:为什么它是最快的 Java 本地缓存——一次压测引发的源码考古

背景:我在给自己的三级缓存组件做压测时发现一个反直觉现象------每次命中都多做一次反序列化的实现,在高并发下反而比"直接返回缓存对象"的流行框架更稳(聚合吞吐 ~1450 万 vs ~690 万 ops/s,p99 1µs vs 427µs)。数据不合直觉,于是把两边基于 Caffeine 的本地缓存源码翻了个底朝天,最后在 Caffeine 的源码里找到了答案。这篇文章就是这个考古过程的整理------组件本身不是主角,Caffeine 才是。组件链接放在文末,正文只谈 Caffeine。

一、先摆数据:反直觉在哪

先解释两个指标(不熟悉压测术语的读者):ops/s 是每秒完成的"读一次缓存"次数(多线程聚合);p99 是把所有请求耗时排序后第 99 百分位的值------99% 的请求比它快,它代表"最倒霉的那批用户"的体验。

对比的两个实现,本地缓存层都用 Caffeine,区别只有一点:

  • 实现 A:Caffeine 里存 JSON 字符串,每次命中反序列化出新对象(Jackson,实测 ~250ns)
  • 实现 B:Caffeine 里存 Java 对象引用,命中直接返回(不反序列化,理论上更快)

单线程实测:B 的单次命中 ~150ns(mean 166ns),A ~430ns(mean 494ns)------B 快 3 倍,符合直觉。

但 16 线程以上画风突变:A 聚合吞吐持续爬到 1450 万 ops/s、p99 稳在 1µs;B 在 8 线程就到顶(~650 万),线程翻倍吞吐不动,p99 从 35µs 一路恶化到 427µs。

单次更贵的赢了,单次更便宜的输了。 差距不在反序列化,在于它们给 Caffeine 喂了不同的"过期策略"。要理解这个差异,得先弄清 Caffeine 的读路径到底做了什么。

二、Caffeine 读路径:为什么它是无锁的

Caffeine 的读操作(getIfPresent)全程不加锁,这是它快的根本原因。拆开看:

scss 复制代码
getIfPresent(key)
  └─ ConcurrentHashMap.get(key)          // 分段数组定位 bin,无锁读
      └─ node.getValue()                 // 取缓存条目
      └─ hasExpired(node)                // 过期检查(读 node 上的时间字段)
      └─ afterRead(node)                 // 唯一的"写"动作,见下文

三个值得注意的设计:

1. 读缓冲是分条的环形数组(StripedBuffer)

每次读都要"记录这次访问"(供 LRU/LFU 统计),如果多个线程往同一个队列塞,就退化成锁竞争。Caffeine 的解法是开 N 条环形数组(N ≈ CPU 核数),线程按哈希分散到不同条带,条带内部用 CAS 入队------绝大多数读操作互不干扰。条带间用填充字段隔离,避免伪共享(几个热字段挤在同一缓存行上互相打架)。

2. 访问记录是异步消费的

读缓冲只负责"记账",记账本身不做任何统计计算。等缓冲攒够了(或写操作触发),Caffeine 提交一个 maintenance 任务(跑在 ForkJoinPool 公共池)批量消化------用户线程从不为统计买单。

3. 淘汰决策也是异步的

条目满了不会当场找受害者淘汰(那要拿锁、扫队列),而是标记"需要维护",由 maintenance 任务统一处理。用户线程的最坏情况只是"多塞了一次队列"。

这套设计下,读路径上唯一的共享写,就是往读缓冲的 CAS------按理说 64 线程也不该劣化。但压测里实现 B 恰恰劣化了。缺口在过期策略。

三、过期机制:固定 TTL 与自定义 Expiry 的本质区别

Caffeine 支持三种过期:expireAfterWrite(写入后固定时长)、expireAfterAccess(访问后刷新)、自定义 Expiry(按 key/value 动态计算)。

源码层面,前两者在内部被包装成常量 Expiry ------expireAfterRead 回调返回的剩余时间是恒定值。而自定义 Expiry(以及任何按时间动态变化的策略)每次读回调返回的剩余时间都在变。

区别在 Caffeine 的时间轮(Timer Wheel):

  • Caffeine 不用优先队列管过期(堆的插入/删除是 O(log n),还有锁),而是用层级时间轮:条目按"到期时间"挂到轮子的槽位上,插入/重挂 O(1),维护任务顺时针扫槽就能批量清理。
  • 当 expireAfterRead 回调返回的新到期时间与当前挂载位置不同 ,节点必须从旧槽摘下、挂到新槽------这是一次对共享数据结构的写操作。
  • 固定 TTL:每次回调返回值相同 → 无需重挂 → 读路径保持纯读。
  • 动态策略:返回值随时间漂移 → 每次读都可能触发重挂 → 热键的条目变成几十个线程争抢的共享写点,在多核间来回弹(缓存行争用)。

我的隔离实验验证了这一点(16 线程、裸 Caffeine、1000 个键,唯一变量是过期策略):

过期策略 吞吐 尾部(max)
固定 expireAfterWrite(300s) ~5200 万 ops/s 无异常尖刺
自定义 Expiry(每次读重算剩余时间) ~4000 万 ops/s(-25%) 300ms 级尖刺

还有一个反直觉的对照:实现 A 每次命中分配 ~880 B(Jackson 反序列化),B 只分配 ~300 B------A 的 GC 压力是 B 的 6 倍,但 A 的 p99 纹丝不动。这说明在这个量级上,"每读一次共享写"的争用才是尾延迟的主导因素,对象分配/GC 不是。

顺带修正一个常见误区:很多文章说"Caffeine 快是因为用了 W-TinyLFU"------W-TinyLFU 决定的是淘汰谁 (容量压力下的命中率),而命中路径上根本没有淘汰逻辑。读写路径快靠的是无锁读 + 异步维护,W-TinyLFU 决定的是"留下来的东西对不对"。

四、W-TinyLFU:比 LRU 强在哪

淘汰策略解决的问题是:容量满了留谁。经典方案的缺陷:

  • LRU:一次批量拉取/扫表就能把真正的热点全部冲出缓存(突发污染)。
  • LFU:老热点僵死------曾经高频、如今冷清的条目赖着不走(频次没有老化)。

W-TinyLFU(论文 TinyLFU 的窗口增强版)的组合拳:

1. 频率估计用 Count-Min Sketch,且只有 4 bit

不存完整计数,而是用紧凑的 sketch:几个独立哈希函数,每个哈希定位到一段 4 bit 计数器,读频率时取所有候选中的最小值。4 bit 溢出怎么办?Caffeine 在计数到达上限时按概率重置一半的计数器(对数老化)------老热点的频次随时间衰减,解决 LFU 僵死。

2. 准入(Admission)而非全量比较

新条目不直接进主缓存,而是和主缓存 LRU 队尾的"受害者"比 sketch 频率:新来的频率不如受害者,新来的直接淘汰。一次扫表污染的热数据,因为 sketch 频率是长期累计的,根本赢不了老热点------突发污染被挡在门外。

3. Window + 分段 LRU

主缓存分 protected(80%,被再次访问晋升)和 probation(20%,待审)两段;前面还有一个 window 队列(比例自适应调整)吸收刚写入的突发流------突发先在 window 里消化,不冲击主缓存的频率统计。

实际效果:对"热点集中"的 skewed 访问,W-TinyLFU 命中率显著高于 LRU;对"突发扫表"场景,LRU 命中率崩到个位数时它还能守住。代价是 sketch 的空间开销和淘汰决策的计算------这些全在异步 maintenance 里,不碰读路径。

五、维护任务:所有脏活的集中地

过期清理、容量淘汰、读缓冲消化、sketch 衰减、时间轮扫描------全部集中在 maintenance 任务里。触发时机:写缓冲满、读缓冲满、或有"该清理"的标记。它跑在 ForkJoinPool 公共池上,一次 run 会循环处理直到干净,然后睡觉。

这个设计换来两个特性:

  • 用户线程最坏只付一次 CAS(读)或一次入队(写);
  • 清理永远批量进行,摊薄成本。

但也意味着:maintenance 线程是 Caffeine 的心脏。如果你给 Caffeine 配了极小的容量(条目频繁淘汰)或自定义 Expiry(频繁重排),maintenance 的负载会放大------压测里的尾延迟尖刺(300ms 级 max)正是重排压力传递到维护循环的表现。

六、给使用者的四条建议

  1. 能用固定 TTL 就别用自定义 Expiry 。expireAfterWrite 是读路径最快形态。确实需要按条目动态过期(比如按 value 大小、按租户差异化)再上 Expiry,并意识到"读触发的重算"在热键高并发下有争用代价。
  2. expireAfterAccess 慎用于热键。它的语义要求每次访问刷新时间------热键的刷新本身就是共享写。和上面同源。
  3. 容量设够,让热点进得了主缓存。W-TinyLFU 的准入机制会拒绝频率低的新条目,容量不足时不是"随机丢",而是"新东西进不来"------表现上像缓存"僵死",先查容量再怀疑策略。
  4. 统计开销可以接受 。recordStats 的计数器也是分条的(LongAdder 风格),打开它做容量规划的成本远小于收益------命中率才是缓存容量的第一变量。

结语

回到开头那个反直觉的压测结果,现在可以说清楚了:"每次命中多做一次反序列化"的代价是可预算的 CPU 时间(~430ns);而"每读一次重算过期"的代价是不可预算的并发争用(尾延迟放大百倍)。缓存组件选型时,比起单次操作的纳秒级差异,"读路径上有没有共享写"才是高并发场景真正该看的。

这些实验全部可复现(Testcontainers + 裸 Caffeine 隔离实验都在仓库的测试里)。背景组件:cache-kit-spring-boot-starter------一个实体元数据驱动的三级缓存(Caffeine → Redis → DB,MyBatis-Plus 零注解接入,已发布 Maven Central),压测对比的完整数据在仓库 docs/CONSISTENCY.md。

相关推荐
dadaobusi1 小时前
Trace-driven 建模(基于轨迹/踪迹的建模)
java·后端·spring
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 3 章 Bean 的全生命周期原理 Bean 的全生命周期概览 阅读笔记 9
spring boot·笔记·后端
墨家句子1 小时前
AnythingLLM 搭本地知识库:文档问答不准怎么调
linux·后端
知守观1 小时前
OBS 同名文件互相覆盖,客户拿错了别人的报告:一次文件串号的排查与重构
java·后端
特立独行的猫A1 小时前
Godot 游戏编辑器移植鸿蒙 PC:难度与可行性分析
后端·harmonyos
章鱼哥19712 小时前
DeepSeek Harness 插件开发新手教程
后端·deepseek
特立独行的猫A2 小时前
C++ 异步编程:std::future 与 std::promise 详解(含 RPC 客户端实现实践)
c++·后端
Anymous2 小时前
支付为什么要验两次签?——从渠道验签到服务间信任边界与 RSA2
后端
泡海椒2 小时前
JQuick-Excel 字段映射实战:用 MAPPING 固化 Excel 表头与业务字段契约
xml·java·开发语言·excel