文章目錄
Caches:正確地進行本地緩存,並支持各種過期行爲。
1.示例
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.removalListener(MY_LISTENER)
.build(
new CacheLoader<Key, Graph>() {
@Override
public Graph load(Key key) throws AnyException {
return createExpensiveGraph(key);
}
});
2.適用
緩存在各種各樣的用例中都非常有用。例如,當值的計算或檢索成本很高時,並且在某個輸入上將需要多次使用該值時,你應該考慮使用緩存。
Cache
類似於ConcurrentMap
,但並不完全相同。最根本的區別是,ConcurrentMap
會保留所有添加到其中的元素,直到將其顯式刪除爲止。另一方面,通常將Cache
配置爲自動淘汰條目,以限制其內存佔用量。在某些情況下,由於LoadingCache
自動加載緩存,即使不淘汰條目,它也很有用。
通常,Guava緩存工具適用於以下情況:
- 你願意花費一些內存來提高速度。
- 你希望有時會多次查詢鍵。
- 你的緩存將不需要存儲超出RAM容量的數據。(Guava緩存是本地應用程序的單次運行。它們不將數據存儲在文件中或外部服務器上。如果這不滿足你的需求,請考慮使用像Memcached這樣的工具)
如果這些都適用於你的用例,那麼Guava緩存工具將很適合你!
如上面的示例代碼所示,使用CacheBuilder
構建器模式可以獲取Cache
,但是自定義緩存是有趣的部分。
注意: 如果不需要Cache
的功能,則ConcurrentHashMap
的內存使用效率更高——但要用任何舊的ConcurrentMap
複製大多數Cache
功能是極其困難或不可能的。
3.種類
有關緩存,問自己的第一個問題是:是否有一些合理的默認函數來加載或計算與鍵關聯的值?如果是這樣,則應使用CacheLoader
。如果不是這樣,或者如果你需要覆蓋默認值,但仍希望使用原子性的"get-if-absent-compute"語義,則應將Callable
傳遞給get
調用。可以使用Cache.put
直接插入元素,但是首選自動緩存加載,因爲這樣可以更容易推斷所有緩存內容的一致性。
3.1來自於CacheLoader
LoadingCache
是使用附加的CacheLoader
構建的Cache
。創建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
可能會拋出Exception
,所以LoadingCache.get(K)
會拋出ExecutionException
。(如果緩存加載器拋出未經檢查的異常,則get(K)
會拋出UncheckedExecutionException
對其進行包裝)你還可以選擇使用getUnchecked(K)
,該方法將所有異常包裝在UncheckedExecutionException
中,但是如果底層CacheLoader
通常會拋出已檢查的異常,則這可能會導致令人驚訝的行爲。
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
可能會同時加載該組中的其餘部分。
3.2來自於Callable
所有已加載或未加載的Guava緩存均支持get(K, Callable)
。此方法返回與緩存中的鍵關聯的值,或從指定的Callable
中計算出該值並將其添加到緩存中。在加載完成之前,不會修改與此緩存關聯的可觀察狀態。此方法爲常規的"如果已緩存,則返回;否則創建,緩存並返回"模式提供了簡單的替代方法。
Cache<Key, Value> 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<Value>() {
@Override
public Value call() throws AnyException {
return doThingsTheHardWay(key);
}
});
} catch (ExecutionException e) {
throw new OtherException(e.getCause());
}
3.3直接插入
可以使用cache.put(key, value)
直接將值插入到緩存中。這將覆蓋緩存中指定鍵的任何先前條目。也可以使用Cache.asMap()
視圖公開的任何ConcurrentMap
方法對緩存進行更改。請注意,asMap
視圖上的任何方法都不會導致條目自動加載到緩存中。此外,該視圖上的原子操作在自動緩存加載範圍之外進行,因此,在使用CacheLoader
或Callable
加載值的緩存中,與Cache.asMap().putIfAbsent
相比,應始終首選Cache.get(K, Callable<V>)
。
4.淘汰
殘酷的現實是,我們幾乎肯定沒有足夠的內存來緩存我們可以緩存的所有內容。你必須決定:什麼時候不值得保留緩存條目?Guava提供三種基本的淘汰類型:基於大小的淘汰,基於時間的淘汰和基於引用的淘汰。
4.1基於大小的淘汰
如果你的緩存不應超過特定大小,則只需使用CacheBuilder.maximumSize(long)
。緩存將嘗試淘汰最近或經常未使用的條目。警告:緩存可能會在超出此限制之前將條目淘汰——通常是在緩存大小接近該限制時。
或者,如果不同的緩存條目具有不同的“權重”——例如,如果你的緩存值具有根本不同的內存佔用量——你可以通過CacheBuilder.weigher(Weigher)
指定權重函數,並通過CacheBuilder.maximumWeight(long)
指定最大緩存權重。除了maximumSize
要求的相同警告外,請注意權重是在條目創建時計算的,此後是靜態的。
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);
}
});
4.2基於時間的淘汰
CacheBuilder
提供了兩種定時淘汰方法:
expireAfterAccess(long, TimeUnit)
僅在自從上次通過讀取或寫入訪問條目以來經過指定的持續時間後,條目纔會過期。請注意,逐出條目的順序將類似於基於大小的淘汰。expireAfterWrite(long, TimeUnit)
自創建條目以來經過指定的時間或該值的最新替換之後,使條目過期。如果經過一定時間後緩存的數據過時,則可能需要這樣做。
定時過期是在寫期間和偶爾在讀期間進行定期維護的,如下所述。
4.2.1測試定時淘汰
測試定時淘汰並不一定會很痛苦…實際上你也不需要花兩秒鐘來測試兩秒鐘的過期時間。使用Ticker接口和CacheBuilder.ticker(Ticker)
方法可以在緩存生成器中指定時間源,而不必等待系統時鐘。
4.3基於引用的淘汰
Guava允許你通過對鍵或值使用弱引用,對值使用軟引用來設置緩存以允許對條目進行垃圾回收。
CacheBuilder.weakKeys()
使用弱引用存儲鍵。如果沒有其他(強或軟)對鍵的引用,則允許對條目進行垃圾回收。由於垃圾回收僅取決於標識相等,因此這導致整個緩存使用標識(==
)相等來比較鍵,而不是equals()
。CacheBuilder.weakValues()
使用弱引用存儲值。如果沒有其他(強或軟)對值的引用,則允許對條目進行垃圾回收。由於垃圾回收僅取決於標識相等,因此這導致整個緩存使用身份(==
)相等來比較值,而不是equals()
。CacheBuilder.softValues()
將值包裝在軟引用中。軟引用對象以全局最近最少使用的方式進行垃圾回收,以響應內存需求。由於使用軟引用會對性能產生影響,因此我們通常建議使用更可預測的最大緩存大小。使用softValues()
將導致使用標識(==
)相等而不是equals()
來比較值。
4.4顯式刪除
在任何時候,你都可以顯式使緩存條目無效,而不必等待條目被淘汰。可以這樣做:
- 個別地,使用
Cache.invalidate(key)
- 批量地,使用
Cache.invalidateAll(keys)
- 對所有條目,使用
Cache.invalidateAll()
4.5刪除監聽器
你可以通過CacheBuilder.removalListener(RemovalListener)
爲緩存指定刪除監聽器,以便在刪除條目時執行某些操作。RemovalListener
傳遞了一個RemovalNotification
,它指定RemovalCause
,鍵和值。
請注意,RemovalListener
拋出的任何異常都會被記錄(使用Logger
)並被丟棄。
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)
裝飾一個RemovalListener
以便異步操作。
4.6什麼時候進行清除?
使用CacheBuilder
構建的緩存不會在值過期後,或任何類似的操作下立即執行"自動"的清除和淘汰值。相反,如果寫操作很少,則在寫操作期間或偶爾的讀操作期間,它將執行少量維護。
這樣做的原因如下:如果我們要連續執行Cache
維護,則需要創建一個線程,並且該線程的操作將與用戶操作競爭共享鎖。此外,一些環境限制了線程的創建,這將使CacheBuilder
在該環境中不可用。
相反,我們將選擇權交給你。如果你的緩存是高吞吐量的,那麼你不必擔心執行緩存維護以清除過期的條目之類的操作等。如果你的緩存確實很少執行寫操作,並且你不想清除操作來阻塞緩存讀取操作,則你可能希望創建自己的維護線程,該線程定期調用Cache.cleanUp()
。
如果要爲一個很少有寫操作的緩存安排定期的緩存維護,只需使用ScheduledExecutorService
安排維護即可。
4.7刷新
刷新與淘汰並不完全相同。如LoadingCache.refresh(K)
中所指定的,刷新鍵可能會異步加載該鍵的新值。在鍵被刷新時,舊值(如果有的話)仍然返回,而淘汰將強制檢索等待,直到重新加載該值。
如果在刷新時拋出異常,則將保留舊值,並記錄和丟棄該異常。
CacheLoader
可以通過重寫CacheLoader.reload(K, V)
來指定要在刷新時使用的智能行爲,這允許你可以在計算新值時使用舊值。
// Some keys don't need refreshing, and we want refreshes to be done asynchronously.
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<Graph> reload(final Key key, Graph prevGraph) {
if (neverNeedsRefresh(key)) {
return Futures.immediateFuture(prevGraph);
} else {
// asynchronous!
ListenableFutureTask<Graph> task = ListenableFutureTask.create(new Callable<Graph>() {
public Graph call() {
return getGraphFromDatabase(key);
}
});
executor.execute(task);
return task;
}
}
});
可以使用CacheBuilder.refreshAfterWrite(long, TimeUnit)
將自動定時刷新添加到緩存中。與expireAfterWrite
相比,refreshAfterWrite
將使鍵在指定的持續時間之後有資格進行刷新,但是僅在查詢條目時纔會真正啓動刷新。(如果將CacheLoader.reload
實現爲異步,則刷新不會降低查詢的速度)因此,例如,你可以在同一緩存上同時指定refreshAfterWrite
和expireAfterWrite
,這樣只要條目符合刷新資格,就不會盲目地重置條目的過期計時器,因此,如果條目在符合刷新資格後未被查詢,則允許該條目過期。
5.特性
5.1統計
通過使用CacheBuilder.recordStats()
,可以打開Guava緩存的統計信息收集。Cache.stats()
方法返回CacheStats
對象,該對象提供統計信息,例如
hitRate()
,它返回匹配數與請求數的比率averageLoadPenalty()
,加載新值所花費的平均時間,以納秒爲單位evictionCount()
,緩存淘汰的數量,不包括顯式清除
以及其他更多統計信息。這些統計信息在緩存調優中至關重要,我們建議在性能關鍵型應用中留意這些統計信息。
5.2asMap
你可以使用其asMap
視圖將任何Cache
作爲ConcurrentMap
查看,但是asMap
視圖如何與Cache
交互需要一些解釋。
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()
的集合視圖的操作。例如,遍歷cache.asMap().entrySet()
不會重置你檢索的條目的訪問時間。
6.中斷
加載方法(如get
)從不拋出InterruptedException
。我們本可以設計這些方法來支持InterruptedException
,但是我們的支持將是不完整的,迫使所有用戶付出代價,但只有部分受益。有關詳細信息,請繼續閱讀。
get
調用請求未緩存的值可分爲兩大類:一類是加載值的和另一類是等待另一個正在運行的線程加載的。兩者在支持中斷的能力上有所不同。最簡單的情況是等待另一個正在進行的線程的加載:在這裏我們可以輸入一個可中斷的等待。困難的情況是我們自己加載值。在這裏,我們由用戶提供的CacheLoader
決定。如果它碰巧支持中斷,我們可以支持中斷;如果不支持,我們不能。
那麼,爲什麼在提供的CacheLoader
支持時不支持中斷呢?從某種意義上說,我們這樣做(但請參見下文):如果CacheLoader
拋出InterruptedException
,則對該鍵的所有get
調用將立即返回(與任何其他異常一樣)。另外,get
將恢復加載線程中的中斷位。令人驚訝的部分是InterruptedException
包裝在ExecutionException
中。
原則上,我們可以爲你解開此異常。但是,這將強制所有LoadingCache
用戶處理InterruptedException
,即使大多數CacheLoader
實現從不拋出該異常。當你考慮到所有非加載線程的等待仍可能被中斷時,也許這仍然是值得的。但是許多緩存僅在單個線程中使用。他們的用戶仍然必須捕獲不可能的InterruptedException
。而且,即使是那些在線程間共享緩存的用戶,有時也只能根據哪個線程首先發出請求來中斷其get
調用。
在此決策中,我們的指導原則是使緩存的行爲就像所有值都已加載到調用線程中一樣。這一原則可以輕鬆地將緩存引入到先前在每次調用時重新計算其值的代碼中。而且,如果舊代碼不可中斷,那麼新代碼也可以。
我說過,我們"在某種意義上"支持中斷。另一種意義上,那就是使LoadingCache
成爲泄漏的抽象。如果加載線程被中斷,我們會像對待其他異常一樣對待它。在許多情況下都可以,但是當多個get
調用正在等待該值時,這不是正確的選擇。儘管恰好正在計算該值的操作被中斷,但其他需要該值的操作可能並未中斷。然而,所有這些調用者都接收InterruptedException
(包裝在ExecutionException
中),即使負載並沒有像“中止”那麼“失敗”。正確的行爲是讓剩餘線程之一重試加載。我們爲此提交了一個錯誤。然而,修復可能會有風險。我們可以在提議的AsyncLoadingCache
中投入額外精力而不是修復這個問題,該方法將返回具有正確中斷行爲的Future
對象。