文章

【笔记】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 的写可以并行,同一 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 把这些差异收敛在统一比较入口中。

5. 维护模型:让清理搭便车

LocalCache 不创建专属定时线程扫描缓存。过期清理、引用队列回收和移除通知等维护工作,主要发生在写操作、部分读操作或显式 cleanUp() 中。

可以把这种取向概括为 piggyback:维护工作搭便车在业务访问上。

它成立的前提是把“访问语义”和“物理清理”分开。例如,过期条目即使尚未从内部表中删除,也不会继续对正常读取可见;延迟清理影响的是内存占用和通知时机,不应改变读取结果。

5.1 元数据是维护的基础

为了支持过期和容量限制,entry 或 value reference 会保存:

  • accessTime:最近访问时间
  • writeTime:最近写入时间
  • weight:条目权重

时间来自可注入的 Ticker。测试可以替换 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 判断条目是否仍然有效。条目已过期时,读取直接按未命中处理;能否立即获得锁并完成物理删除,不影响这个结果。

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

5.4 容量驱逐:顺序决定候选,权重决定是否超限

每个 Segment 维护 totalWeight 和自己的容量配额。写入新值后,evictEntries 检查是否超过配额,并从 accessQueue 前端选择驱逐候选。

需要区分两个概念:

  • 访问顺序决定优先驱逐谁。
  • 权重决定当前是否超过容量,以及需要驱逐到什么程度。

官方 API 也明确说明,weight 用于判断容量,而不负责决定下一个被驱逐的条目。权重为 0 的条目不参与基于容量的驱逐。

5.5 引用回收:GC 只发出信号,缓存仍要清理残留

weakKeys()weakValues()softValues() 让键或值可以被 GC 回收。GC 回收对象后,对应引用进入 ReferenceQueue。

drainReferenceQueues 在维护过程中消费这些通知,并从 Segment 中移除已经失去 key 或 value 的条目。尚未物理删除的回收条目可能仍被 size() 计数,但不会继续对正常读写可见。

5.6 移除通知默认在调用线程处理

条目被替换、显式删除、过期、容量驱逐或 GC 回收时,Guava 会把 RemovalNotification 放入待处理队列。runUnlockedCleanup 在退出 Segment 锁后调用 listener。

这里的队列用于把 listener 与内部锁解耦,并不代表默认创建后台线程。默认 listener 仍由触发清理的调用线程执行。需要异步通知时,应显式使用异步包装并提供 Executor。

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 发布结果
      → 等待线程共享同一结果

6.3 refresh:有旧值时继续提供旧值

refresh 与首次加载不同。刷新开始时,LoadingValueReference.oldValue 保存原值。

如果 reload 尚未完成,其他读取可以继续得到旧值。刷新成功后,新值替换旧值;刷新失败时,旧值继续保留。

CacheLoader.reload 的默认实现会同步调用 load,再返回一个已经完成的 future。因此 Guava 不保证 refresh 一定异步:

  • 使用默认 reload 时,触发刷新的读取可能在当前线程完成重新加载。
  • 自定义 reload 返回未完成的 future 时,触发线程可以先返回旧值,加载在调用方提供的执行环境中继续。

refreshAfterWrite 表示条目在访问时达到刷新条件后可以触发刷新,不代表 Guava 创建定时线程主动扫描和刷新所有条目。

6.4 加载失败不会成为缓存值

加载成功时,storeLoadedValue 把结果替换到占位中,并唤醒等待线程。

加载失败时,异常通过 future 传播给等待线程,加载占位被移除或恢复旧值。下次访问仍可以重新加载。Guava 不会自动把异常缓存成负结果;如果业务需要防止持续穿透,需要在 loader 或外层策略中处理。

7. 统计、视图与可观测边界

7.1 StatsCounter

启用 recordStats() 后,缓存记录命中、未命中、加载成功、加载失败、加载耗时和驱逐等指标。

