【笔记】Guava Cache 设计与实现
1. 要搞清楚什么
这篇笔记试图回答三个问题:
- Guava Cache 要解决哪些缓存问题?
LocalCache如何把并发存储、过期、驱逐、引用回收和自动加载组合起来?- 这些设计带来了哪些边界和代价?
核验基线是 Guava v33.2.1:
源码会继续变化,因此正文使用类名和方法名作为锚点,不依赖绝对行号。
2. 从缓存问题得到能力清单
缓存用空间换时间:把计算或获取成本较高的结果暂存在内存中,后续访问直接复用。
这个收益同时带来四组矛盾:
| 矛盾 | 需要解决的问题 |
|---|---|
| 访问要快,但内存有限 | 限制容量,并决定淘汰谁 |
| 结果可复用,但会变旧 | 判断失效,并清理过期数据 |
| 多线程共享,但不能互相破坏 | 保证并发读写和加载的正确性 |
| 缓存需要持有对象,但不能无限阻碍 GC | 支持弱引用、软引用及引用回收 |
在这些基础上,还需要处理缓存未命中后的加载,以及运行状态的观测。由此得到六组能力:
- 并发存储
- 容量驱逐
- 时间过期
- 自动加载与刷新
- 引用回收
- 统计、视图和移除通知
下面先建立 LocalCache 的整体结构,再沿着这些能力逐层展开。
3. LocalCache 的整体结构
LocalCache 本身实现 ConcurrentMap。LocalManualCache 和 LocalLoadingCache 在它外面补充 Cache、LoadingCache 的接口语义。
内部结构可以先压缩成这张图:
1
2
3
4
5
6
7
8
9
10
11
12
LocalManualCache / LocalLoadingCache
→ LocalCache
→ Segment[]
→ AtomicReferenceArray<ReferenceEntry>
→ ReferenceEntry
→ key、hash、next
→ accessTime、writeTime
→ ValueReference
→ accessQueue
→ writeQueue
→ recencyQueue
→ key/value ReferenceQueue
这里有三层关键关系:
LocalCache负责把请求路由到某个Segment。Segment是并发控制和维护工作的基本单位,拥有自己的哈希表、计数、权重和队列。ReferenceEntry保存键和维护元数据,ValueReference表示值的持有方式或加载状态。
后面的分段锁、过期队列、引用回收和加载占位,都建立在这套结构之上。
4. 并发存储与键值语义
4.1 分段锁把写竞争限制在局部
LocalCache 持有 Segment[],每个 Segment 继承 ReentrantLock。哈希高位选择 Segment,哈希低位在 Segment 内寻找桶:
1
segments[(hash >>> segmentShift) & segmentMask]
Segment 数量和每个 Segment 的 table 长度都保持为 2 的幂,因此 & (length - 1) 可以完成范围映射。路由时先右移再取 mask,使用 hash 高位选择 Segment;进入 Segment 后则用 hash & (table.length() - 1) 的低位选择桶。这样同一个 hash 的不同位段分别承担两级寻址。
一次写操作只锁住目标 Segment。不同 Segment 的写可以并行,同一 Segment 内的表、队列、计数和权重由同一把锁保护。
分段也带来代价:
- 容量和维护状态按 Segment 管理,热点分布不均时可能提前驱逐。
- 驱逐顺序是 Segment 内的近似 LRU,不是全局严格 LRU。
- Segment 越多,独立数据结构和管理成本越高。
maximumSize 或 maximumWeight 的总预算会按余数分配到各 Segment,分配后的配额总和仍等于全局上限。问题不是缓存会超过 maximumSize,而是某个 Segment 达到局部配额后,不能向其他 Segment 借用闲置容量。
4.2 读路径依赖可见性,不等于完全无同步
大多数命中读取不获取 Segment 写锁,但会读取 volatile 字段和原子数组。写路径在锁内完成结构修改,再通过 volatile 写等方式把结果发布给后续读取。
源码中常把 volatile 字段先读入局部变量,完成计算后再写回。这是一种减少重复 volatile 访问的实现技巧,但它不是完整的“正确性模型”。真正的并发正确性来自锁、volatile、原子容器和对象状态转换共同形成的 happens-before 关系。
4.3 Equivalence 统一键值比较语义
普通强引用键使用 equals 语义。调用 weakKeys() 后,键改用 identity,也就是 ==。
这不是单纯的性能优化,而是弱引用语义的一部分:两个内容相等但不是同一对象的 key,不应因为其中一个对象尚未被 GC,就暂时命中另一个对象创建的缓存项。
类似地,weakValues() 和 softValues() 会让值比较采用 identity。Guava 通过 Equivalence 把这些差异收敛在统一比较入口中。
4.4 Entry 类型按实际维护能力组合
不是每个缓存条目都需要保存访问时间、写入时间和对应的队列指针。例如,只配置 expireAfterWrite 时,entry 不需要 access queue 的前后指针。
EntryFactory 根据三组条件选择具体的 ReferenceEntry 实现:
- key 使用强引用还是弱引用
- 是否需要 access 元数据
- 是否需要 write 元数据
这里的判断入口是 usesAccessEntries() 和 usesWriteEntries(),不等同于是否真的创建对应队列。例如,refreshAfterWrite 需要记录 writeTime,因此会选择带 write 元数据的 entry;但只有 expireAfterWrite 才需要 writeQueue。
因此源码中既有 StrongEntry、StrongAccessEntry、StrongWriteEntry,也有同时携带两组元数据的 StrongAccessWriteEntry;弱引用 key 也有一组对称实现。
这是一种用类型组合消除无用字段的取舍。它减少了每个 entry 的内存占用,代价是类型数量和复制逻辑明显增加。EntryFactory.copyEntry 也必须按具体类型迁移相应的队列关系。
4.5 扩容不能破坏正在进行的无锁读取
Segment 内的哈希表也会扩容。由于读路径可能仍在遍历旧表,Segment.expand 不能原地修改旧链的 next,也不能提前清空旧桶。它会创建新表,并在新表中重建需要变化的链。
容量翻倍且始终为 2 的幂,因此旧桶中的 entry 在新表中只有两个去向:留在原下标,或者移动 oldCapacity。Guava 从链尾向前识别“新下标相同的连续尾段”:
- 尾段的
next关系不需要改变,可以直接复用。 - 尾段之前的 entry 通过
copyEntry复制到新链。 - 最后以
volatile table写发布新表;已经拿到旧表的读线程仍可沿完整旧链继续读取。
源码注释估算,在默认负载因子下,扩容时平均只有约六分之一的节点需要复制。这个优化依赖两个前提:容量按 2 倍扩张,以及 entry 的链式关系不会被原地破坏。
5. 维护模型:让清理搭便车
LocalCache 不创建专属定时线程扫描缓存。过期清理、引用队列回收和移除通知等维护工作,主要发生在写操作、部分读操作或显式 cleanUp() 中。
可以把这种取向概括为 piggyback:维护工作搭便车在业务访问上。
它成立的前提是把“访问语义”和“物理清理”分开。例如,过期条目即使尚未从内部表中删除,也不会继续对正常读取可见;延迟清理影响的是内存占用和通知时机,不应改变读取结果。
5.1 元数据是维护的基础
为了支持过期和容量限制,entry 或 value reference 会保存:
accessTime:最近访问时间writeTime:最近写入时间weight:条目权重
时间来自可注入的 Ticker。系统实现读取 System.nanoTime(),表示相对某个固定起点经过了多久,而不是当前日期时间。
过期判断关心的是时间间隔。若使用墙上时间,NTP 校时或人工修改系统时间可能让时间倒退或突然跳跃;单调递增的 elapsed time 更符合这里的语义。测试还可以替换 Ticker,在不真实等待的情况下推进时间并验证过期行为。
5.2 三条队列承担不同职责
每个 Segment 按配置维护三类队列:
writeQueue:按写入顺序排列,用于expireAfterWrite。accessQueue:按访问顺序排列,用于expireAfterAccess和容量驱逐。recencyQueue:读路径无锁记录近期访问,后续在持锁维护时回灌到accessQueue。
recencyQueue 是共享的 ConcurrentLinkedQueue,不是 ThreadLocal。它解决的是“读路径不拿写锁,但 LRU 顺序仍要更新”的矛盾。
这种批量回灌意味着访问顺序可以短暂滞后,所以 Guava 提供的是近似 LRU,而不是每次读取后立即得到全局精确顺序。
5.3 时间过期:先保证不可见,再择机删除
isExpired 根据配置检查:
now - writeTime是否达到或超过expireAfterWritenow - accessTime是否达到或超过expireAfterAccess
读路径通过 getLiveEntry 判断条目是否仍然有效。条目已过期时,读取直接按未命中处理;能否立即获得锁并完成物理删除,不影响这个结果。
getLiveEntry 发现过期后会调用 tryExpireEntries。后者只在 tryLock() 成功时执行清理,拿不到锁就放弃本次物理删除。此时读路径仍返回 null,因此锁竞争只会推迟回收,不会让过期值重新可见。
真正清理时,expireEntries 从 writeQueue 或 accessQueue 队头开始处理。队列已经按相关时间排序,因此遇到第一个未过期条目即可停止,不需要扫描整张哈希表。
5.4 容量驱逐:顺序决定候选,权重决定是否超限
每个 Segment 维护 totalWeight 和自己的容量配额。写入新值后,evictEntries 检查是否超过配额,并从 accessQueue 前端选择驱逐候选。
需要区分两个概念:
- 访问顺序决定优先驱逐谁。
- 权重决定当前是否超过容量,以及需要驱逐到什么程度。
官方 API 也明确说明,weight 用于判断容量,而不负责决定下一个被驱逐的条目。权重为 0 的条目不参与基于容量的驱逐。
5.5 引用回收:GC 只发出信号,缓存仍要清理残留
weakKeys()、weakValues() 和 softValues() 让键或值可以被 GC 回收。GC 回收对象后,对应引用进入 ReferenceQueue。
drainReferenceQueues 在维护过程中消费这些通知,并从 Segment 中移除已经失去 key 或 value 的条目。尚未物理删除的回收条目可能仍被 size() 计数,但不会继续对正常读写可见。
这里不要与 Guava base 包的 FinalizableReferenceQueue 混淆。后者为用户自定义的 finalizable reference 启动清理线程;LocalCache 没有使用它,而是为各 Segment 维护自己的 key/value ReferenceQueue,继续遵循访问或 cleanUp() 触发的搭便车清理模型。
5.6 移除通知默认在调用线程处理
条目被替换、显式删除、过期、容量驱逐或 GC 回收时,Guava 会把 RemovalNotification 放入待处理队列。runUnlockedCleanup 在退出 Segment 锁后调用 listener。
这里的队列用于把 listener 与内部锁解耦,并不代表默认创建后台线程。默认 listener 仍由触发清理的调用线程执行。需要异步通知时,应显式使用异步包装并提供 Executor。
5.7 一次写入如何串起多种维护
以普通 put 为例,分散在前文的维护动作会按下面的顺序咬合:
1
2
3
4
5
6
7
8
9
10
11
Segment.put(持锁)
→ preWriteCleanup
→ drainReferenceQueues
→ expireEntries
→ setValue / recordWrite
→ 更新时间、队列和 totalWeight
→ evictEntries
→ 超出局部配额时按 accessQueue 选择候选
Segment.put(解锁)
→ postWriteCleanup
→ processPendingNotifications
这个顺序先移除已经回收或过期的条目,再登记新值的权重,最后判断是否仍超出容量。不同原因通过各自的判断入口进入统一移除逻辑,最终都在退出 Segment 锁后处理通知。
6. 自动加载与刷新
6.1 两种缓存包装对应两种加载入口
LocalManualCache 不自动加载。调用方通过 get(key, Callable) 提供一次性加载逻辑。
LocalLoadingCache 持有 CacheLoader。get(key) 未命中时,LocalCache 使用 loader 计算结果。
6.2 首次并发 miss:一个线程加载,其他线程等待
首次 miss 时,Segment 在锁内为 key 安装 LoadingValueReference。它相当于“正在加载”的占位状态,并持有一个 future。
第一个线程离开锁后执行 loader。其他线程看到同一个首次加载占位时,不会重复调用 loader,而是等待同一个 future。
因此,并发 miss 去重的核心不是“所有线程都能读旧值”,而是:
1
2
3
4
锁内安装唯一占位
→ 锁外执行加载
→ future 发布结果
→ 等待线程共享同一结果
ValueReference.isLoading() 与 isActive() 是两个不同维度:
| 状态 | isLoading | isActive | 含义 |
|---|---|---|---|
UNSET | false | false | 尚无可用值 |
首次加载的 LoadingValueReference | true | false | 正在加载,但没有旧值 |
正常 refresh 的 LoadingValueReference | true | true | 正在加载,同时保留 active 旧值 |
| refresh 期间旧值被移除 | true | false | 加载仍在继续,但 oldValue 已被清为 UNSET |
| 普通值引用 | false | true | 已有可用值 |
这个区分让同一个 loading 占位同时表达“首次加载”和“带旧值刷新”,后续删除、失败恢复和统计逻辑可以据此决定是否仍有一个应被计数、通知或继续提供的旧值。
加载结果的发布顺序也经过专门处理。正常首次加载会先 set(newValue) 完成 futureValue,再返回这个 future;reload 返回另一个 future 时,loadFuture 用 transform 保证先把结果写入当前 LoadingValueReference,再让转换后的 future 完成。若 future 已被并发 put 提前完成,则保留手工写入的结果,并让当前加载走后续的冲突核对。这样等待同一占位的线程不会先观察到“加载已完成”,却还拿不到占位中的结果。
6.3 refresh:有旧值时继续提供旧值
refresh 与首次加载不同。刷新开始时,LoadingValueReference.oldValue 保存原值。
如果 reload 尚未完成,其他读取可以继续得到旧值。刷新成功后,新值替换旧值;没有并发删除或覆盖时,刷新失败会恢复并继续保留旧值。
CacheLoader.reload 的默认实现会同步调用 load,再返回一个已经完成的 future。因此 Guava 不保证 refresh 一定异步:
- 使用默认
reload时,触发刷新的读取可能在当前线程完成重新加载。 - 自定义
reload返回未完成的 future 时,触发线程可以先返回旧值,加载在调用方提供的执行环境中继续。
CacheLoader.asyncReloading(loader, executor) 会把被包装 loader 的 reload 调用提交给指定 Executor。若被包装的是默认同步 reload,其内部的 load 也就随之在 Executor 中执行。
refreshAfterWrite 表示条目在访问时达到刷新条件后可以触发刷新,不代表 Guava 创建定时线程主动扫描和刷新所有条目。
6.4 在途加载与显式修改的竞争
loader 必须在 Segment 锁外执行,否则一次慢 IO 会阻塞整个 Segment。代价是从发起加载到结果返回之间,其他线程可以 put、invalidate 或触发清理。Guava 不会取消 loader,而是在写回阶段重新加锁并核对当前的 ValueReference。
需要分开看三种情况:
- 并发
put:setValue用手工写入的新值替换 loading reference,并通过notifyNewValue(newValue)让等待当前加载的读取返回这个新值。旧 loader 完成后,storeLoadedValue发现占位已被替换,会丢弃加载结果,避免覆盖更新的值。 - 首次加载时
invalidate:此时 loading reference 尚未 active,也没有可移除的旧值,remove路径直接返回。它不会取消在途加载;加载完成后结果仍会进入缓存。 - refresh 时
invalidate:refresh 的 loading reference 仍持有 active 旧值。删除会移除旧值并把oldValue置为UNSET,但不会完成或取消futureValue。reload 完成后,storeLoadedValue仍可把新值写回缓存。
因此,invalidate 的精确语义是丢弃调用当下可见的缓存值,不是给在途加载建立“结果不得再写入”的墓碑。若业务需要“删除后绝不被旧请求重新填充”,必须在 loader 外增加版本号、代次或其他业务级失效协议。
这里也修正一个容易误读的细节:notifyNewValue(null) 不会唤醒等待线程。源码明确把它解释为“pending load 被移除,延迟通知直到加载完成”;它只清除 refresh 保存的旧值。
6.5 加载失败不会成为缓存值
加载成功时,LoadingValueReference.futureValue 发布结果并唤醒等待线程,加载路径随后通过 getAndRecordStats 进入 storeLoadedValue。首次加载由 loadSync 直接执行这一步;refresh 则由 loadAsync 注册的 listener 在 future 完成后执行。结果发布和写入缓存相互关联,但不是同一个动作。
加载失败时,异常通过 future 传播给等待线程,加载占位被移除或恢复旧值。下次访问仍可以重新加载。Guava 不会自动把异常缓存成负结果;如果业务需要防止持续穿透,需要在 loader 或外层策略中处理。
6.6 getAll 的批量加载与降级
LoadingCache.getAll(keys) 先逐项读取已命中值,再把去重后的缺失 key 交给 CacheLoader.loadAll。重写 loadAll 的价值在于把多个数据库查询或 RPC 合并成一次批量请求。
CacheLoader.loadAll 默认抛出 UnsupportedLoadingOperationException。LocalCache.getAll 捕获这个特定异常后,才会逐个调用 get(key, loader) 降级;普通加载异常不会触发降级。
批量结果还有几条契约:
- 必须为每个请求 key 返回非 null 值,否则已有结果会被缓存,但
getAll抛出InvalidCacheLoadException。 - 返回额外 key 时,这些额外条目也会被缓存,但不会出现在本次
getAll的返回结果中。 loadAll和load的接口都是同步返回;批量描述的是一次加载多少 key,不代表异步执行。
7. 统计、视图与可观测边界
7.1 StatsCounter
启用 recordStats() 后,缓存记录命中、未命中、加载成功、加载失败、加载耗时和驱逐等指标。
统计打点分散在真实读写和加载路径中。它能说明缓存发生了什么,但不能替代对 key 分布、加载源压力和尾延迟的业务监控。
7.2 asMap() 是并发视图,不是快照
asMap() 返回线程安全的 ConcurrentMap 视图,对视图的修改会直接作用于缓存。例如,Cache.invalidate(key) 最终调用 LocalCache.remove(key),asMap().remove(key) 也进入同一删除实现。两者的底层效果相近,但 API 语义不同:前者表达缓存失效,后者表达 Map 修改并返回旧值。
它的迭代器是 weakly consistent:
- 可以与并发修改同时使用。
- 不会抛
ConcurrentModificationException。 - 创建迭代器后的哪些修改能够被观察到,没有确定保证。
因此,不能把一次遍历解释成“缓存在某个时间点的完整快照”。
7.3 modCount 只为部分批量读取检测变化
每个 Segment 的 modCount 是映射发生结构性或可观察更新的版本信号,不只在哈希表大小变化时递增;替换已有 value 时,即使 count 不变,也会更新它。containsValue 会在无锁遍历前后汇总各 Segment 的 modCount;发现总和变化时最多重试几次,以降低遍历期间修改造成误判的概率。isEmpty 也用两轮 count/modCount 检查减少不一致判断。
它不是全局版本号或跨 Segment 协调器,也不参与普通 get。迭代器同样不使用它来保证完整性,而是直接遍历当时拿到的 Segment table,并跳过已经失效的 entry。
size() 的边界更弱:它直接累加各 Segment 的 volatile count,因此只返回并发环境下的近似值。modCount 并没有把这些分别读取的 count 组合成同一时刻的快照。
8. 设计边界与代价
现在可以把前面的机制收束成一个整体:
1
2
3
4
5
6
LocalCache
→ Segment:分片并发与局部维护单元
├─ 存储:table、entry、value reference
├─ 并发:锁、volatile count、modCount
├─ 维护:时间、权重、队列、cleanup、eviction
└─ 加载:LoadingValueReference、load/reload、结果写回
存储、并发、维护和加载不是一条先后执行的流水线,而是 Segment 内围绕同一组 entry 协同工作的并列机制。这套设计的主要收益是:不需要缓存自建调度线程,读路径大多不获取写锁,多个维护能力复用 Segment 内的元数据和队列。
对应代价是:
- 容量和 LRU 顺序按 Segment 管理,热点不均时可能提前驱逐。
- 清理时机依赖访问或显式
cleanUp(),冷缓存中的失效条目可能延迟回收。 - 读路径虽然轻量,仍要记录访问并承担部分维护成本。
- 默认 refresh 和 removal listener 都可能占用调用线程,耗时逻辑需要调用方主动异步化。
invalidate不取消在途加载,严格失效语义需要业务层额外协调。- 批量读取和遍历只能提供近似或弱一致观察,不能当作原子快照。
Guava 官方目前建议优先考虑 Caffeine。Caffeine 提供更深入的异步 API,并使用不同的准入、驱逐和维护结构。但 refreshAfterWrite 仍是访问触发的刷新资格,不应与 Scheduler 驱动的主动全量刷新混为一谈。更细的差异需要在阅读 BoundedLocalCache 后单独核验,不在这篇 Guava 笔记中提前下结论。
9. 复习索引
9.1 一句话结论
Guava Cache 以 Segment 作为并发和维护单元,用 entry/value reference 表示数据、引用强度与加载状态,用单调时间、权重和队列支撑过期、近似 LRU 和引用回收,再通过锁外加载、写回核对与搭便车清理组合这些能力。
9.2 易混点
- 全局上限与局部配额:Segment 配额总和等于全局上限,但分布不均可能导致提前驱逐。
- 两级 hash 寻址:hash 高位选择 Segment,低位选择 Segment 内的桶;两级容量都依赖 2 的幂。
- 逻辑过期与物理删除:条目先对读取不可见,物理删除可以稍后发生。
recencyQueue与accessQueue:前者无锁暂存读取记录,后者在锁内维护驱逐顺序。- 首次加载与 refresh:首次加载的并发读取等待 future;refresh 期间可以继续读旧值。
- refresh 同步与异步:取决于
CacheLoader.reload的实现,不由 cache 自动保证。 - active 与 loading:两者是正交状态;正常 refresh 同时 active 且 loading,首次加载只有 loading,refresh 旧值被移除后也会只剩 loading。
- invalidate 与取消加载:invalidate 丢弃当前值,但不会取消在途 load/reload,结果仍可能重新进入缓存。
- 批量与异步:
loadAll是同步批量加载;默认未实现时才逐 key 降级,与异步无关。 - 通知队列与后台线程:队列用于锁外处理,默认 listener 仍在调用线程执行。
- ReferenceQueue 与 FRQ:LocalCache 自己轮询引用队列,不使用
FinalizableReferenceQueue的后台线程。 - 弱一致与快照:
asMap()可并发迭代,但不是时间点快照。 - modCount 与全局版本:它只帮助部分批量读取检测并发变化,不负责跨 Segment 协调。
9.3 核心源码锚点
| 机制 | 类或方法 |
|---|---|
| Segment 路由 | LocalCache.segmentFor |
| 桶内寻址 | Segment.getFirst、Segment.put |
| 分段存储 | LocalCache.Segment |
| Entry 能力组合 | LocalCache.EntryFactory |
| Segment 扩容 | Segment.expand、Segment.copyEntry |
| 过期判断 | LocalCache.isExpired、Segment.getLiveEntry |
| 过期清理 | Segment.expireEntries |
| 容量驱逐 | Segment.evictEntries |
| 访问回灌 | Segment.recordRead、Segment.drainRecencyQueue |
| 引用回收 | Segment.drainReferenceQueues |
| 移除通知 | Segment.enqueueNotification、LocalCache.processPendingNotifications |
| 写入维护链 | Segment.put、Segment.preWriteCleanup、Segment.postWriteCleanup |
| 加载占位 | LoadingValueReference |
| 并发加载 | Segment.lockedGetOrLoad |
| 刷新入口 | Segment.scheduleRefresh、Segment.refresh |
| 加载结果处理 | Segment.getAndRecordStats、Segment.storeLoadedValue |
| 加载失败恢复 | Segment.removeLoadingValue |
| 加载期间失效测试 | CacheLoadingTest.testInvalidateDuringLoading |
| 批量加载 | LocalCache.getAll、LocalCache.loadAll |
| 批量变化检测 | Segment.modCount、LocalCache.containsValue |
10. 下一步
- 阅读 Caffeine
BoundedLocalCache,重点核验它如何替代 Segment、访问队列和维护批处理。 - 用
concurrencyLevel(1)与默认并发级别做容量分布实验,观察局部热点导致的提前驱逐。 - 复现
CacheLoadingTest.testInvalidateDuringLoading,再补充put覆盖在途加载,以及业务 generation/tombstone 阻止旧请求回填的对照实验。 - 补充 removal listener、统计打点和 Segment 扩容路径的测试笔记。