Guava工程包含了若干被Google的Java项目广泛依赖的核心库, Guava Cache是Guava的核心库之一,Guava Cache是一个全内存的本地缓存实现,它提供了线程安全的实现机制。整体上来说Guava cache 是本地缓存的不二之选,简单易用,性能好。今天我们就来聊聊guava cache的那些事儿。
Guava Cache继承了ConcurrentHashMap的设计思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache类似于Map,它是存储键值对的集合,不同的是它还需要处理expire、dynamic load等算法逻辑,需要一些额外信息来实现这些操作。对此,根据面向对象思想,需要做方法与数据的关联封装。
需要说明的是:
每一个Segment中的有效队列(废弃队列不算)的个数最多可能不止一个, 队列用于实现LRU缓存回收算法,数据超过设置的最大值时,使用LRU算法移除;
其中的ReferenceEntry[i]用于存放key-value,ReferenceEntry是对一个键值对节点的抽象,缓存的key被封装在 WeakReference引用内, value被封装在 WeakReference或SoftReference引用内
每个ReferenceEntry数组项都是一条ReferenceEntry链,自动将entry节点加载进缓存结构中。
多个Segment之间互不打扰,可以并发执行
各个Segment的扩容只需要扩自己的就好,与其他Segment无关
在保证缓存命中率的前提下,根据需要根据时间、空间两个维度设置好初始化容量与并发水平参数
Guava cache 的数据回收策略整体有三种回收方案,分为基于弱引用的回收策略、基于容量的回收策略,以及基于时间的回收策略
1、基于引用的回收
通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:
CacheBuilder.weakKeys()使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用键的缓存用==而不是equals比较键。
CacheBuilder.weakValues()使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用值的缓存用==而不是equals比较值。CacheBuilder.softValues()使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见下文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。
2、基于容量回收策略
maximumSize(long)可以设置缓存的最大容量。缓存将会尝试回收最近没有使用,或者没有经常使用的缓存项。警告:缓存可能会在容量达到限制之前执行回收,通常是在缓存大小逼近限制大小时。
另外,如果不同的缓存项有不同的“权重”,缓存项有不同的内存占用,此时你需要使用CacheBuilder.weigher(Weigher)指定一个权重计算函数,并使用CacheBuilder.maxmumWeight(long)设定总权重。和maximumSize同样需要注意的是缓存也是在逼近总权重的时候进行回收处理。此外,缓存项的权重是在创建时进行计算,此后不再改变。
3、基于时间回收策略
guava cache三种基于时间的清理或刷新缓存数据的方式:
expireAfterAccess: 当缓存项在指定的时间段内没有被读或写就会被回收。
这种缓存的回收顺序和基于大小回收一样。当缓存中存储的数据达到过期时间没有被读写,则数据就会被回收,如果数据一直处于被读写状态,数据一直不会被回收,这种情况就可能导致数据脏读的发生。
expireAfterWrite:当缓存项在指定的时间段内没有更新就会被回收。
使用expireAfterWrite,使每次更新之后的指定时间让缓存失效,然后重新load缓存。在并发的场景下,guava cache会严格限制只有1个加载操作,这样会很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应。
然而,通过分析源码,guava cache在限制只有1个加载操作时进行加锁,并发场景下,其他请求必须阻塞等待这个加载操作完成;而且,在加载完成之后,其他请求的线程会逐一获得锁,去判断是否已被加载完成,每个线程必须轮流地走一个“”获得锁,获得值,释放锁“”的过程,这样会导致系统的请求抛弃量的增加,同时在QPS的表现上会出现波峰波谷走势(当请求命中缓存时服务QPS瞬间上升,缓存时效时QPS瞬间降低的显现)。
refreshAfterWrite:当缓存项上一次更新操作之后的多久会被刷新。
refreshAfterWrite的特点是,在refresh的过程中,严格限制只有1个重新加载操作,而其他查询先返回旧值,这样有效地可以减少等待和锁竞争导致的阻塞,所以refreshAfterWrite会比expireAfterWrite性能好。但是它也有一个缺点,因为到达指定时间后,它不能严格保证所有的查询都获取到新值。guava cache并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过设置的时间会选取1个线程进行加载或刷新。所以,如果使用refreshAfterWrite,在吞吐量很低的情况下,如很长一段时间内没有查询之后产生瞬时并发的场景下,由于请求不等待缓存的加载完成而是直接返回缓存中的旧值,这个旧值有可能是很长时间之前的数据,这将会在一些时效性很高的场景下引发问题。
可以看出refreshAfterWrite和expireAfterWrite两种方式各有优缺点,各有使用场景。在金币商城的春运项目中,我们首次在项目中,针对瞬时流量高峰的一些场景,我们使用guava cache,并在refreshAfterWrite和expireAfterWrite找到了一个折中的解决方案,比如商城的商品信息,控制缓存每3s进行refresh,如果超过5s没有访问,那么则让缓存失效,下次访问时不会得到旧值,而是必须得待新值加载。
通过追踪LoadingCache的get方法源码(以下源码为guava 18.0版本),发现最终会调用以下核心方法,下面贴出源码:
com.google.common.cache.LocalCache.Segment.get方法:
@Nullable
V get(Object key, int hash) {
try{
if (this.count != 0) {
now = this.map.ticker.read();
LocalCache.ReferenceEntry e =getLiveEntry(key, hash, now);
if (e == null) {
Object localObject1 = null;
return localObject1;
}
Object value =e.getValueReference().get();
if (value != null) {
recordRead(e, now);
Object localObject2 =scheduleRefresh(e, e.getKey(), hash, value, now, this.map.defaultLoader);
return localObject2;
}
tryDrainReferenceQueues();
}
long now = null;
return now; } finally { postReadCleanup();
}
}
这个缓冲的get方法,判断是否有存活值,先查找LocalCache中是否已存在entry没有被回收、也没有expire的entry,如果找到,并在CacheBuilder中配置了refreshAfterWrite,并且当前时间间隔已经操作这个事件,则重新加载值,否则,直接返回原有的值,即根据expireAfterAccess和expireAfterWrite进行判断是否过期,如果过期,则value为null,根据refreshAfterWrite判断队列中的数据是否需要refresh。
从段代码来看,在get的时候,是先判断过期,再判断refresh,所以我们在实际使用时可以通过设置refreshAfterWrite为3s,将expireAfterWrite 设为5s,当访问频繁的时候,会在每3秒都进行缓存数据预refresh,而地吞吐量的时候,当超过5s没有访问,缓存的数据进行过期失效处理,下一次访问会将cache中的数据进行更新。
下面看看com.google.common.cache.LocalCache.Segment.scheduleRefresh方法:
VscheduleRefresh(LocalCache.ReferenceEntry<K, V> entry, K key, int hash, V oldValue, long now, CacheLoader<? super K, V> loader)
{
if((this.map.refreshes())&& (now - entry.getWriteTime() > this.map.refreshNanos) &&(!(entry.getValueReference().isLoading())))
{
Object newValue = refresh(key, hash,loader, true);
if (newValue != null) {
return newValue;
}
}
returnoldValue;
}
判断是否需要refresh,且缓存时间已失效,且当前非loading状态,如果是则进行refresh操作,并返回新值,否则直接返回缓存中的oldValue;
我们进一步的查看scheduleRefresh中调用的refresh方法,源码如下
@Nullable
V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime)
{
LocalCache.LoadingValueReference loadingValueReference =insertLoadingValueReference(key, hash, checkTime);
if(loadingValueReference == null) {
returnnull;
}
ListenableFuture result = loadAsync(key, hash, loadingValueReference,loader);
if(result.isDone())
try {
return Uninterruptibles.getUninterruptibly(result);
}
catch (Throwable t)
{
}
returnnull;
}
插入loadingValueReference,表示该值正在loading,其他请求根据此判断是需要进行refresh还是返回旧值。insertLoadingValueReference里有加锁操作,确保只有1个refresh穿透到后端。限于篇幅,这里不再展开。但是,这里加锁的范围比load时候加锁的范围要小,在expire->load的过程,所有的get一旦知道expire,则需要获得锁,直到得到新值为止,阻塞的影响范围会是从expire到load到新值为止;而refresh->reload的过程,一旦get发现需要refresh,会先判断是否有loading,再去获得锁,然后释放锁之后再去reload,阻塞的范围只是insertLoadingValueReference的一个小对象的new和set操作,几乎可以忽略不计,所以这是之前说refresh比expire高效的原因之一。
到这里,我们知道了refresh和expire的区别:refresh执行reload,而expire后会重新执行load,和初始化时一样。
下面看看com.google.common.cache.LocalCache.Segment.lockedGetOrLoad方法:
V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException
{
LocalCache.ValueReference valueReference = null;
LocalCache.LoadingValueReference loadingValueReference = null;
booleancreateNewEntry = true;
lock();
LocalCache.ReferenceEntry e;
try
{
long now = this.map.ticker.read();
preWriteCleanup(now);
int newCount = this.count - 1;
AtomicReferenceArray table = this.table;
int index = hash & table.length() - 1;
LocalCache.ReferenceEntry first =(LocalCache.ReferenceEntry)table.get(index);
for (e = first; e != null; e = e.getNext()) {
Object entryKey = e.getKey();
if ((e.getHash() != hash) || (entryKey == null) || (!(this.map.keyEquivalence.equivalent(key,entryKey))))
continue;
valueReference =e.getValueReference();
if (valueReference.isLoading()) {
createNewEntry = false; break;
}
Object value = valueReference.get();
if (value == null) {
enqueueNotification(entryKey, hash,valueReference, RemovalCause.COLLECTED);
} elseif (this.map.isExpired(e,now))
{
enqueueNotification(entryKey, hash,valueReference, RemovalCause.EXPIRED);
} else {
recordLockedRead(e, now);
this.statsCounter.recordHits(1);
Object localObject2 = value;
return localObject2;
}
this.writeQueue.remove(e);
this.accessQueue.remove(e);
this.count = newCount;
break;
}
if (createNewEntry) {
loadingValueReference = new LocalCache.LoadingValueReference();
if (e == null) {
e = newEntry(key, hash, first);
e.setValueReference(loadingValueReference);
table.set(index, e);
} else {
e.setValueReference(loadingValueReference);
}
}
} finally{
unlock();
postWriteCleanup();
}
if(createNewEntry)
{
try
{
synchronized (e) {
Object localObject1 = loadSync(key,hash, loadingValueReference, loader);
this.statsCounter.recordMisses(1); return localObject1; } } finally { this.statsCounter.recordMisses(1);
}
}
returnwaitForLoadingValue(e, key, valueReference);
}
步骤有7步。
1.获得锁
2.获得key对应的valueReference
3.判断是否该缓存值正在loading,如果loading,则不再进行load操作(通过设置createNewEntry为false),后续会等待获取新值。
4.如果不是在loading,判断是否已经有新值了(被其他请求load完了),如果是则返回新值
5.准备loading,设置为loadingValueReference。loadingValueReference 会使其他请求在步骤3的时候会发现正在loding。
6.释放锁。
7.如果真的需要load,则进行load操作。
通过分析发现,只会有1个load操作,其他get会先阻塞住。
综上所述,在日常使用guava cache的过程中,建议使用如下的方式进行缓存策略的管理,具体代码如下:
private LoadingCache<String,Optional< Object >> demoCahce = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(500).recordStats()
.refreshAfterWrite(3, TimeUnit.SECONDS)
.build(new CacheLoader<String, Optional<Object>>(){
@Override
public Optional<Object> load(String key) throws Exception {
return Optional.fromNullable(takeAwardService.getHornNotice());
}
});
在缓存的初始化和管理上,建议将时间回收策略与容量回收策略同时设置的方式进行缓存管理策略,使用expireAfterWrite为缓存中的数据进行过期时效管理,使用refreshAfterWrite方法完成缓存数据的预更新操作,这样既解决了单独使用expireAfterWrite导致的缓存穿透问题,又解决了只使用refreshAfterWrite导致的数据过旧的问题,同时使用maximumSize对缓冲的容量进行限制,这样就在时间和空间两个维度对缓存进行了高效的内存管理。