Guava Cache实现原理浅析

一.概述

在上篇文章《Guava Cache做本地缓存那些事》中我介绍了Guava Cache作为本地缓存的一般用法,叙述了构造LoadingCache的方式和其基本使用,以及两种get方法的区别,LoadingCache和Cache的区别等内容。而为了知其然并知其所以然,本文对Guava Cache的实现原理做一些简单介绍,如有不对的地方请大家批评指正。

首先,先把Guava Cache在github上的源码链接放出来。里面有作者写的注释,可以方便大家学习。
Guava Cache带注释源码

二.关注的问题

从上述源码和前一篇介绍Guava Cache用法的文章中大家可以看出Guava Cache涉及的类和代码还是比较多的,如果挨个文件去分析涉及的细节太多,不容易把握关键的问题。所以笔者决定从get方法的执行过程出发对Guava Cache的原理进行分析。分析主要关注以下三个问题:

  1. Guava Cache是如何通过key映射到value的;
  2. 未命中缓存时如何调用的CacheLoader.load方法来载入缓存的;
  3. 两种缓存失效策略是如何实现的。

三.源码分析

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判断过期并不是单独再开一个线程,而是在读写操作时顺带着进行了过期判断。

关注公众号,第一时间接收我更多文章
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/vxzhg/article/details/102648232