[TOC]
本次主要是分析 cache 的源码,基本概念官方简介即可。
在官方的文档说明中,Guava Cache 实现了三种加载缓存的方式:
核心类及接口的说明,简单的理解如下:
LocalCache 是线程安全的集合,为了实现这个特性,使用了经典的细粒度锁来控制,本质和 ConcurrentHashMap 的实现方式类型,在存储中采用了多个 Segment 对应一个锁,来分散全局锁带来的性能损失。当去 put 一个 entry 的时候,一般只需要拥有某一个 segment 锁就可以完成。下图是 ConcurrentHashMap 和 HashTable 存储的描述。
在实现上,LocalCache 的并发策略和 ConcurrentHashMap 的并发策略一致,也是进行了分段,支持不同段的并发写入。
ReferenceEntry 是 Guava 中对一个 key-value 节点的抽象,每一个 Segment 中都包含这一个 ReferenceEntry 数组,每个 ReferenceEntry 数组项都是一条 ReferenceEntry 链,其数据结构如下:
类继承结构如下:
ReferenceEntry 包装了 key-value 节点的同时,主要的功能点是增加了引用数据类型回收机制(这个不讨论),设置了 accessQueue 和 writeQueue 队列,这个两个其实是双向链表,分别通过 previousAccess、nextAccess 和 previousWrite、nextWrite 字段链接而成,这两个队列存在的目的是:实现 LRU 算法
涉及到一些概念说明:
https://github.com/google/guava/issues/1487
对于 Segment 的 put,基本流程如下:
上面提到过 LocalCacal 对于并发的控制,粒度是 Segment 级别,而 Segment 当中锁的操作相对来说比较频繁,在设计的时候,为了简单,直接让 Segment 继承了
- java.util.concurrent.locks.ReentrantLock
guava cache 并不会开启额外的线程去扫描当前的存储,看是否达到了存储上限,而是在每次 put 的时候进行判断
- /**
- * Performs eviction if the segment is full. This should only be called prior to adding a new
- * entry and increasing {@code count}.
- */
- @GuardedBy("Segment.this") void evictEntries() {
- if (!map.evictsBySize()) {
- return;
- }
- drainRecencyQueue();
- while (totalWeight > maxSegmentWeight) {
- ReferenceEntry < K,
- V > e = getNextEvictable();
- if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) {
- throw new AssertionError();
- }
- }
- }
- // TODO(fry): instead implement this with an eviction head
- ReferenceEntry < K,
- V > getNextEvictable() {
- for (ReferenceEntry < K, V > e: accessQueue) {
- int weight = e.getValueReference().getWeight();
- if (weight > 0) {
- return e;
- }
- }
- throw new AssertionError();
- }
之前有说到过
, 这个队列是按照最久未使用的顺序存放的缓存对象(ReferenceEntry)的。由于会经常进行元素的移动,例如把访问过的对象放到队列的最后。而当元素超过了预设的
- accessQueue
, 就会从 accessQueue 的队头取对应的数据,也就是最长时间没有访问到的那个元素,然后从 Segment 的 table 中剔除,同样的也要从 writeQueue、accessQueue 中剔除
- maximumSize
- ReferenceEntry < K,
- V > removeValueFromChain(ReferenceEntry < K, V > first, ReferenceEntry < K, V > entry, @Nullable K key, int hash, ValueReference < K, V > valueReference, RemovalCause cause) {
- enqueueNotification(key, hash, valueReference, cause);
- writeQueue.remove(entry);
- accessQueue.remove(entry);
- if (valueReference.isLoading()) {
- valueReference.notifyNewValue(null);
- return first;
- } else {
- return removeEntryFromChain(first, entry);
- }
- }
segment 对失效时间的控制也并不是由单独的线程去控制,而是在用户每次请求的时候触发检测,这样可以有效的避免不必要的线程消耗。但是这样也会有一定的问题,简单讲,如果大量的请求同时到,而且缓存内容全部都失效的话,相当于没有做任何缓存控制,而且还延长了单次请求的时间。在大促的时候曾经遇到过,每隔一段时间都发现请求 rt 会出现毛刺,后来发现是用来本地缓存,大量的数据同时失效,而且恰好有很多请求同时来到,全部都去读取 DB,rt 全部变高。
这种方式也临时的解决方案,比如说,预热缓存的时候分批进行。
如果真的存在一些数据需要常驻本地缓存,可以考虑使用额外的线程进行定时刷新,简单的做法是:假设设置的 expireTime 为 10 分钟,那么每隔 9 分钟,定时任务去读取 cache 中的数据,然后更新。(之前看 zk 的代码,zkserver 对于 client Session 的控制是单独线程控制的,那个实现感觉是比较经典的,如果有必要做成是开启额外线程失效的话,可以参考那个实现)。
失效的代码如下,和对数量的控制没大的区别:
- void expireEntries(long now) {
- drainRecencyQueue();
- ReferenceEntry < K,
- V > e;
- while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
- if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
- throw new AssertionError();
- }
- }
- while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
- if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
- throw new AssertionError();
- }
- }
- }
为了纪录 Cache 的使用情况,如果命中次数、没有命中次数、evict 次数等,Guava Cache 中定义了 StatsCounter 做这些统计信息,它有一个简单的 SimpleStatsCounter 实现,我们也可以通过 CacheBuilder 配置自己的 StatsCounter。
put 和 get 操作后都会通知 removeListener,默认是同步的方式处理事件通知。也可以通过 RemoveListeners 将 listener 包装成异步方式处理
来源: http://www.cnblogs.com/kakaxisir/p/7409871.html