统计打点分散在真实读写和加载路径中。它能说明缓存发生了什么,但不能替代对 key 分布、加载源压力和尾延迟的业务监控。

7.2 asMap() 是并发视图,不是快照

asMap() 返回线程安全的 ConcurrentMap 视图。它的迭代器是 weakly consistent:

  • 可以与并发修改同时使用。
  • 不会抛 ConcurrentModificationException
  • 创建迭代器后的哪些修改能够被观察到,没有确定保证。

因此,不能把一次遍历解释成“缓存在某个时间点的完整快照”。

8. 设计边界与代价

现在可以把前面的机制收束成一个整体:

1
2
3
4
5
Segment 分片并发
  → entry/value reference 承载数据和状态
    → 时间、权重和队列记录维护依据
      → 读写与 cleanUp 触发批量维护
        → loading reference 协调并发加载和刷新

这套设计的主要收益是:不需要缓存自建调度线程,读路径大多不获取写锁,多个维护能力复用 Segment 内的元数据和队列。

对应代价是:

  • 容量和 LRU 顺序按 Segment 管理,热点不均时可能提前驱逐。
  • 清理时机依赖访问或显式 cleanUp(),冷缓存中的失效条目可能延迟回收。
  • 读路径虽然轻量,仍要记录访问并承担部分维护成本。
  • 默认 refresh 和 removal listener 都可能占用调用线程,耗时逻辑需要调用方主动异步化。

Guava 官方目前建议优先考虑 Caffeine。Caffeine 提供更深入的异步 API,并使用不同的准入、驱逐和维护结构。但 refreshAfterWrite 仍是访问触发的刷新资格,不应与 Scheduler 驱动的主动全量刷新混为一谈。更细的差异需要在阅读 BoundedLocalCache 后单独核验,不在这篇 Guava 笔记中提前下结论。

9. 复习索引

9.1 一句话结论

Guava Cache 以 Segment 作为并发和维护单元,用 entry/value reference 表示数据与加载状态,用时间、权重和队列支撑过期、近似 LRU 和引用回收,再把大部分维护工作搭便车到读写与 cleanUp() 上。

9.2 易混点

  • 全局上限与局部配额:Segment 配额总和等于全局上限,但分布不均可能导致提前驱逐。
  • 逻辑过期与物理删除:条目先对读取不可见,物理删除可以稍后发生。
  • recencyQueueaccessQueue:前者无锁暂存读取记录,后者在锁内维护驱逐顺序。
  • 首次加载与 refresh:首次加载的并发读取等待 future;refresh 期间可以继续读旧值。
  • refresh 同步与异步:取决于 CacheLoader.reload 的实现,不由 cache 自动保证。
  • 通知队列与后台线程:队列用于锁外处理,默认 listener 仍在调用线程执行。
  • 弱一致与快照asMap() 可并发迭代,但不是时间点快照。

9.3 核心源码锚点

机制类或方法
Segment 路由LocalCache.segmentFor
分段存储LocalCache.Segment
过期判断LocalCache.isExpiredSegment.getLiveEntry
过期清理Segment.expireEntries
容量驱逐Segment.evictEntries
访问回灌Segment.recordReadSegment.drainRecencyQueue
引用回收Segment.drainReferenceQueues
移除通知Segment.enqueueNotificationLocalCache.processPendingNotifications
加载占位LoadingValueReference
并发加载Segment.lockedGetOrLoad
刷新入口Segment.scheduleRefreshSegment.refresh
加载结果处理Segment.getAndRecordStatsSegment.storeLoadedValue

10. 下一步

  • 阅读 Caffeine BoundedLocalCache,重点核验它如何替代 Segment、访问队列和维护批处理。
  • concurrencyLevel(1) 与默认并发级别做容量分布实验,观察局部热点导致的提前驱逐。
  • 补充 removal listener、统计打点和 Segment 扩容路径的测试笔记。
本文由作者按照 CC BY 4.0 进行授权