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
NOTE
除了提到的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));