[02] 本地缓存- guava缓存

576 阅读13分钟

Guava Cache简介

Guava cache是一个支持高并发的线程安全的本地缓存。多线程情况下也可以安全的访问或者更新cache。这些都是借鉴了ConcurrentHashMap的结果,不过,guava cache 又有自己的特性

"automatic loading of entries into the cache"

即 :当cache中不存在要查找的entry的时候,它会自动执行用户自定义的加载逻辑,加载成功后再将entry存入缓存并返回给用户未过期的entry,如果不存在或者已过期,则需要load,同时为防止多线程并发下重复加载,需要先锁定,获得加载资格的线程(获得锁的线程)创建一个LoadingValueRefrerence并放入map中,其他线程等待结果返回。

1.1 主要实现的缓存功能

  • 自动将entry节点加载进缓存结构中;
  • 当缓存的数据超过设置的最大值时,使用LRU算法移除
  • 具备根据entry节点上次被访问或者写入时间计算它的过期机制;
  • 缓存的key被封装在WeakReference引用内;
  • 缓存的Value被封装在WeakReference或SoftReference引用内;
  • 统计缓存使用过程中命中率、异常率、未命中率等统计数据。

Guava Cache的使用

在介绍Guava Cache使用之前,先来看一下官方推荐的使用场景:

愿意消耗一些内存空间来提升速度;

能够预计某些key会被查询一次以上;

缓存中存放的数据总量不会超出内存容量(Guava Cache是单个应用运行时的本地缓存)。

如果上面说的这些都符合你的需求的话,我觉得guava Cache 将会是你很不错的选择

GuavaCache使用时主要分两种模式:

  • LoadingCache
  • CallableCache

区别在于:你有没有合理的默认方法来加载或计算与键关联的值

2.1 准备工作

这里我们先来写一个mockDB类,用于模拟当缓存中没有相关数据的时候,我们需要去数据库里或者其他文件中读取

public class MockDB {
    public static List<String> getCityListFromDB(String cityId) {
        System.out.println("getting from DB,cityId:" + cityId + " please wait...");
        List<String> returnList = null;
        // 模仿从数据库中取数据
        try {
            switch (cityId) {
                case "0101":
                    returnList = ImmutableList.of("上海", "北京", "广州", "深圳");
                    break;
            }
        } catch (Exception e) {
            // 记日志
        }
        return Optional.fromNullable(returnList).or(Collections.EMPTY_LIST);
    }
}

2.2 LoadingCache

LoadingCache是附带CacheLoader构建而成的缓存实现。创建自己的CacheLoader通常只需要简单地实现load()方法,如下

public class GuavaLoadingCache {
    public static void main(String[] args) {
        LoadingCache<String, Optional<List<String>>> loadingCache = CacheBuilder
                .newBuilder()
                .expireAfterWrite(3, TimeUnit.SECONDS)
                .removalListener(notification -> System.out.println("cache expired, remove key : " + notification.getKey()))
                .build(new CacheLoader<String, Optional<List<String>>>() {
                    @Override
                    public Optional<List<String>> load(String key) throws Exception {
                        return Optional.ofNullable(MockDB.getCityListFromDB(key));
                    }
                });
        try {
            System.out.println("load from cache once : " + loadingCache.get("0101").orElse(Lists.newArrayList()));
            Thread.sleep(2000);
            System.out.println("load from cache two : " + loadingCache.get("0101").orElse(Lists.newArrayList()));
            Thread.sleep(2000);
            System.out.println("load from cache three : " + loadingCache.get("0101").orElse(Lists.newArrayList()));
            Thread.sleep(2000);
            System.out.println("load not exist key from cache : " + loadingCache.get("0103").orElse(Lists.newArrayList()));

        } catch (ExecutionException | InterruptedException e) {
            //记录日志
        }
    }
}

