一.概述
在上篇文章《Guava Cache做本地缓存那些事》中我介绍了Guava Cache作为本地缓存的一般用法,叙述了构造LoadingCache的方式和其基本使用,以及两种get方法的区别,LoadingCache和Cache的区别等内容。而为了知其然并知其所以然,本文对Guava Cache的实现原理做一些简单介绍,如有不对的地方请大家批评指正。
首先,先把Guava Cache在github上的源码链接放出来。里面有作者写的注释,可以方便大家学习。
Guava Cache带注释源码
二.关注的问题
从上述源码和前一篇介绍Guava Cache用法的文章中大家可以看出Guava Cache涉及的类和代码还是比较多的,如果挨个文件去分析涉及的细节太多,不容易把握关键的问题。所以笔者决定从get方法的执行过程出发对Guava Cache的原理进行分析。分析主要关注以下三个问题:
- Guava Cache是如何通过key映射到value的;
- 未命中缓存时如何调用的CacheLoader.load方法来载入缓存的;
- 两种缓存失效策略是如何实现的。
三.源码分析
3.1 key-value的映射过程
通过以下例子我们来看一下key-value的映射过程:
public static void demo1() throws Exception {
//定义Cache,并定义未命中时的载入方法
LoadingCache<Integer, Integer> cache = CacheBuilder.newBuilder().build(new CacheLoader<Integer, Integer>() {
@Override
public Integer load(Integer key) throws Exception {
return key + 1;
}
});
cache.get(1); //第一个get:第一次访问key,使用载入方法载入
cache.get(1); //第二个get:第二次访问,在缓存中得到value
}
以上demo定义了一个LoadingCache,并传入了CacheLoader提供在未命中缓存时的处理方法。后面可以看到对get方法调用了两次,第一次是为了通过load方法将相应的value载入缓存,这个过程在3.2节中介绍。本节主要看一下第二次get的过程:在缓存中存在对应value的情况下,如何通过key拿到value的。
通过debug断点,我们进入get方法,观察其执行过程:
step1:
首先进入的是如下这个方法,可以推断它所在的LoaclLoadingCache是LoadingCache接口的默认实现。这个类是LoaclCache的一个内部类。get方法直接调用了getOrLoad方法。
static class LocalLoadingCache<K, V> extends LocalCache.LocalManualCache<K, V> implements LoadingCache<K, V> {
public V get(K key) throws ExecutionException {
return this.localCache.getOrLoad(key);
}
}
step2:
我们继续往下看getOrLoad方法,发现它也只是一层封装,go on!
V getOrLoad(K key) throws ExecutionException {
return this.get(key, this.defaultLoader);
}
step3:
继续往下追踪到LocalCache.get方法。到这里终于可以看出一些操作了。一共有两条语句,分别分析下。
第一句是求哈希值,其中Preconditions.checkNotNull方法用于检查空指针,如果key是null的话就抛出空指针异常,然后进行hash操作。hash操作时,先取得key.hashCode()的返回值,再进行rehash操作。rehash的目的是为了防御由key.hashCode()质量差引起的hash冲突严重问题。个人理解的其具体做法是进行一系列位运算,将高位和低位的hash值进行混淆。
第二句是我们分析的重点,请看step4。
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
int hash = this.hash(Preconditions.checkNotNull(key));
return this.segmentFor(hash).get(key, hash, loader);
}
step4:
LoadingCache采用了类似ConcurrentHashMap的方式,将映射表分为多个segment。segment之间可以并发访问,这样可以大大提高并发的效率,使得并发冲突的可能性降低了(同时访问一个segment上的key时还是有可能冲突的)。
可以将step3中最后一行代码分为两部分,一是this.segmentFor(hash),这是通过hash值确定该key位于哪一个segment上,并获取该segment。而是再通过segment执行get方法来获取具体的value值。
首先看一下this.segmentFor(hash)的代码:
LocalCache.Segment<K, V> segmentFor(int hash) {
return this.segments[hash >>> this.segmentShift & this.segmentMask];
}
可以看出segments是存储在一个数组中的,该数组的长度是2的次幂,同时等于LoadingCache的并发等级。那么segmentShift和segmentMask是如何取值的呢?可以将segmentMash理解为数组长度减一,这样和它进行与操作后的范围总是在数组的长度范围内。segmentShift的取值有兴趣的可以参考源码LocalCache的构造方法了解,这里不再赘述。
取得segment后,便可以执行我们最终的get方法了。其核心主要代码如下:
//count记录当前segment中存活的缓存个数
if (this.count != 0) {
//其实这一句就已经获取到了value值,下面的代码进行了过期判断等操作
ReferenceEntry<K, V> e = this.getEntry(key, hash);
if (e != null) {
long now = this.map.ticker.read();
//判断是否过期
V value = this.getLiveValue(e, now);
if (value != null) {
this.recordRead(e, now);
this.statsCounter.recordHits(1);
Object var17 = this.scheduleRefresh(e, key, hash, value, now, loader);
return var17;
}
LocalCache.ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) {
Object var9 = this.waitForLoadingValue(e, key, valueReference);
return var9;
}
}
}
//未载入的情况,3.2节进行分析
Object var15 = this.lockedGetOrLoad(key, hash, loader);
return var15;
代码ReferenceEntry<K, V> e = this.getEntry(key, hash);就获取到了key相关的键值对,后续代码进行了过期策略(3.3节介绍)和载入操作(3.2介绍)等操作。这里主要看一下getEntry方法。
和HashMap等类似,segment也采用了数组加链表的数据结构。首先由hash值和数组长度减一进行与操作获取链表的头结点,再遍历链表得到真正的结果。
小结
以上就是对LocalCache的get方法的分析,可以看出其实现方法除了一些细节上的不同基本和ConcurrentHashMap等Java标准类库中的实现类似。从分析的step4我们也知道了进行get操作时会进行CacheLoader.load的调用和缓存失效的判断,下面就具体再来看一下这两块内容的原理。
3.2 CacheLoader.load的调用机制
先回顾一下上篇文章知识,CacheLoader.load是在构建LoadingCache时传入给CacheBuilder的,并在缓存未命中的情况下进行调用。在3.1节step4中我们看到了Load被调用的入口点,为了阅读方便,这里再把该块代码贴一下:
//count记录当前segment中存活的缓存个数
if (this.count != 0) {
//其实这一句就已经获取到了value值,下面的代码进行了过期判断等操作
ReferenceEntry<K, V> e = this.getEntry(key, hash);
if (e != null) {
long now = this.map.ticker.read();
//判断是否过期
V value = this.getLiveValue(e, now);
if (value != null) {
this.recordRead(e, now);
this.statsCounter.recordHits(1);
Object var17 = this.scheduleRefresh(e, key, hash, value, now, loader);
return var17;
}
LocalCache.ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) {
Object var9 = this.waitForLoadingValue(e, key, valueReference);
return var9;
}
}
}
//未载入的情况,3.2节进行分析
Object var15 = this.lockedGetOrLoad(key, hash, loader);
return var15;
可以看到当this.count==0时就会执行倒数第二行的this.lockedGetOrLoad方法,在这个方法里就对 CacheLoader.load进行了调用。由于这里是写操作,需要对segment进行加锁。segment是ReentrantLock的子类,直接调用其lock()方法即可加锁。之后对CacheLoader.load()进行调用获取value值,并放入table中的链表里。具体的代码较为繁琐就不再分析了。
3.3 两种缓存过期策略的实现
Guava Cache可以设置两种过期策略,一是单位时间内没有读缓存过期,另一种是单位时间内没有写缓存过期。
判断是否过期的入口在3.1节step4中提到的segment.get中:
//count记录当前segment中存活的缓存个数
if (this.count != 0) {
//其实这一句就已经获取到了value值,下面的代码进行了过期判断等操作
ReferenceEntry<K, V> e = this.getEntry(key, hash);
if (e != null) {
long now = this.map.ticker.read();
//判断是否过期
V value = this.getLiveValue(e, now);
……
}
……
}
一路追踪getLiveValue方法,找到了以下判断的位置:
boolean isExpired(ReferenceEntry<K, V> entry, long now) {
Preconditions.checkNotNull(entry);
if (this.expiresAfterAccess() && now - entry.getAccessTime() >= this.expireAfterAccessNanos) {
return true;
} else {
return this.expiresAfterWrite() && now - entry.getWriteTime() >= this.expireAfterWriteNanos;
}
}
可以看到根据过期策略、当前的时间、设置的过期时间进行了是否过期判断。同时我们也知道了Guava Cache判断过期并不是单独再开一个线程,而是在读写操作时顺带着进行了过期判断。