文章

【笔记】Guava Cache 设计与实现

【笔记】Guava Cache 设计与实现

1. 要搞清楚什么

这篇笔记试图回答三个问题:

  1. Guava Cache 要解决哪些缓存问题?
  2. LocalCache 如何把并发存储、过期、驱逐、引用回收和自动加载组合起来?
  3. 这些设计带来了哪些边界和代价?

核验基线是 Guava v33.2.1

源码会继续变化,因此正文使用类名和方法名作为锚点,不依赖绝对行号。

2. 从缓存问题得到能力清单

缓存用空间换时间:把计算或获取成本较高的结果暂存在内存中,后续访问直接复用。

这个收益同时带来四组矛盾:

矛盾需要解决的问题
访问要快,但内存有限限制容量,并决定淘汰谁
结果可复用,但会变旧判断失效,并清理过期数据
多线程共享,但不能互相破坏保证并发读写和加载的正确性
缓存需要持有对象,但不能无限阻碍 GC支持弱引用、软引用及引用回收

在这些基础上,还需要处理缓存未命中后的加载,以及运行状态的观测。由此得到六组能力:

  • 并发存储
  • 容量驱逐
  • 时间过期
  • 自动加载与刷新
  • 引用回收
  • 统计、视图和移除通知

下面先建立 LocalCache 的整体结构,再沿着这些能力逐层展开。

3. LocalCache 的整体结构

LocalCache 本身实现 ConcurrentMapLocalManualCacheLocalLoadingCache 在它外面补充 CacheLoadingCache 的接口语义。

内部结构可以先压缩成这张图:

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

这里有三层关键关系:

  1. LocalCache 负责把请求路由到某个 Segment
  2. Segment 是并发控制和维护工作的基本单位,拥有自己的哈希表、计数、权重和队列。
  3. 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 越多,独立数据结构和管理成本越高。

maximumSizemaximumWeight 的总预算会按余数分配到各 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

因此源码中既有 StrongEntryStrongAccessEntryStrongWriteEntry,也有同时携带两组元数据的 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 是否达到或超过 expireAfterWrite
  • now - accessTime 是否达到或超过 expireAfterAccess

读路径通过 getLiveEntry 判断条目是否仍然有效。条目已过期时,读取直接按未命中处理;能否立即获得锁并完成物理删除,不影响这个结果。

getLiveEntry 发现过期后会调用 tryExpireEntries。后者只在 tryLock() 成功时执行清理,拿不到锁就放弃本次物理删除。此时读路径仍返回 null,因此锁竞争只会推迟回收,不会让过期值重新可见。

真正清理时,expireEntrieswriteQueueaccessQueue 队头开始处理。队列已经按相关时间排序,因此遇到第一个未过期条目即可停止,不需要扫描整张哈希表。

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 持有 CacheLoaderget(key) 未命中时,LocalCache 使用 loader 计算结果。

6.2 首次并发 miss:一个线程加载,其他线程等待

首次 miss 时,Segment 在锁内为 key 安装 LoadingValueReference。它相当于“正在加载”的占位状态,并持有一个 future。

第一个线程离开锁后执行 loader。其他线程看到同一个首次加载占位时,不会重复调用 loader,而是等待同一个 future。

因此,并发 miss 去重的核心不是“所有线程都能读旧值”,而是:

1
2
3
4
锁内安装唯一占位
  → 锁外执行加载
    → future 发布结果
      → 等待线程共享同一结果

ValueReference.isLoading()isActive() 是两个不同维度:

状态isLoadingisActive含义
UNSETfalsefalse尚无可用值
首次加载的 LoadingValueReferencetruefalse正在加载,但没有旧值
正常 refresh 的 LoadingValueReferencetruetrue正在加载,同时保留 active 旧值
refresh 期间旧值被移除truefalse加载仍在继续,但 oldValue 已被清为 UNSET
普通值引用falsetrue已有可用值

这个区分让同一个 loading 占位同时表达“首次加载”和“带旧值刷新”,后续删除、失败恢复和统计逻辑可以据此决定是否仍有一个应被计数、通知或继续提供的旧值。

加载结果的发布顺序也经过专门处理。正常首次加载会先 set(newValue) 完成 futureValue,再返回这个 future;reload 返回另一个 future 时,loadFuturetransform 保证先把结果写入当前 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。代价是从发起加载到结果返回之间,其他线程可以 putinvalidate 或触发清理。Guava 不会取消 loader,而是在写回阶段重新加锁并核对当前的 ValueReference

需要分开看三种情况:

  • 并发 putsetValue 用手工写入的新值替换 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 默认抛出 UnsupportedLoadingOperationExceptionLocalCache.getAll 捕获这个特定异常后,才会逐个调用 get(key, loader) 降级;普通加载异常不会触发降级。

批量结果还有几条契约:

  • 必须为每个请求 key 返回非 null 值,否则已有结果会被缓存,但 getAll 抛出 InvalidCacheLoadException
  • 返回额外 key 时,这些额外条目也会被缓存,但不会出现在本次 getAll 的返回结果中。
  • loadAllload 的接口都是同步返回;批量描述的是一次加载多少 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 的幂。
  • 逻辑过期与物理删除:条目先对读取不可见,物理删除可以稍后发生。
  • recencyQueueaccessQueue:前者无锁暂存读取记录,后者在锁内维护驱逐顺序。
  • 首次加载与 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.getFirstSegment.put
分段存储LocalCache.Segment
Entry 能力组合LocalCache.EntryFactory
Segment 扩容Segment.expandSegment.copyEntry
过期判断LocalCache.isExpiredSegment.getLiveEntry
过期清理Segment.expireEntries
容量驱逐Segment.evictEntries
访问回灌Segment.recordReadSegment.drainRecencyQueue
引用回收Segment.drainReferenceQueues
移除通知Segment.enqueueNotificationLocalCache.processPendingNotifications
写入维护链Segment.putSegment.preWriteCleanupSegment.postWriteCleanup
加载占位LoadingValueReference
并发加载Segment.lockedGetOrLoad
刷新入口Segment.scheduleRefreshSegment.refresh
加载结果处理Segment.getAndRecordStatsSegment.storeLoadedValue
加载失败恢复Segment.removeLoadingValue
加载期间失效测试CacheLoadingTest.testInvalidateDuringLoading
批量加载LocalCache.getAllLocalCache.loadAll
批量变化检测Segment.modCountLocalCache.containsValue

10. 下一步

  • 阅读 Caffeine BoundedLocalCache,重点核验它如何替代 Segment、访问队列和维护批处理。
  • concurrencyLevel(1) 与默认并发级别做容量分布实验,观察局部热点导致的提前驱逐。
  • 复现 CacheLoadingTest.testInvalidateDuringLoading,再补充 put 覆盖在途加载,以及业务 generation/tombstone 阻止旧请求回填的对照实验。
  • 补充 removal listener、统计打点和 Segment 扩容路径的测试笔记。
本文由作者按照 CC BY 4.0 进行授权