结果:
getting from DB,cityId:0101 please wait...
load from cache once : [上海, 北京, 广州, 深圳]
load from cache two : [上海, 北京, 广州, 深圳]
cache expired, remove key : 0101
getting from DB,cityId:0101 please wait...
load from cache three : [上海, 北京, 广州, 深圳]
getting from DB,cityId:0103 please wait...
load not exist key from cache : []
  • removalListener:用来监听当缓存里面的key被移除时候触发的事件;
  • build(new CacheLoader<String, Optional>():传入一个CacheLoader类,指定缓存中没有的时候调用 CacheLoader 类的load方法(所以一般需要重写该方法);
  • Optional:当CacheLoader尝试获取数据库中不存在的数据会抛出异常,所以我们这里使用Optional可空对象处理一下。
  • Thread.sleep(2000):缓存我们设置3秒过期,所以两次Sleep以后就会重新获取数据库。

2.3 CallableCache

CallableCache的方式最大的特点在于可以在get的时候动态的指定load的数据源:

public class GuavaCallableCache {
    public static void main(String[] args) {
        final String key = "0101";
        Cache<String, Optional<List<String>>> cache = CacheBuilder.newBuilder()
                .expireAfterWrite(3, TimeUnit.SECONDS)
                .removalListener(notification ->
                        System.out.println("cache expired, remove key : " + notification.getKey())).build();
        try {
            Optional<List<String>> optional;
            System.out.println("load from cache once : " + cache.get(key, () -> Optional.ofNullable(MockDB.getCityListFromDB(key))).orElse(null));
            Thread.sleep(2000);
            System.out.println("load from cache twice : " + cache.get(key, () -> Optional.ofNullable(MockDB.getCityListFromDB(key))).orElse(null));
            Thread.sleep(2000);
            System.out.println("load from cache third : " + cache.get(key, () -> Optional.ofNullable(MockDB.getCityListFromDB(key))).orElse(null));
            Thread.sleep(2000);
            final String nullKey = "0103";
            optional = cache.get(nullKey, () -> Optional.ofNullable(MockDB.getCityListFromDB(nullKey)));
            System.out.println("load not exist key from cache : " + optional.orElse(null));
        } catch (ExecutionException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

结果:
etting from DB,cityId:0101 please wait...
load from cache once : [上海, 北京, 广州, 深圳]
load from cache two : [上海, 北京, 广州, 深圳]
cache expired, remove key : 0101
getting from DB,cityId:0101 please wait...
load from cache three : [上海, 北京, 广州, 深圳]
getting from DB,cityId:0103 please wait...
load not exist key from cache : []
  • 与上面例子唯一的不同就是没有在build的时候传入CacheLoader,而是在cache.get使用Cache的时候用传入Callable对象。
  • 这样做可以灵活配置每次获取的缓存源不一样,但是两种方案都各有好处,大家再使用的时候可以酌情选择哈~。

3.源码剖析

3.1 Guava Cache类图结构

image.png

  • Cache:定义get、put、invalidate等操作,这里只有缓存增删改的操作,没有数据加载的操作。
  • LoadingCache:接口,继承自Cache。定义get、getUnchecked、getAll等操作,这些操作都会从数据源load数据。
  • CacheLoader :抽象类。用于从数据源加载数据,定义load、reload、loadAll等操作;
  • CacheBuilder:缓存构建器。构建缓存的入口,用于指定缓存配置参数和初始化本地缓存。 主要采用builder的模式,可以很方便定义你的本地缓存,不过需要注意的是build方法有重载的,这个后面会具体介绍 不同的加载方式。
  • LocalManualCache:作为LocalCache的一个内部类,实现Cache接口,在构造方法里面会把LocalCache类型的变量传入,所以其内部的增删改缓存操作全部调用成员变量 localCache(LocalCache类型)的相应方法。
  • LocalLoadingCache :可以看到该类继承了LocalManualCache并实现接口LoadingCache。覆盖了get,getUnchecked等方法。
  • LocalCache:整个guava cache的核心类,包含了guava cache的数据结构以及基本的缓存的操作方法。后面会详细分析

3.2 LocalCache

Guava Cache中的核心类,数据结构图如下所示:

image.png

通过上图可以看出,LocalCache的数据结构与ConcurrentHashMap很相似,采用了分段策略,通过减小锁的粒度来提高并发 ,LocalCache中数据存储在Segment[]中,每个segment又包含5个队列和一个table,这个table是自定义的一种类数组的结构,每个元素都包含一个ReferenceEntry<k,v>链表,指向next entry。这些队列,前2个是key、value引用队列用以加速GC回收,后3个队列记录用户的写记录、访问记录、高频访问顺序队列用以实现LRU算法。AtomicReferenceArray是JUC包下的Doug Lea老李头设计的类:一组对象引用,其中元素支持原子性更新。这种实现比ConcurrentHashMap要复杂的多,除了多了5个引用队列之外,并且采用了ReferenceEntry的方式,引用数据存储接口,默认强引用,具体类图如下:

image.png

3.3 回收策略

前面说到Guava Cache与ConcurrentHashMap很相似,包括其并发策略,数据结构等,但也不完全一样。最基本的区别是ConcurrentHashMap会一直保存所有添加的元素,直到显式地移除,而guava cache可以自动回收元素,这在某种情况下可以更好优化资源,避免浪费的情况。

3.3.1 缓存回收方式

  • 基于容量回收

    CacheBuilder.maximumSize(long):缓存将尝试回收最近没有使用或总体上很少使用的缓存项,除此之外,你还可以通过对缓存设定不同的权重,来决定它的回收顺序

LoadingCache<Key, PoiDto> poi = CacheBuilder.newBuilder()
        .maximumWeight(100000)
        .weigher(new Weigher<Key, PoiDto>() {
            public int weigh(Key k, PoiDto p) {
                return p.power();
            }
        })
        .build(
            new CacheLoader<Key, PoiDto>() {
                public PoiDto load(Key key) { // no checked exception
                    return queryByDB(key);
                }
            });

1:容量回收机制是在缓存项的数目达到限定值之前,缓存就可能进行回收操作——通常来说,这种情况发生在缓存项的数目逼近限定值时,所以在定义这个值的时候需要视情况适量地增大一点。

2:重量是在缓存创建时计算的,因此要考虑重量计算的复杂度。

  • 定时回收
  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。\
  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。
  • refreshAfterWrite当缓存项上一次更新操作之后的多久会被刷新
  • 基于引用回收
  • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。\
  • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。\
  • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。
  • 显式清除

当然,任何时候你都可以显示的清除缓存项,而不用等到它满足某种回收机制被回收

个别清除:Cache.invalidate(key)

批量清除:Cache.invalidateAll(keys)

清除所有缓存项:Cache.invalidateAll()

3.3.2 回收时机

前面讲了一堆的回收方式,那么这些机制到底是在什么时候触发的呢?在这之前我也和你一样好奇,我们假设一下:guava cache内部会单独起个线程去扫描,如果数据过期就将其删除,就像我们团购也有定时的job去清理那些创建之后长时间没有提交的团单一样;但真实的情况是怎样的呢?不知道大家还记不记得最开始我们的那两个使用例子里其实就有线索,我们会发现,当我们get某些值的时候,他会顺便将某些过期的缓存值清除,我们还打印出来了“cache expired, remove key : 0101”,顺着这个思路我们来分析下这个get方法吧,看看它到底是在什么时候清除的

V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
      checkNotNull(key);
      checkNotNull(loader);
      try {
        if (count != 0) { // read-volatile
          // don't call getLiveEntry, which would ignore loading values
          ReferenceEntry<K, V> e = getEntry(key, hash);
          if (e != null) {
            long now = map.ticker.read();
            //我们发现在get方法中,有个getLiveValue(),这个方法是拿到当前可用的缓存值,那不可用的值呢?何时剔除的
            V value = getLiveValue(e, now);
            if (value != null) {
              recordRead(e, now);
              statsCounter.recordHits(1);
              return scheduleRefresh(e, key, hash, value, now, loader);
            }
            ValueReference<K, V> valueReference = e.getValueReference();
            if (valueReference.isLoading()) {
              return waitForLoadingValue(e, key, valueReference);
            }
          }
        }

        // 如果不存在或者过期,就通过loader方法进行加载(注意这里会加锁清清理GC遗留引用数据和超时数据);
        return lockedGetOrLoad(key, hash, loader);
      } catch (ExecutionException ee) {
        Throwable cause = ee.getCause();
        if (cause instanceof Error) {
          throw new ExecutionError((Error) cause);
        } else if (cause instanceof RuntimeException) {
          throw new UncheckedExecutionException(cause);
        }
        throw ee;
      } finally {
        postReadCleanup();
      }
    }

注意:在cache get数据的时候,如果链表上找不到entry,或者value已经过期,则调用lockedGetOrLoad()方法,这个方法会锁住整个segment,直到从数据源加载数据,更新缓存,所以如果并发量比较大,又遇到很多key失效的情况就会很容易导致线程block,需要慎重考虑是否这个特性,采用定时refresh机制

我们发现在get方法中,有个getLiveValue(),这个方法是拿到当前可用的缓存值,那不可用的值呢?是在什么时候被淘汰的呢?,继续

 /**
     * Gets the value from an entry. Returns null if the entry is invalid, partially-collected,
     * loading, or expired.
     */
    V getLiveValue(ReferenceEntry<K, V> entry, long now) {
      if (entry.getKey() == null) {
        tryDrainReferenceQueues();
        return null;
      }
      V value = entry.getValueReference().get();
      if (value == null) {
        tryDrainReferenceQueues();
        return null;
      }
      //就是在这里map.isExpired(entry, now)满足条件执行清除tryExpireEntries(now)
      if (map.isExpired(entry, now)) {
        tryExpireEntries(now);
        return null;
      }
      return value;
    }

就是在这里map.isExpired(entry, now)满足条件执行清除tryExpireEntries(now);核心代码如下:

  @GuardedBy("this")
    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();
        }
      }
    }

