目录javascript
本次主要是分析cache的源码,基本概念官方简介便可。html
在官方的文档说明中,Guava Cache实现了三种加载缓存的方式:java
核心类及接口的说明,简单的理解以下:git
Cache接口是Guava对外暴露的缓存接口,对外的方法以下图,Cache定义的接口get接口中,必需要传入Callable,Callable是若是不存在就加载的方式定义,这种就是第二种加载缓存的方式,若是缓存中key不存在或者过时的状况,调用get(K,Callable)来实现
github
LoadingCache是对Cache的进一步封装,继承自Cache接口,主要是实现了get(K)这种定义策略
算法
LocalManualCache其实是Cache的标准实现,注意LocalManualCache不包含无Callable参数的get方法,是一种能在键值找不到的时候手动调用获取值的方式数组
LocalLoadingCache则是LoadingCache的实现,核心的区别在于支持在key找不到的状况下自动加载value的功能点,实际上是保存了一个CacheLoading的初始值缓存
LocalCache是存储层,是真正意义上数据存放的地方,继承了java.util.AbstractMap同时也实现了ConcurrentMap接口,实现方式和ConcurrentHashMap的实现相同,都是采用分segment来细化管理HashMap中的节点Entry,细粒度锁的方式来增大并发性能。安全
CacheLoader个人理解是缓存加载策略,即负责计算key-value的对应关系,是一个抽象类,须要业务定制本身的策略。在Guava的使用过程当中,get参数传入的Callable接口最终会被封装成匿名的CacheLoader,负责加载key到缓存中数据结构
CacheBuilder 因为cache配置项众多,典型的builder模式场景,复杂对象的构造与其对应配置属性表示的分离。
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(); }
以前有说到过accessQueue
,这个队列是按照最久未使用的顺序存放的缓存对象(ReferenceEntry)的。因为会常常进行元素的移动,例如把访问过的对象放到队列的最后。而当元素超过了预设的maximumSize
,就会从accessQueue的队头取对应的数据,也就是最长时间没有访问到的那个元素,而后从Segment的table中剔除,一样的也要从writeQueue、accessQueue中剔除
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包装成异步方式处理