一篇搞定Guava Cache做本地缓存

566 阅读9分钟

1、 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引用内;
  • 统计缓存使用过程中命中率、异常率、未命中率等统计数据。

2.Guava Cache的使用

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

愿意消耗一些内存空间来提升速度; 能够预计某些key会被查询一次以上; 缓存中存放的数据总量不会超出内存容量(Guava Cache是单个应用运行时的本地缓存)。 如果上面说的这些都符合你的需求的话,我觉得guava Cache 将会是你很不错的选择

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

  • LoadingCache
  • CallableCache

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

2.1 准备工作

  • 与上面例子唯一的不同就是没有在build的时候传入CacheLoader,而是在cache.get使用Cache的时候用传入Callable对象。
  • 这样做可以灵活配置每次获取的缓存源不一样,但是两种方案都各有好处,大家再使用的时候可以酌情选择哈~。

通过上面的两个例子,相信大家对于guava cache的使用应该没啥问题了,同时大家应该和我一样好奇,它的数据结构到底是怎样的?,以及它又是如何去清理缓存的,带着这些疑问我们继续阅读下面的文章吧!

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算法。

3.3 回收策略

前面说到Guava Cache与ConcurrentMap很相似,包括其并发策略,数据结构等,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除,而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):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

  • 基于引用回收 CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。
    CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。
    CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。

  • 显式清除 当然,任何时候你都可以显示的清除缓存项,而不用等到它满足某种回收机制被回收 个别清除:Cache.invalidate(key) 批量清除:Cache.invalidateAll(keys) 清除所有缓存项:Cache.invalidateAll()

有兴趣可以自己探索源码发现:guava cache并不会显示的启动一个线程去处理这些过期的缓存项,而是在每次进行缓存操作的时候,如get()或者put()的时候,判断缓存是否过期,过期就将其处理掉。(1)新起线程需要资源消耗。(2)维护过期数据还要获取额外的锁,增加了消耗。(3)如果该缓存迟迟没有访问也会存在数据不能被回收的情况,所以这个使用的时候需要稍微注意一下。 同时我们在跟踪清除的时候,其实也顺便的把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的不同之处在于该用户线程触发刷新操作之后,会立马返回旧的缓存值。

使用不当的坑

前面特别提到了一个方法——“lockedGetOrLoad()”,他是在cache get数据时,当cache中没有key或者key过期的时候执行的,并且为了保证线程安全,这个方法会对当前segment加锁,第一个“抢到锁”的线程或执行加载逻辑,而其他获取同样key的线程会等待当前key加载线程加载(上面waitForValue),如果此时加载过程过慢就会引发大部分线程block,这个时候就需要停用cache过期机制,采用定时refresh机制

本文参考:tech.meituan.com/2017/03/17/…