本文内容包括Guava Cache的使用、核心机制的讲解、核心源代码的分析以及最佳实践的说明。
概要
Guava Cache是一款非常优秀本地缓存,使用起来非常灵活,功能也十分强大。Guava Cache说简单点就是一个支持LRU的ConcurrentHashMap,并提供了基于容量,时间和引用的缓存回收方式。
本文详细的介绍了Guava Cache的使用注意事项,即最佳实践,以及作为一个Local Cache的实现原理。
应用及使用
应用场景
- 读取热点数据,以空间换时间,提升时效
- 计数器,例如可以利用基于时间的过期机制作为限流计数
基本使用
Guava Cache提供了非常友好的基于Builder构建者模式的构造器,用户只需要根据需求设置好各种参数即可使用。Guava Cache提供了两种方式创建一个Cache。
CacheLoader
CacheLoader可以理解为一个固定的加载器,在创建Cache时指定,然后简单地重写V load(K key) throws Exception方法,就可以达到当检索不存在的时候,会自动的加载数据的。例子代码如下:
//创建一个LoadingCache,并可以进行一些简单的缓存配置
private static LoadingCache<String, String > loadingCache = CacheBuilder.newBuilder()
//最大容量为100(基于容量进行回收)
.maximumSize(100)
//配置写入后多久使缓存过期-下文会讲述
.expireAfterWrite(150, TimeUnit.SECONDS)
//配置写入后多久刷新缓存-下文会讲述
.refreshAfterWrite(1, TimeUnit.SECONDS)
//key使用弱引用-WeakReference
.weakKeys()
//当Entry被移除时的监听器
.removalListener(notification -> log.info("notification={}", GsonUtil.toJson(notification)))
//创建一个CacheLoader,重写load方法,以实现"当get时缓存不存在,则load,放到缓存,并返回"的效果
.build(new CacheLoader<String, String>() {
//重点,自动写缓存数据的方法,必须要实现
@Override
public String load(String key) throws Exception {
return "value_" + key;
}
//异步刷新缓存-下文会讲述
@Override
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
return super.reload(key, oldValue);
}
});
@Test
public void getTest() throws Exception {
//测试例子,调用其get方法,cache会自动加载并返回
String value = loadingCache.get("1");
//返回value_1
log.info("value={}", value);
}
Callable
在上面的build方法中是可以不用创建CacheLoader的,不管有没有CacheLoader,都是支持Callable的。Callable在get时可以指定,效果跟CacheLoader一样,区别就是两者定义的时间点不一样,Callable更加灵活,可以理解为Callable是对CacheLoader的扩展。例子代码如下:
@Test
public void callableTest() throws Exception {
String key = "1";
//loadingCache的定义跟上一面一样
//get时定义一个Callable
String value = loadingCache.get(key, new Callable<String>() {
@Override
public String call() throws Exception {
return "call_" + key;
}
});
log.info("call value={}", value);
}
其他用法
显式插入:
支持loadingCache.put(key, value)
方法直接覆盖key的值。
显式失效:
支持loadingCache.invalidate(key)
或 loadingCache.invalidateAll()
方法,手动使缓存失效。
缓存失效机制
Guava Cache有一套十分优秀的缓存失效机制,这里主要介绍的是基于时间的失效回收。
缓存失效的目的是让缓存进行重新加载,即刷新,使调用者可以正常访问获取到最新的数据,而不至于返回null或者直接访问DB。
从上面的例子中我们知道与失效/缓存刷新相关配置有 expireAfterWrite / expireAfterAccess、refreshAfterWrite 还有 CacheLoader的reload方法。
一般用法
expireAfterWrite/expireAfterAccess
使用背景
如果对缓存设置过期时间,在高并发下同时执行get操作,而此时缓存值已过期了,如果没有保护措施,则会导致大量线程同时调用生成缓存值的方法,比如从数据库读取,对数据库造成压力,这也就是我们常说的“缓存击穿”。
做法
而Guava cache则对此种情况有一定控制。当大量线程用相同的key获取缓存值时,只会有一个线程进入load方法,而其他线程则等待,直到缓存值被生成。这样也就避免了缓存击穿的危险。这两个配置的区别前者记录写入时间,后者记录写入或访问时间,内部分别用writeQueue和accessQueue维护。
PS: 但是在高并发下,这样还是会阻塞大量线程。
refreshAfterWrite
使用背景
使用 expireAfterWrite 会导致其他线程阻塞。
做法
更新线程调用load方法更新该缓存,其他请求线程返回该缓存的旧值。
异步刷新
使用背景
单个key并发下,使用refreshAfterWrite,虽然不会阻塞了,但是如果恰巧同时多个key同时过期,还是会给数据库造成压力,这就是我们所说的“缓存雪崩”。
做法
这时就要用到异步刷新,将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值。
方法是覆盖CacheLoader的reload方法,使用线程池去异步加载数据
PS:只有重写了 reload 方法才有“异步加载”的效果。默认的 reload 方法就是同步去执行 load 方法。
总结
大家都应该对各个失效/刷新机制有一定的理解,清楚在各个场景可以使用哪个配置,简单总结一下:
- expireAfterWrite 是允许一个线程进去load方法,其他线程阻塞等待。
- refreshAfterWrite 是允许一个线程进去load方法,其他线程返回旧的值。
- 在上一点基础上做成异步,即回源线程不是请求线程。异步刷新是用线程异步加载数据,期间所有请求返回旧的缓存值。
实现原理
数据结构
Guava Cache的数据结构跟JDK1.7的ConcurrentHashMap类似,如下图所示:
LoadingCache
LoadingCache即是我们API Builder返回的类型,类继承图如下:
LocalCache
LoadingCache这些类表示获取Cache的方式,可以有多种方式,但是它们的方法最终调用到LocalCache的方法,LocalCache是Guava Cache的核心类。看看LocalCache的定义:
class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>
说明Guava Cache本质就是一个Map。
LocalCache的重要属性:
//Map的数组
final Segment<K, V>[] segments;
//并发量,即segments数组的大小
final int concurrencyLevel;
//key的比较策略,跟key的引用类型有关
final Equivalence<Object> keyEquivalence;
//value的比较策略,跟value的引用类型有关
final Equivalence<Object> valueEquivalence;
//key的强度,即引用类型的强弱
final Strength keyStrength;
//value的强度,即引用类型的强弱
final Strength valueStrength;
//访问后的过期时间,设置了expireAfterAccess就有
final long expireAfterAccessNanos;
//写入后的过期时间,设置了expireAfterWrite就有
final long expireAfterWriteNa就有nos;
//刷新时间,设置了refreshAfterWrite就有
final long refreshNanos;
//removal的事件队列,缓存过期后先放到该队列
final Queue<RemovalNotification<K, V>> removalNotificationQueue;
//设置的removalListener
final RemovalListener<K, V> removalListener;
//时间器
final Ticker ticker;
//创建Entry的工厂,根据引用类型不同
final EntryFactory entryFactory;
Segment
从上面可以看出LocalCache这个Map就是维护一个Segment数组。Segment是一个ReentrantLock
static class Segment<K, V> extends ReentrantLock
看看Segment的重要属性:
//LocalCache
final LocalCache<K, V> map;
//segment存放元素的数量
volatile int count;
//修改、更新的数量,用来做弱一致性
int modCount;
//扩容用
int threshold;
//segment维护的数组,用来存放Entry。这里使用AtomicReferenceArray是因为要用CAS来保证原子性
volatile @MonotonicNonNull AtomicReferenceArray<ReferenceEntry<K, V>> table;
//如果key是弱引用的话,那么被GC回收后,就会放到ReferenceQueue,要根据这个queue做一些清理工作
final @Nullable ReferenceQueue<K> keyReferenceQueue;
//跟上同理
final @Nullable ReferenceQueue<V> valueReferenceQueue;
//如果一个元素新写入,则会记到这个队列的尾部,用来做expire
@GuardedBy("this")
final Queue<ReferenceEntry<K, V>> writeQueue;
//读、写都会放到这个队列,用来进行LRU替换算法
@GuardedBy("this")
final Queue<ReferenceEntry<K, V>> accessQueue;
//记录哪些entry被访问,用于accessQueue的更新。
final Queue<ReferenceEntry<K, V>> recencyQueue;
ReferenceEntry
ReferenceEntry就是一个Entry的引用,有几种引用类型:
我们拿StrongEntry为例,看看有哪些属性:
final K key;
final