到这里已经很明了了,guava cache并不会显示的启动一个线程去处理这些过期的缓存项,而是在每次进行缓存操作的时候,如get()或者put()的时候,判断缓存是否过期,过期就将其处理掉;

  • 新起线程需要资源消耗。
  • 维护过期数据还要获取额外的锁,增加了消耗。

当然如果该缓存迟迟没有访问也会存在数据不能被回收的情况,所以这个使用的时候需要稍微注意一下

同时我们在跟踪清除的时候,其实也顺便的把cache的get()逻辑梳理了一遍,这里我总结了下具体的get流程:

image.png

3.4 刷新

通过前面的讲解,我们知道当大量线程用相同的key获取缓存值时,如果此时缓存过期了,那么只会有一个线程进入load方法,而其他线程则等待,直到缓存值被生成,这个听起来似乎是一种很危险的操作啊!不用担心,Guava还提供了另一种缓存策略,缓存值定时刷新更新线程调用load方法更新该缓存,其他请求线程返回该缓存的旧值,这样对于某个key的缓存来说,只会有一个线程被阻塞这里就需要用到Guava cache的refreshAfterWrite方法。如下所示:

 LoadingCache<String, Optional<List<String>>> loadingCache = CacheBuilder
                .newBuilder()
                .refreshAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Optional<List<String>>>() {
                    @Override
                    public Optional<List<String>> load(String key) throws Exception {
                        return Optional.ofNullable(MockDB.getCityListFromDB(key));
                    }
                });
