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类图结构
-
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中的核心类,数据结构图如下所示:
通过上图可以看出,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流程:
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机制