深入理解Guava Cache的refresh和expire刷新机制
一、思考和猜想
首先看一下三种基于时间的清理或刷新缓存数据的方式:
expireAfterAccess: 当缓存项在指定的时间段内没有被读或写就会被回收。
expireAfterWrite:当缓存项在指定的时间段内没有更新就会被回收。
refreshAfterWrite:当缓存项上一次更新操作之后的多久会被刷新。
考虑到时效性,我们可以使用expireAfterWrite,使每次更新之后的指定时间让缓存失效,然后重新加载缓存。guava cache会严格限制只有1个加载操作,这样会很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应。
然而,通过分析源码,guava cache在限制只有1个加载操作时进行加锁,其他请求必须阻塞等待这个加载操作完成;而且,在加载完成之后,其他请求的线程会逐一获得锁,去判断是否已被加载完成,每个线程必须轮流地走一个“获得锁,获得值,释放锁”的过程,这样性能会有一些损耗。这里由于我们计划本地缓存1秒,所以频繁的过期和加载,锁等待等过程会让性能有较大的损耗。
因此我们考虑使用refreshAfterWrite。refreshAfterWrite的特点是,在refresh的过程中,严格限制只有1个重新加载操作,而其他查询先返回旧值,这样可以有效地减少等待和锁争用,所以refreshAfterWrite会比expireAfterWrite性能好。但是它也有一个缺点,因为到达指定时间后,它不能严格保证所有的查询都获取到新值。了解过guava cache的定时失效(或刷新)原来的同学都知道,guava cache并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过指定时间则进行加载或刷新。所以,如果使用refreshAfterWrite,在吞吐量很低的情况下,如很长一段时间内没有查询之后,发生的查询有可能会得到一个旧值(这个旧值可能来自于很长时间之前),这将会引发问题。
可以看出refreshAfterWrite和expireAfterWrite两种方式各有优缺点,各有使用场景。那么能否在refreshAfterWrite和expireAfterWrite找到一个折中?比如说控制缓存每1s进行refresh,如果超过2s没有访问,那么则让缓存失效,下次访问时不会得到旧值,而是必须得待新值加载。由于guava官方文档没有给出一个详细的解释,查阅一些网上资料也没有得到答案,因此只能对源码进行分析,寻找答案。经过分析,当同时使用两者的时候,可以达到预想的效果,这真是一个好消息呐!
二、源码分析
通过追踪LoadingCache的get方法源码,发现最终会调用以下核心方法,下面贴出源码:
com.google.common.cache.LocalCache.Segment.get方法:
这个缓冲的get方法,编号1是判断是否有存活值,即根据expireAfterAccess和expireAfterWrite进行判断是否过期,如果过期,则value为null,执行编号3。编号2指不过期的情况下,根据refreshAfterWrite判断是否需要refresh。而编号3是需要进行加载(load而非reload),原因是没有存活值,可能因为过期,可能根本就没有过该值。从段代码来看,在get的时候,是先判断过期,再判断refresh,所以我们可以通过设置refreshAfterWrite为1s,将expireAfterWrite 设为2s,当访问频繁的时候,会在每秒都进行refresh,而当超过2s没有访问,下一次访问必须load新值。
我们继续顺藤摸瓜,顺带看看load和refresh分别都做了什么事情,验证以下上面说的理论。
下面看看 com.google.common.cache.LocalCache.Segment.lockedGetOrLoad方法:
这个方法有点长,限于篇幅,没有贴出全部代码,关键步骤有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会先阻塞住,验证了之前的理论。
下面看看com.google.common.cache.LocalCache.Segment.scheduleRefresh方法:
1、判断是否需要refresh,且当前非loading状态,如果是则进行refresh操作,并返回新值。
2、步骤2是我加上去的,为后面的测试做准备。如果需要refresh,但是有其他线程正在对该值进行refreshing,则打印,最终会返回旧值。
继续深入步骤1中调用的refresh方法:
1、插入loadingValueReference,表示该值正在loading,其他请求根据此判断是需要进行refresh还是返回旧值。insertLoadingValueReference里有加锁操作,确保只有1个refresh穿透到后端。限于篇幅,这里不再展开。但是,这里加锁的范围比load时候加锁的范围要小,在expire->load的过程,所有的get一旦知道expire,则需要获得锁,直到得到新值为止,阻塞的影响范围会是从expire到load到新值为止;而refresh->reload的过程,一旦get发现需要refresh,会先判断是否有loading,再去获得锁,然后释放锁之后再去reload,阻塞的范围只是insertLoadingValueReference的一个小对象的new和set操作,几乎可以忽略不计,所以这是之前说refresh比expire高效的原因之一。
2、进行refresh操作,这里不对loadAsync进行展开,它调用了CacheLoader的reload方法,reload方法支持重载去实现异步的加载,而当前线程返回旧值,这样性能会更好,其默认是同步地调用了CacheLoader的load方法实现。
到这里,我们知道了refresh和expire的区别了吧!refresh执行reload,而expire后会重新执行load,和初始化时一样。
三、测试和验证
在上面贴出的源码,大家应该注意到一些System.out.println语句,这些是我加上去的,便于后续进行测试验证。现在就来对刚刚的分析进行程序验证。
贴出测试的源码:
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache ; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; public class ConcurrentTest { private static final int CONCURRENT_NUM = 10;//并发数 private volatile static int value = 1; private static LoadingCache <String, String> cache = CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(5, TimeUnit. SECONDS) .refreshAfterWrite(1, TimeUnit. SECONDS) .build(newCacheLoader<String, String>() { public String load(String key) throws InterruptedException { System. out.println( "load by " + Thread.currentThread().getName()); return createValue(key); } @Override public Listenable Future<String> reload(String key, String oldValue) throwsException { System. out.println( "reload by " + Thread.currentThread().getName()); return Futures.immediateFuture(createValue(key )); } } ); //创建value private static String createValue(String key) throws InterruptedException{ Thread. sleep(1000L);//让当前线程sleep 1秒,是为了测试load和reload时候的并发特性 return String.valueOf(value++); } public static void main(String[] args) throws InterruptedException, ExecutionException { CyclicBarrier barrier = newCyclicBarrier(CONCURRENT_NUM ); CountDownLatch latch = newCountDownLatch(CONCURRENT_NUM ); for(inti = 0; i < CONCURRENT_NUM; i++) { finalClientRunnable runnable = newClientRunnable(barrier, latch ); Thread thread = newThread( runnable, "client-"+ i); thread.start(); } //测试一段时间不访问后是否执行expire而不是refresh latch.await(); Thread.sleep(5100L); System.out.println( "\n超过expire时间未读之后..."); System.out.println(Thread. currentThread().getName() + ",val:"+ cache .get("key")); } static class Client Runnable implementsRunnable{ CyclicBarrier barrier; CountDownLatch latch; public Client Runnable(CyclicBarrier barrier, CountDownLatch latch){ this.barrier = barrier; this.latch = latch; } public void run() { try{ barrier.await(); Thread.sleep((long)(Math.random()*4000));//每个client随机睡眠,为了充分测试refresh和load System.out.println(Thread. currentThread().getName() + ",val:"+ cache .get("key")); latch.countDown(); }catch(Exception e) { e.printStackTrace(); } } } }
执行结果:
验证结果和预期一致:
1、在缓存还没初始化的时候,client-1最新获得了load锁,进行load操作,在进行load的期间,其他client也到达进入load过程,阻塞,等待client-1释放锁,再依次获得锁。最终只load by client-1。
2、当超过了refreshAfterWrite设定的时间之内没有访问,需要进行refresh,client-5进行 refresh,在这个过程中,其他client并没有获得锁,而是直接查询旧值,直到refresh后才得到新值,过渡平滑。
3、在超过了expireAfterWrite设定的时间内没有访问,main线程在访问的时候,值已经过期,需要进行load操作,而不会得到旧值。
转载于:https://blog.csdn.net/abc86319253/article/details/53020432