try {
    System.out.println(loadingCache.get("0101").orElse(Lists.newArrayList()));
} catch (ExecutionException e) {
    e.printStackTrace();
}

注意:这里的定时并不是真正意义上的定时。Guava cache的刷新需要依靠用户请求线程,让该线程去进行load方法的调用,所以如果一直没有用户尝试获取该缓存值,则该缓存也并不会刷新

refreshAfterWrite() 方法解决了同一个key的缓存过期时会让多个线程阻塞的问题,因为他只会让用来执行刷新缓存操作的一个用户线程会被阻塞,但是聪明的你一定也发现了另一个问题:当缓存的key很多时,高并发条件下大量线程同时获取不同key对应的缓存,此时依然会造成大量线程阻塞,并且给数据库带来很大压力。这个怎么解决呢?请看代码:

ListeningExecutorService backgroundRefreshPools =
                MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));
        LoadingCache<String, Optional<List<String>>> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .refreshAfterWrite(1000, TimeUnit.MILLISECONDS)
                .build(new CacheLoader<String, Optional<List<String>>>() {
                   @Override
                    public Optional<List<String>> load(String key) throws Exception {
                        return Optional.ofNullable(MockDB.getCityListFromDB(key));
                    }

                    @Override
                    public ListenableFuture<Optional<List<String>>> reload(String key,
                                                                           Optional<List<String>> optionalStrings) throws Exception {
                        return backgroundRefreshPools.submit(new Callable<Optional<List<String>>>() {
                            @Override
                            public Optional<List<String>> call() throws Exception {
                                System.out.println(Thread.currentThread().getName()+"定时异步去更新");
                                return Optional.ofNullable(MockDB.getCityListFromDB(key));
                            }
                        });
                    }
                });


        try {
            Thread.sleep(2000);
            System.out.println("第一次拿:"+caches.get(key));
            Thread.sleep(2000);
            System.out.println("第二次拿:"+caches.get(key));
        } catch (Exception e) {
            e.printStackTrace();
        }

没错,解决办法就是将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值,这样就不会有用户线程被阻塞了。在上面的代码中,我们新建了一个线程池,用来执行缓存刷新任务。并且重写了CacheLoader的reload方法,在该方法中建立缓存刷新的任务并提交到线程池。 注意此时缓存的刷新依然需要靠用户线程来驱动,只不过和代码1的不同之处在于该用户线程触发刷新操作之后,会立马返回旧的缓存值

[注意:这种方案还是需要依赖用户主动请求来触发,如果没有用户请求,并且配置expireAfterAccess 定时失效后,缓存失效后用户再次请求时还是会触发同步load同步构建缓存]

参考文献:

  1. juejin.cn/post/684490…
  2. www.cnblogs.com/aspirant/p/…
  3. www.jianshu.com/p/637a442d4…
  4. cloud.tencent.com/developer/a…