Guava Cache:快取的回收、重新整理和統計
適用性
快取在很多場景下都是相當有用的。例如,計算或檢索一個值的代價很高,並且對同樣的輸入需要不止一次獲取值的時候,就應當考慮使用快取。
Guava Cache與ConcurrentMap很相似,但也不完全一樣。最基本的區別是ConcurrentMap會一直儲存所有新增的元素,直到顯式地移除。相對地,Guava Cache為了限制記憶體佔用,通常都設定為自動回收元素。在某些場景下,儘管LoadingCache 不回收元素,它也是很有用的,因為它會自動載入快取。
通常來說,Guava Cache適用於:
- 你願意消耗一些記憶體空間來提升速度。
- 你預料到某些鍵會被查詢一次以上。
- 快取中存放的資料總量不會超出記憶體容量。(Guava Cache是單個應用執行時的本地快取。它不把資料存放到檔案或外部伺服器。如果這不符合你的需求,請嘗試Memcached這類工具)
- 如果你的場景符合上述的每一條,Guava Cache就適合你。
注:如果你不需要Cache中的特性,使用ConcurrentHashMap有更好的記憶體效率——但Cache的大多數特性都很難基於舊有的ConcurrentMap複製,甚至根本不可能做到。
Guava Cache建立方式:
- CacheLoader
// cacheLoader建立方式 LoadingCache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(100) //最大快取數目 .expireAfterAccess(1, TimeUnit.SECONDS) //快取1秒後過期 .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { return key; } }); cache.put("j", "JAVA"); cache.put("c", "C++"); cache.put("s", "SCALA"); cache.put("g", "GO"); try { System.out.println(cache.get("j")); TimeUnit.SECONDS.sleep(2); System.out.println(cache.get("s")); //輸出s } catch (ExecutionException | InterruptedException e) { e.printStackTrace(); }
- Callable callback
// Callable建立方式 Cache<String, String> stringCache = CacheBuilder.newBuilder() .maximumSize(100) .expireAfterAccess(1, TimeUnit.SECONDS) .build(); try { String fly = stringCache.get("FLY", new Callable<String>() { @Override public String call() throws Exception { return "Hi,AlexFly"; } }); System.out.println(fly); // lambda呼叫 String result = stringCache.get("java", () -> "hello java"); System.out.println(result); } catch (ExecutionException e) { e.printStackTrace(); }
Guava Cache快取回收:
- 基於容量和定時的回收
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String key) throws Exception {
return generateValueByKey(key);
}
});
try {
System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
e.printStackTrace();
}
- 如程式碼所示,新建了名為caches的一個快取物件,maximumSize定義了快取的容量大小,當快取數量即將到達容量上線時,則會進行快取回收,回收最近沒有使用或總體上很少使用的快取項。需要注意的是在接近這個容量上限時就會發生,所以在定義這個值的時候需要視情況適量地增大一點。
- 另外通過expireAfterWrite這個方法定義了快取的過期時間,寫入十分鐘之後過期。
CacheBuilder提供兩種定時回收的方法: expireAfterAccess(long, TimeUnit):快取項在給定時間內沒有被讀/寫訪問,則回收。 請注意這種快取的回收順序和基於大小回收一樣。 expireAfterWrite(long, TimeUnit):快取項在給定時間內沒有被寫訪問(建立或覆蓋),則回收。 如果認為快取資料總是在固定時候後變得陳舊不可用,這種回收方式是可取的。
- 在build方法裡,傳入了一個CacheLoader物件,重寫了其中的load方法。當獲取的快取值不存在或已過期時,則會呼叫此load方法,進行快取值的計算。
- 這就是最簡單也是我們平常最常用的一種使用方法。定義了快取大小、過期時間及快取值生成方法。
- 如果用其他的快取方式,如redis,我們知道上面這種“如果有快取則返回;否則運算、快取、然後返回”的快取模式是有很大弊端的。當高併發條件下同時進行get操作,而此時快取值已過期時,會導致大量執行緒都呼叫生成快取值的方法,比如從資料庫讀取。這時候就容易造成資料庫雪崩。這也就是我們常說的“快取穿透”。
- 而Guava cache則對此種情況有一定控制。當大量執行緒用相同的key獲取快取值時,只會有一個執行緒進入load方法,而其他執行緒則等待,直到快取值被生成。這樣也就避免了快取穿透的危險。
Guava Cache定時重新整理:
如上的使用方法,雖然不會有快取穿透的情況,但是每當某個快取值過期時,老是會導致大量的請求執行緒被阻塞。而Guava則提供了另一種快取策略,快取值定時重新整理:更新執行緒呼叫load方法更新該快取,其他請求執行緒返回該快取的舊值。這樣對於某個key的快取來說,只會有一個執行緒被阻塞,用來生成快取值,而其他的執行緒都返回舊的快取值,不會被阻塞。
這裡就需要用到Guava cache的refreshAfterWrite方法。
如下所示:
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
.maximumSize(100)
.refreshAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String key) throws Exception {
return generateValueByKey(key);
}
});
try {
System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
e.printStackTrace();
}
如程式碼所示,每隔十分鐘快取值則會被重新整理。
此外需要注意一個點,這裡的定時並不是真正意義上的定時。Guava cache的重新整理需要依靠使用者請求執行緒,讓該執行緒去進行load方法的呼叫,所以如果一直沒有使用者嘗試獲取該快取值,則該快取也並不會重新整理。
Guava Cache非同步重新整理:
如上的使用方法,解決了同一個key的快取過期時會讓多個執行緒阻塞的問題,只會讓用來執行重新整理快取操作的一個使用者執行緒會被阻塞。由此可以想到另一個問題,當快取的key很多時,高併發條件下大量執行緒同時獲取不同key對應的快取,此時依然會造成大量執行緒阻塞,並且給資料庫帶來很大壓力。這個問題的解決辦法就是將重新整理快取值的任務交給後臺執行緒,所有的使用者請求執行緒均返回舊的快取值,這樣就不會有使用者執行緒被阻塞了。
詳細做法如下:
ListeningExecutorService backgroundRefreshPools =
MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
.maximumSize(100)
.refreshAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Object>() {
@Override
public Object load(String key) throws Exception {
return generateValueByKey(key);
}
@Override
public ListenableFuture<Object> reload(String key,
Object oldValue) throws Exception {
return backgroundRefreshPools.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
return generateValueByKey(key);
}
});
}
});
try {
System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
e.printStackTrace();
}
在上面的程式碼中,我們新建了一個執行緒池,用來執行快取重新整理任務。並且重寫了CacheLoader的reload方法,在該方法中建立快取重新整理的任務並提交到執行緒池。
注意此時快取的重新整理依然需要靠使用者執行緒來驅動,只不過和上面不同之處在於該使用者執行緒觸發重新整理操作之後,會立馬返回舊的快取值。
TIPS
-
可以看到防快取穿透和防使用者執行緒阻塞都是依靠返回舊值來完成的。所以如果沒有舊值,同樣會全部阻塞,因此應視情況儘量在系統啟動時將快取內容載入到記憶體中。
-
在重新整理快取時,如果generateValueByKey方法出現異常或者返回了null,此時舊值不會更新。
-
題外話:在使用記憶體快取時,切記拿到快取值之後不要在業務程式碼中對快取直接做修改,因為此時拿到的物件引用是指向快取真正的內容的。如果需要直接在該物件上進行修改,則在獲取到快取值後拷貝一份副本,然後傳遞該副本,進行修改操作。(我曾經就犯過這個低階錯誤 - -!)
Guava Cache統計:
- CacheBuilder.recordStats()用來開啟Guava Cache的統計功能。統計開啟後,Cache.stats()方法會返回CacheStats物件以提供
- 如下統計資訊:
hitRate():快取命中率;
averageLoadPenalty():載入新值的平均時間,單位為納秒;
evictionCount():快取項被回收的總數,不包括顯式清除。
此外,還有其他很多統計資訊。這些統計資訊對於調整快取設定是至關重要的,在效能要求高的應用中我們建議密切關注這些資料。
Guava Cache:asMap檢視
- asMap檢視提供了快取的ConcurrentMap形式,但asMap檢視與快取的互動需要注意:
- cache.asMap()包含當前所有載入到快取的項。因此相應地,cache.asMap().keySet()包含當前所有已載入鍵;
- asMap().get(key)實質上等同於cache.getIfPresent(key),而且不會引起快取項的載入。這和Map的語義約定一致。
- 所有讀寫操作都會重置相關快取項的訪問時間,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合檢視上的操作。比如,遍歷Cache.asMap().entrySet()不會重置快取項的讀取時間。
參考來源:http://ifeve.com/google-guava-cachesexplained/
參考來源:https://blog.csdn.net/u012859681/article/details/75220605#commentBox