[Google Guava] 3-快取
範例
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .removalListener(MY_LISTENER) .build( new CacheLoader<Key, Graph>() { public Graph load(Key key) throws AnyException { return createExpensiveGraph(key); } });
適用性
快取在很多場景下都是相當有用的。例如,計算或檢索一個值的代價很高,並且對同樣的輸入需要不止一次獲取值的時候,就應當考慮使用快取。
Guava Cache與ConcurrentMap很相似,但也不完全一樣。最基本的區別是ConcurrentMap會一直儲存所有新增的元素,直到顯式地移除。相對地,Guava Cache為了限制記憶體佔用,通常都設定為自動回收元素。在某些場景下,儘管LoadingCache 不回收元素,它也是很有用的,因為它會自動載入快取。
通常來說,Guava Cache適用於:
- 你願意消耗一些記憶體空間來提升速度。
- 你預料到某些鍵會被查詢一次以上。
- 快取中存放的資料總量不會超出記憶體容量。(Guava Cache是單個應用執行時的本地快取。它不把資料存放到檔案或外部伺服器。如果這不符合你的需求,請嘗試
如果你的場景符合上述的每一條,Guava Cache就適合你。
如同範例程式碼展示的一樣,Cache例項通過CacheBuilder生成器模式獲取,但是自定義你的快取才是最有趣的部分。
注:如果你不需要Cache中的特性,使用ConcurrentHashMap有更好的記憶體效率——但Cache的大多數特性都很難基於舊有的ConcurrentMap複製,甚至根本不可能做到。
載入
在使用快取前,首先問自己一個問題:有沒有合理的預設方法來載入或計算與鍵關聯的值?如果有的話,你應當使用CacheLoader。如果沒有,或者你想要覆蓋預設的載入運算,同時保留"獲取快取-如果沒有-則計算"[get-if-absent-compute]的原子語義,你應該在呼叫get時傳入一個Callable例項。快取元素也可以通過Cache.put方法直接插入,但自動載入是首選的,因為它可以更容易地推斷所有快取內容的一致性。
LoadingCache是附帶CacheLoader構建而成的快取實現。建立自己的CacheLoader通常只需要簡單地實現V load(K key) throws Exception方法。例如,你可以用下面的程式碼構建LoadingCache:
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() .maximumSize(1000) .build( new CacheLoader<Key, Graph>() { public Graph load(Key key) throws AnyException { return createExpensiveGraph(key); } }); ... try { return graphs.get(key); } catch (ExecutionException e) { throw new OtherException(e.getCause()); }
從LoadingCache查詢的正規方式是使用get(K)方法。這個方法要麼返回已經快取的值,要麼使用CacheLoader向快取原子地載入新值。由於CacheLoader可能丟擲異常,LoadingCache.get(K)也宣告為丟擲ExecutionException異常。如果你定義的CacheLoader沒有宣告任何檢查型異常,則可以通過getUnchecked(K)查詢快取;但必須注意,一旦CacheLoader聲明瞭檢查型異常,就不可以呼叫getUnchecked(K)。
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() .expireAfterAccess(10, TimeUnit.MINUTES) .build( new CacheLoader<Key, Graph>() { public Graph load(Key key) { // no checked exception return createExpensiveGraph(key); } }); ... return graphs.getUnchecked(key);
getAll(Iterable<? extends K>)方法用來執行批量查詢。預設情況下,對每個不在快取中的鍵,getAll方法會單獨呼叫CacheLoader.load來載入快取項。如果批量的載入比多個單獨載入更高效,你可以過載CacheLoader.loadAll來利用這一點。getAll(Iterable)的效能也會相應提升。
注:CacheLoader.loadAll的實現可以為沒有明確請求的鍵載入快取值。例如,為某組中的任意鍵計算值時,能夠獲取該組中的所有鍵值,loadAll方法就可以實現為在同一時間獲取該組的其他鍵值。校注:getAll(Iterable<? extends K>)方法會呼叫loadAll,但會篩選結果,只會返回請求的鍵值對。
所有型別的Guava Cache,不管有沒有自動載入功能,都支援方法。這個方法返回快取中相應的值,或者用給定的Callable運算並把結果加入到快取中。在整個載入方法完成前,快取項相關的可觀察狀態都不會更改。這個方法簡便地實現了模式"如果有快取則返回;否則運算、快取、然後返回"。
Cache<Key, Graph> cache = CacheBuilder.newBuilder() .maximumSize(1000) .build(); // look Ma, no CacheLoader ... try { // If the key wasn't in the "easy to compute" group, we need to // do things the hard way. cache.get(key, new Callable<Key, Graph>() { @Override public Value call() throws AnyException { return doThingsTheHardWay(key); } }); } catch (ExecutionException e) { throw new OtherException(e.getCause()); }
顯式插入
使用方法可以直接向快取中插入值,這會直接覆蓋掉給定鍵之前對映的值。使用Cache.asMap()檢視提供的任何方法也能修改快取。但請注意,asMap檢視的任何方法都不能保證快取項被原子地載入到快取中。進一步說,asMap檢視的原子運算在Guava Cache的原子載入範疇之外,所以相比於Cache.asMap().putIfAbsent(K,
V),Cache.get(K, Callable<V>) 應該總是優先使用。
快取回收
一個殘酷的現實是,我們幾乎一定沒有足夠的記憶體快取所有資料。你你必須決定:什麼時候某個快取項就不值得保留了?Guava Cache提供了三種基本的快取回收方式:基於容量回收、定時回收和基於引用回收。
基於容量的回收(size-based eviction)
如果要規定快取項的數目不超過固定值,只需使用CacheBuilder.maximumSize(long)。快取將嘗試回收最近沒有使用或總體上很少使用的快取項。——警告:在快取項的數目達到限定值之前,快取就可能進行回收操作——通常來說,這種情況發生在快取項的數目逼近限定值時。
另外,不同的快取項有不同的“權重”(weights)——例如,如果你的快取值,佔據完全不同的記憶體空間,你可以使用CacheBuilder.weigher(Weigher)指定一個權重函式,並且用CacheBuilder.maximumWeight(long)指定最大總重。在權重限定場景中,除了要注意回收也是在重量逼近限定值時就進行了,還要知道重量是在快取建立時計算的,因此要考慮重量計算的複雜度。
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() .maximumWeight(100000) .weigher(new Weigher<Key, Graph>() { public int weigh(Key k, Graph g) { return g.vertices().size(); } }) .build( new CacheLoader<Key, Graph>() { public Graph load(Key key) { // no checked exception return createExpensiveGraph(key); } });
定時回收(Timed Eviction)
CacheBuilder提供兩種定時回收的方法:
- expireAfterWrite(long, TimeUnit):快取項在給定時間內沒有被寫訪問(建立或覆蓋),則回收。如果認為快取資料總是在固定時候後變得陳舊不可用,這種回收方式是可取的。
如下文所討論,定時回收週期性地在寫操作中執行,偶爾在讀操作中執行。
測試定時回收
對定時回收進行測試時,不一定非得花費兩秒鐘去測試兩秒的過期。你可以使用Ticker
基於引用的回收(Reference-based Eviction)
通過使用弱引用的鍵、或弱引用的值、或軟引用的值,Guava Cache可以把快取設定為允許垃圾回收:
- CacheBuilder.weakKeys():使用弱引用儲存鍵。當鍵沒有其它(強或軟)引用時,快取項可以被垃圾回收。因為垃圾回收僅依賴恆等式(==),使用弱引用鍵的快取用==而不是equals比較鍵。
- CacheBuilder.weakValues():使用弱引用儲存值。當值沒有其它(強或軟)引用時,快取項可以被垃圾回收。因為垃圾回收僅依賴恆等式(==),使用弱引用值的快取用==而不是equals比較值。
- CacheBuilder.softValues():使用軟引用儲存值。軟引用只有在響應記憶體需要時,才按照全域性最近最少使用的順序回收。考慮到使用軟引用的效能影響,我們通常建議使用更有效能預測性的快取大小限定(見上文,基於容量回收)。使用軟引用值的快取同樣用==而不是equals比較值。
顯式清除
任何時候,你都可以顯式地清除快取項,而不是等到它被回收:
移除監聽器
請注意,RemovalListener丟擲的任何異常都會在記錄到日誌後被丟棄[swallowed]。
CacheLoader<Key, DatabaseConnection> loader = new CacheLoader<Key, DatabaseConnection> () { public DatabaseConnection load(Key key) throws Exception { return openConnection(key); } }; RemovalListener<Key, DatabaseConnection> removalListener = new RemovalListener<Key, DatabaseConnection>() { public void onRemoval(RemovalNotification<Key, DatabaseConnection> removal) { DatabaseConnection conn = removal.getValue(); conn.close(); // tear down properly } }; return CacheBuilder.newBuilder() .expireAfterWrite(2, TimeUnit.MINUTES) .removalListener(removalListener) .build(loader);
警告:預設情況下,監聽器方法是在移除快取時同步呼叫的。因為快取的維護和請求響應通常是同時進行的,代價高昂的監聽器方法在同步模式下會拖慢正常的快取請求。在這種情況下,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)把監聽器裝飾為非同步操作。
清理什麼時候發生?
使用CacheBuilder構建的快取不會"自動"執行清理和回收工作,也不會在某個快取項過期後馬上清理,也沒有諸如此類的清理機制。相反,它會在寫操作時順帶做少量的維護工作,或者偶爾在讀操作時做——如果寫操作實在太少的話。
這樣做的原因在於:如果要自動地持續清理快取,就必須有一個執行緒,這個執行緒會和使用者操作競爭共享鎖。此外,某些環境下執行緒建立可能受限制,這樣CacheBuilder就不可用了。
相反,我們把選擇權交到你手裡。如果你的快取是高吞吐的,那就無需擔心快取的維護和清理等工作。如果你的 快取只會偶爾有寫操作,而你又不想清理工作阻礙了讀操作,那麼可以建立自己的維護執行緒,以固定的時間間隔呼叫Cache.cleanUp()。ScheduledExecutorService可以幫助你很好地實現這樣的定時排程。
重新整理
重新整理和回收不太一樣。正如所宣告,重新整理表示為鍵載入新值,這個過程可以是非同步的。在重新整理操作進行時,快取仍然可以向其他執行緒返回舊值,而不像回收操作,讀快取的執行緒必須等待新值載入完成。
如果重新整理過程丟擲異常,快取將保留舊值,而異常會在記錄到日誌後被丟棄[swallowed]。
//有些鍵不需要重新整理,並且我們希望重新整理是非同步完成的 LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder() .maximumSize(1000) .refreshAfterWrite(1, TimeUnit.MINUTES) .build( new CacheLoader<Key, Graph>() { public Graph load(Key key) { // no checked exception return getGraphFromDatabase(key); } public ListenableFuture<Key, Graph> reload(final Key key, Graph prevGraph) { if (neverNeedsRefresh(key)) { return Futures.immediateFuture(prevGraph); }else{ // asynchronous! ListenableFutureTask<Key, Graph> task=ListenableFutureTask.create(new Callable<Key, Graph>() { public Graph call() { return getGraphFromDatabase(key); } }); executor.execute(task); return task; } } });
可以為快取增加自動定時重新整理功能。和expireAfterWrite相反,refreshAfterWrite通過定時重新整理可以讓快取項保持可用,但請注意:快取項只有在被檢索時才會真正重新整理(如果CacheLoader.refresh實現為非同步,那麼檢索不會被重新整理拖慢)。因此,如果你在快取上同時宣告expireAfterWrite和refreshAfterWrite,快取並不會因為重新整理盲目地定時重置,如果快取項沒有被檢索,那重新整理就不會真的發生,快取項在過期時間後也變得可以回收。
其他特性
統計
此外,還有其他很多統計資訊。這些統計資訊對於調整快取設定是至關重要的,在效能要求高的應用中我們建議密切關注這些資料。
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()不會重置快取項的讀取時間。
中斷
快取載入方法(如Cache.get)不會丟擲InterruptedException。我們也可以讓這些方法支援InterruptedException,但這種支援註定是不完備的,並且會增加所有使用者的成本,而只有少數使用者實際獲益。詳情請繼續閱讀。
Cache.get請求到未快取的值時會遇到兩種情況:當前執行緒載入值;或等待另一個正在載入值的執行緒。這兩種情況下的中斷是不一樣的。等待另一個正在載入值的執行緒屬於較簡單的情況:使用可中斷的等待就實現了中斷支援;但當前執行緒載入值的情況就比較複雜了:因為載入值的CacheLoader是由使用者提供的,如果它是可中斷的,那我們也可以實現支援中斷,否則我們也無能為力。
如果使用者提供的CacheLoader是可中斷的,為什麼不讓Cache.get也支援中斷?從某種意義上說,其實是支援的:如果CacheLoader丟擲InterruptedException,Cache.get將立刻返回(就和其他異常情況一樣);此外,在載入快取值的執行緒中,Cache.get捕捉到InterruptedException後將恢復中斷,而其他執行緒中InterruptedException則被包裝成了ExecutionException。
原則上,我們可以拆除包裝,把ExecutionException變為InterruptedException,但這會讓所有的LoadingCache使用者都要處理中斷異常,即使他們提供的CacheLoader不是可中斷的。如果你考慮到所有非載入執行緒的等待仍可以被中斷,這種做法也許是值得的。但許多快取只在單執行緒中使用,它們的使用者仍然必須捕捉不可能丟擲的InterruptedException異常。即使是那些跨執行緒共享快取的使用者,也只是有時候能中斷他們的get呼叫,取決於那個執行緒先發出請求。
對於這個決定,我們的指導原則是讓快取始終表現得好像是在當前執行緒載入值。這個原則讓使用快取或每次都計算值可以簡單地相互切換。如果老程式碼(載入值的程式碼)是不可中斷的,那麼新程式碼(使用快取載入值的程式碼)多半也應該是不可中斷的。
如上所述,Guava Cache在某種意義上支援中斷。另一個意義上說,Guava Cache不支援中斷,這使得LoadingCache成了一個有漏洞的抽象:當載入過程被中斷了,就當作其他異常一樣處理,這在大多數情況下是可以的;但如果多個執行緒在等待載入同一個快取項,即使載入執行緒被中斷了,它也不應該讓其他執行緒都失敗(捕獲到包裝在ExecutionException裡的InterruptedException),正確的行為是讓剩餘的某個執行緒重試載入。為此,我們記錄了一個bug。然而,與其冒著風險修復這個bug,我們可能會花更多的精力去實現另一個建議AsyncLoadingCache,這個實現會返回一個有正確中斷行為的Future物件。