guava localcache 的 lru 是由 accessqueue 和 writequeue 两个惰性初始化队列协同实现的:get/put 触发 entry 移至 accessqueue 尾部用于访问过期与容量驱逐,put 追加至 writequeue 尾部用于写入过期检查;段锁基于 concurrencylevel 划分 segment,路由通过 segmentfor() 哈希映射;驱逐为懒执行,依赖 put/get/cleanup() 触发,其中 get 通过 readcount 计数器(默认64)触发 postreadcleanup() 有限清理。

Guava 的 LocalCache 并不直接暴露 LRU 行为供你“配置开关”,它的 LRU 是隐式、分散在多个队列中协同工作的;段锁(Segment)也不是用户可调用的 API,而是底层并发控制的实现细节。想从源码里看懂它,得逆着使用路径往回挖:先知道缓存行为表现 → 再定位触发点 → 最后钻进 Segment 和队列逻辑。
为什么 get/put 后元素顺序会变?—— accessQueue 和 writeQueue 的真实作用
LocalCache 里没有全局链表维护访问顺序,而是靠两个独立的 Queue:一个 accessQueue 记录最近访问时间(用于 expireAfterAccess 和容量驱逐),一个 writeQueue 记录写入时间(用于 expireAfterWrite)。它们都基于 ReferenceEntry 的双向链接构造,但不是传统 LRU 链表。
-
accessQueue在每次get或put时,把对应 entry 移到队尾;驱逐时从队头开始扫,越老的越先被踢 -
writeQueue只在put时追加 entry 到队尾;过期检查时也是从队头开始,遇到第一个未过期的 entry 就停止(因为后续更晚写入的一定没过期) - 这两个队列是 lazy 初始化的,没配对应策略就不会创建,也不会参与清理
- 注意:
recencyQueue是个辅助队列,只在读操作中暂存刚访问的 entry,随后会被移到accessQueue尾部,不直接参与淘汰
Segment 分段锁怎么分?—— segmentFor() 路由逻辑与默认并发度
LocalCache 的分段数由 concurrencyLevel 参数决定,默认是 4。这个值不会动态调整,也不等于 CPU 核心数;它只是初始化时划分 Segment 数量的依据。实际路由靠 segmentFor(int hash) 方法,用哈希值无符号右移再与掩码做 & 运算,映射到具体 Segment 实例。
- 每个
Segment持有自己的AtomicReferenceArray(底层桶数组)、自己的accessQueue/writeQueue、自己的ReentrantLock - 读操作(如
get)多数情况无锁,但会触发postReadCleanUp()—— 此时可能轻量级地尝试获取锁来清理过期项 - 写操作(如
put、invalidate)必须获取对应Segment的锁;锁粒度是段级,不是 key 级,所以同段内不同 key 也会竞争 - 如果并发写压力集中在少数 key 上,且这些 key 总被路由到同一
Segment,就可能出现锁争用;这时调大concurrencyLevel可缓解,但会增加内存开销
驱逐不是实时发生的—— cleanUp() 的触发时机与 readCount 机制
LocalCache 的过期和容量驱逐都是懒执行的,没有后台线程轮询。真正触发清理的有三类入口:put、get、显式调用 cleanUp()。其中 get 的清理最隐蔽:它靠一个 readCount 计数器,每读一次 +1,达到阈值(默认 64)就尝试获取 Segment 锁并执行 postReadCleanUp()。
-
postReadCleanUp()只清理当前Segment内的过期项或超容项,不跨段;它不保证清理完所有过期项,只做有限步扫描(避免卡住读请求) - 如果你只读不写,又没手动调
cleanUp(),过期项可能长期滞留 —— 这就是为什么示例里 sleep 后 size 不变,必须调cache.cleanUp()才见效 -
cleanUp()是同步方法,会遍历所有Segment并尝试清理,但依然受单次扫描步数限制;高负载下仍可能残留 - 不要依赖
asMap().size()判断有效条目数,它返回的是未被清理的总 entry 数,包含已过期但尚未驱逐的
真正难啃的地方在于:LRU 不是单一链表,而是 access/write/recency 三个队列+段锁+计数器+惰性扫描共同构成的协作机制;任何一个环节单独看都合理,合起来却容易误判行为。读源码时建议从 LocalCache.get() 入口开始,顺着 segmentFor → Segment.get → postReadCleanUp → drainRecencyQueue 这条链路走一遍,比直接翻 accessQueue 的 add/remove 更能看清数据流动。










