Guava 是 Google 提供的一套 Java 工具包,而 Guava Cache 是該工具包中提供的一套完善的 JVM 級別高併發緩存框架;本文主要介紹它的相關功能及基本使用,文中所使用到的軟體版本:Java 1.8.0_341、Guava 32.1.3-jre。 1、簡介 緩存在很多情況下非 ...
Guava 是 Google 提供的一套 Java 工具包,而 Guava Cache 是該工具包中提供的一套完善的 JVM 級別高併發緩存框架;本文主要介紹它的相關功能及基本使用,文中所使用到的軟體版本:Java 1.8.0_341、Guava 32.1.3-jre。
1、簡介
緩存在很多情況下非常有用。例如,當某個值的計算或檢索代價很高,並且你需要在特定輸入下多次使用該值時,就應該考慮使用緩存。
Guava Cache 與 ConcurrentMap 類似,但並不完全相同。最基本的區別在於,ConcurrentMap 會一直保存所有添加到其中的元素,直到顯式地將它們刪除。而 Guava Cache 通常會配置自動刪除條目,以限制其記憶體占用。在某些情況下,即使不刪除條目,LoadingCache 也很有用,因為它具有自動載入條目的功能。
通常情況下,Guava Cache 適用於以下情況:
- 你願意花費一些記憶體來提高速度。
- 你預期某些鍵有時會被多次查詢。
- 你的緩存不需要存儲超過記憶體容量的數據。(Guava Cache 是局限於應用程式運行期間的本地緩存。它們不會將數據存儲在文件或外部伺服器上。如果這不符合你的需求,可以考慮使用像Memcached 這樣的工具。)
如果你的情況符合上述每一點,那麼 Guava Cache 可能適合你。
註意:如果你不需要緩存的特性,ConcurrentHashMap 在記憶體效率方面更高——但是使用任何 ConcurrentMap 幾乎不可能複製大多數 Guava Cache 的特性。
2、數據載入
使用 Guava Cache 時,首先要問自己一個問題:是否有合理的預設函數來載入或計算需緩存的數據?如果是這樣,應該使用 CacheLoader。如果沒有,或者需要覆蓋預設函數,但仍然希望具有原子的“如果不存在則計算並獲取”語義,你應該將一個 Callable 對象傳遞給 get 方法。可以直接使用 Cache.put 方法插入元素,但更推薦自動載入數據,因為這樣可以更容易地推斷所有緩存內容的一致性。
2.1、CacheLoader
LoadingCache 是一個帶有 CacheLoader 的緩存。創建 CacheLoader 很容易,只需要實現方法 V load(K key) throws Exception 即可。
LoadingCache<Long, String> loadingCache = CacheBuilder.newBuilder() .maximumSize(1000) .build(new CacheLoader<Long, String>() { @Override public String load(Long key) throws Exception { //TODO: 根據業務載入數據 return RandomStringUtils.randomAlphanumeric(10); } }); try { log.info(loadingCache.get(1L)); } catch (ExecutionException e) { e.printStackTrace(); }
LoadingCache 使用 get(K) 方法來獲取數據。該方法要麼返回已緩存的值,要麼使用 CacheLoader 來原子地載入一個新值到緩存中。由於 CacheLoader 可能會拋出異常,LoadingCache.get(K) 方法會拋出 ExecutionException 異常。(如果 CacheLoader 拋出未經檢查異常,get(K) 方法將拋出包裝異常 UncheckedExecutionException)。也可以選擇使用 getUnchecked(K) 方法,它將所有異常都包裝在UncheckedExecutionException 中,但如果底層的 CacheLoader 拋出已檢查異常,這可能導致意外行為。
LoadingCache<Long, String> loadingCache = CacheBuilder.newBuilder() .maximumSize(1000) .build(new CacheLoader<Long, String>() { @Override public String load(Long key) {//拋出未檢查異常 //TODO: 根據業務載入數據 return RandomStringUtils.randomAlphanumeric(10); } }); log.info(loadingCache.getUnchecked(1L));
可以使用 getAll(Iterable<? extends K>)方法執行批量查詢。預設情況下,getAll 會為緩存中不存在的每個鍵單獨調用 CacheLoader.load 方法。當批量檢索比多個單獨查找更高效時,可以重寫CacheLoader.loadAll 以利用此功能。getAll(Iterable)的性能將相應提高。
2.2、Callable
所有 Guava Cache,無論是 LoadingCache 還是非 LoadingCache,都支持 get(K, Callable<V>) 方法。該方法返回與緩存中鍵相關聯的值,或者從指定的 Callable 計算它並將其添加到緩存中。在載入完成之前,與此緩存關聯的任何可觀察狀態都不會被修改。該方法為傳統的“如果有緩存,則返回;否則創建、緩存並返回”模式提供了一個簡單的替代方案。
Cache<Long, String> cache = CacheBuilder.newBuilder() .maximumSize(1000) .build(); try { String s = cache.get(1L, new Callable<String>() { @Override public String call() throws Exception { //TODO: 根據業務載入數據 return RandomStringUtils.randomAlphanumeric(10); } }); log.info(s); } catch (ExecutionException e) { e.printStackTrace(); }
2.3、直接插入
可以使用 cache.put(key, value) 方法直接將值插入到緩存中。這會覆蓋緩存中指定鍵的的任何先前條目。還可以使用 Cache.asMap() 視圖公開的任何 ConcurrentMap 方法更改緩存。請註意,asMap 視圖上的任何方法都不會自動將條目載入到緩存中。此外,視圖上的原子操作在緩存自動載入的範圍之外運行,因此在使用 CacheLoader 或 Callable 載入值的緩存中,始終應優先選擇 Cache.get(K, Callable<V>) 而不是 Cache.asMap().putIfAbsent()。
3、數據淘汰
現實情況是,我們幾乎肯定沒有足夠的記憶體來緩存所有可能的內容。你必須決定:什麼時候不值得保留緩存條目?Guava提供了三種數據淘汰方式:基於大小的淘汰、基於時間的淘汰和基於引用的淘汰。
3.1、基於容量的淘汰
如果你的緩存不應該超過一定大小,只需使用 CacheBuilder.maximumSize(long) 。緩存將嘗試淘汰最近未被使用或使用頻率很低的條目。警告:在達到限制之前,緩存可能會淘汰條目,通常是在緩存大小接近限制時。
或者,如果不同的緩存條目具有不同的“權重”——例如,如果你的緩存值具有截然不同的記憶體占用,你可以使用 CacheBuilder.weigher(Weigher) 來指定一個權重函數,並使用CacheBuilder.maximumWeight(long) 來設置最大的緩存權重。除了與 maximumSize 相同的註意事項外,請註意權重是在條目創建時計算的,並且在此後是靜態的。
Cache<Long, String> cache = CacheBuilder.newBuilder() .maximumWeight(100000) .weigher(new Weigher<Long, String>() { @Override public int weigh(Long key, String value) { return value.getBytes().length; } }).build();
3.2、基於時間的淘汰
CacheBuilder 提供了兩種基於時間淘汰數據的方法:
expireAfterAccess(long, TimeUnit):在最後一次讀取或寫入條目後,僅在指定的持續時間過去後才淘汰條目。需要註意的是,條目的淘汰順序類似於基於大小的淘汰策略。
expireAfterWrite(long, TimeUnit):在條目創建或最近一次替換值之後,僅在指定的持續時間過去後才淘汰條目。如果緩存數據在一段時間後變得過時,這種方式可能是可取的。
3.3、基於引用的淘汰
Guava 允許通過對鍵或值使用弱引用和對值使用軟引用來設置緩存,從而利用垃圾回收來淘汰數據。
- CacheBuilder.weakKeys() 使用弱引用來存儲鍵。這意味著當鍵沒有其他(強或軟)引用時,條目可以被垃圾回收。由於垃圾回收僅依賴於記憶體地址相等性,這導致整個緩存使用(==)來比較鍵,而不是equals()方法。
- CacheBuilder.weakValues() 使用弱引用來存儲值。這意味著當值沒有其他(強或軟)引用時,條目可以被垃圾回收。由於垃圾回收僅依賴於記憶體地址相等性,這導致整個緩存使用(==)來比較值,而不是equals()方法。
- CacheBuilder.softValues() 使用軟引用來存儲值。以軟引用方式引用的對象會根據記憶體需求以全局最近最少使用的方式進行垃圾回收。由於使用軟引用可能會影響性能,我們通常建議使用更可預測的 maximum cache size 替代。使用 softValues() 將導致值使用(==)來比較,而不是 equals() 方法。
3.4、顯式刪除
在任何時候,你可以顯式地使緩存條目失效,而不是等待條目被淘汰。可以通過以下方式實現:
單個失效:使用 Cache.invalidate(key)
批量失效:使用 Cache.invalidateAll(keys)
全部失效:使用 Cache.invalidateAll()
3.5、刪除監聽器
你可以為緩存指定一個刪除監聽器(RemovalListener),以在條目被移除時執行某些操作,通過 CacheBuilder.removalListener(RemovalListener) 方法指定刪除監聽器。RemovalListener 會接收到一個RemovalNotification 對象,其中包含了 RemovalCause、鍵和值的信息。
需要註意的是,任何由 RemovalListener 拋出的異常都會被記錄(使用 Logger 時)並被忽略。
Cache<Long, String> cache = CacheBuilder.newBuilder() .maximumSize(1000) .removalListener(new RemovalListener<Long, String>() { @Override public void onRemoval(RemovalNotification<Long, String> notification) { log.info(notification.toString()); } }) .build();
警告:預設情況下,移除監聽器操作是同步執行的。由於緩存維護通常在正常緩存操作期間執行,因此移除監聽器可能會降低緩存的速度!如果需要移除監聽器,請使用RemovalListeners.asynchronous(RemovalListener, Executor) 方法來裝飾 RemovalListener,這樣可以以非同步的方式運行。
3.6、數據清理時機
使用 CacheBuilder 構建的緩存不會“自動”執行清理和逐出值,也不會在值過期後立即執行清理和逐出值,也不會執行任何類似操作。相反,它會在寫入操作期間執行少量維護,或者在偶爾的讀取操作期間(如果寫入很少)執行少量維護。
原因是:如果我們想要連續執行緩存維護,我們需要創建一個線程,它的操作將與用戶操作競爭共用鎖。此外,某些環境限制了線程的創建,這將使 CacheBuilder 在該環境中無法使用。
相反,我們將選擇權交到你手中。如果你的緩存是高吞吐量的,那麼你無需擔心執行緩存維護來清除過期條目等問題。如果你的緩存只偶爾進行寫操作,並且不想讓清理阻塞緩存讀取,你可以創建自己的維護線程,定期調用 Cache.cleanUp() 方法。
如果要為很少進行寫入操作的緩存安排定期緩存維護,請使用 ScheduledExecutorService。
3.7、刷新
刷新(Refreshing)與淘汰(Eviction)並不完全相同。根據 LoadingCache.refresh(K) 的定義,刷新一個鍵會載入該鍵的新值,這可能是非同步的。在鍵正在刷新的過程中,舊值(如果存在)仍然會被返回,這與淘汰操作不同,淘汰操作會導致獲取操作等待直到新值載入完成。
如果在刷新過程中發生異常,舊值將被保留,異常將被記錄並忽略。
可以根據業務需要,重寫 CacheLoader 的 CacheLoader.reload(K, V) 方法來重新定義刷新操作;這允許你在計算新值時使用舊值。
LoadingCache<Integer, String> loadingCache = CacheBuilder.newBuilder() .maximumSize(1000) .refreshAfterWrite(1, TimeUnit.MINUTES) .build(new CacheLoader<Integer, String>() { @Override public String load(Integer key) throws Exception { //TODO: 根據業務載入數據 return RandomStringUtils.randomAlphanumeric(10); } @Override public ListenableFuture<String> reload(Integer key, String oldValue) throws Exception { if (neverNeedsRefresh(key)) {//不需要刷新 return Futures.immediateFuture(oldValue); } else { //非同步刷新 ListenableFutureTask<String> task = ListenableFutureTask.create(new Callable<String>() { public String call() { return RandomStringUtils.randomAlphanumeric(10); } }); executorService.execute(task); return task; } } });
可以使用 CacheBuilder.refreshAfterWrite(long, TimeUnit) 為緩存添加自動定時刷新。與 expireAfterWrite 不同,refreshAfterWrite 會使一個鍵在指定的時間後變為可刷新狀態,但只有在查詢該條目時才會實際啟動刷新(如果 CacheLoader.reload 被實現為非同步,則查詢不會因刷新而變慢)。因此,可以在同一個緩存上同時指定 refreshAfterWrite 和 expireAfterWrite,以便在條目變為可刷新狀態時不會盲目地重置過期計時器,如果一個條目在變為可刷新狀態後沒有被查詢,它就允許過期。
4、特點
4.1、統計信息
使用 CacheBuilder.recordStats() 可以為 Guava Cache 打開統計信息收集功能。Cache.stats() 方法返回一個 CacheStats 對象,該對象提供了諸如以下統計信息:
- hitRate():返回命中次數與請求次數之比。
- averageLoadPenalty():平均載入新值所花費的時間(以納秒為單位)。
- evictionCount():緩存淘汰的數量。
還有許多其他的統計信息。這些統計信息在緩存調優中非常重要,我們建議在性能關鍵的應用程式中密切關註這些統計信息。
4.2、asMap
可以使用 Cache 的 asMap 視圖將任何緩存視為 ConcurrentMap,但是 asMap 視圖與緩存的交互需要一些說明。
- cache.asMap() 包含當前載入在緩存中的所有條目。例如,cache.asMap().keySet() 包含當前載入的所有鍵。
- asMap().get(key) 基本等同於 cache.getIfPresent(key),並且不會導致值被載入。這與 Map 的約定一致。
- 訪問時間會被讀取和寫入操作重置(包括 Cache.asMap().get(Object) 和 Cache.asMap().put(K, V)),但不會被 containsKey(Object) 或其他操作所重置。因此,遍歷 cache.asMap().entrySet() 不會重置條目的訪問時間。
5、簡單使用
5.1、引入依賴
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>32.1.3-jre</version> </dependency>
5.2、簡單使用
public static void main(String[] args) { LoadingCache<Long, String> loadingCache = CacheBuilder.newBuilder() .initialCapacity(1000) .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .refreshAfterWrite(3, TimeUnit.MINUTES) .recordStats() .build(new CacheLoader<Long, String>() { @Override public String load(Long key) throws Exception {//拋出已檢查異常 //TODO: 根據業務載入數據 return RandomStringUtils.randomAlphanumeric(10); } }); try { log.info(loadingCache.get(1L)); } catch (ExecutionException e) { e.printStackTrace(); } }
參考:https://github.com/google/guava/wiki/CachesExplained。