guava cache是一种支持自动回收、刷新的concurrentHashMap。显然防止内存溢出是其核心功能,包括主动刷新refresh、过期失效expire、java的软/虚引用以及设置最大的size和weight。在实际开发中,个人对于expire和refresh使用较多,expire又分为expireAfterAccess、expireAfterWrite,现对比下三者的区别:
expireAfterAccess | expireAfterWrite | refreshAfterWrite | |
---|---|---|---|
功能 | 读写后回收 | 写后回收 | 定时刷新 |
更新方法 | load | load | 首次load,其他reload |
触发线程 | 读取线程 | 读取线程 | 读取线程+异步线程(如果重写reload使用额外线程,默认没异步) |
并行非更新线程同步 | 通过ReentrantLock加锁,其他线程等待加载完成 | 通过ReentrantLock加锁 ,其他线程等待加载完成 | 不加锁,其他线程返回旧值 |
高并发读取问题 | 可能造成同一key永不过期 | - | - |
高并发更新问题 | 频繁上锁解锁导致性能问题 | 频繁上锁解锁导致性能问题 | - |
低频读取问题 | - | - | refresh并非异步线程定时刷新,而是由请求线程触发,在低频访问下,某个key的value可能是较早之前读取留下的,距离现在已久,会读取到较老的值 |
综上所看,expire和refresh都各有优缺点,refresh在低频访问可能获取到一个较老的值,而expire在高频率的收回时候因为每个线程都会加锁解锁而有性能问题。实际开发中,将两者综合使用,通过各自的优点屏蔽掉对方的缺点。比如设定refresh为1分钟一次,expireAfterWrite为2分钟一次,那么在访问高峰下,大部分会通过refresh的异步线程触发更新,避免了加锁。而当程序处于访问低谷时候,通过expire设置最大有效期为2分钟回收,后进来的线程发现已被回收再通过加锁的方式读取新值,因为量小,加锁的性能问题也可以很好的规避。
可能有些同学会问,如果refresh和expireAfterWrite的更新频率都设置成一样的话,比如都是1分钟更新,那究竟是通过哪一种方式来更新呢?
答案是通过expire来更新。通过下面简化版的源码分析原因。
//与concurrentHashMap一样,获取值时候传入key与计算key的hash
V get(K key,int hash ) {
ReferenceEntry<K, V> e = getEntry(key, hash);
if (e != null) {
//获取当前时间
long now = map.ticker.read();
//获取存活的值,即判断是否有过期的值
V value = getLiveValue(e, now);
if (value != null) {
//不存在过期,判断是否符合refresh条件触发执行refresh
return scheduleRefresh(e, key, hash, value, now, loader);
}
ValueReference<K, V> valueReference = e.getValueReference();
//已回收的或者新的值时候,val都是null,此时统一key其他线程访问需要等待
if (valueReference.isLoading()) {
return waitForLoadingValue(e, key, valueReference);
}
}
}
// at this point e is either null or expired;
// 不是空就是过期才会调到这个方法,即首次或者过期会加锁读取
return lockedGetOrLoad(key, hash, loader);
}
可以看出,guava是先判断是否有存活live的对象,如果有存活,才会进行refresh操作,如果没有存活,可能的情况是expire造成的回收或者从未有过值,这时候进行上锁lock和load取新的值,竞争的其他线程则wait等待结果。所以refresh设置的更新频率大于expire,即refresh的更新更频繁,否则refresh的设置相当于无效。
为此我们做出下面的实验,通过expire调用load,refresh非首次调用reload的特性,验证上面的结论。
@Test
public void testExpireAndRefresh() {
ExecutorService executor = Executors.newFixedThreadPool(1);
ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(executor);
AtomicInteger count = new AtomicInteger(0);
LoadingCache<String, Integer> cache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.refreshAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) throws Exception {
return count.getAndIncrement();
}
@Override
public ListenableFuture<Integer> reload(String key, Integer oldValue) throws Exception {
return listeningExecutorService.submit(()-> count.getAndIncrement()+10000);
}
});
IntStream.range(0, 10).forEach(i -> {
System.out.println(cache.getUnchecked(""));
ThreadUtil.safeSleep(1000);
});
}
执行结果如下,正如我们所料,调用都是load方法。
0
1
2
3
4
5
6
7
8
9
Process finished with exit code 0
同样,我们调用expire和refresh都设置成1,还是以上的结果。接着我们设置成refresh频率更高的情况
.expireAfterWrite(2, TimeUnit.SECONDS)
.refreshAfterWrite(1, TimeUnit.SECONDS)
结果也证明了原来的猜想
0
10001
10001
10002
10003
10004
10005
10006
10007
10008
Process finished with exit code 0
除了提到的refresh和expire的更新频率问题,我们在实际开发中,最好将reload的方法异步处理,默认reload是直接调用load方法,且同步实现。因为在refresh reload后还有部分操作,异步可以并行化改块内容,加快refresh执行效率(也是官方推荐的方式)。我们可以通过CacheLoader.asyncReloading简化改部分的异步实现。
ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
LoadingCache<String, Integer> cache2 = CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.refreshAfterWrite(1, TimeUnit.SECONDS)
.build(CacheLoader.asyncReloading(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) throws Exception {
return count.getAndIncrement();
}
}, executor));