【Guava 用戶指南——個人翻譯】Caches(緩存技術)
Example – 舉個栗子
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);
}
});
Applicability – 適用於
緩存在很多場景下都非常有用.比如,當計算或者搜索一個值的開銷特別大、或者需要多次獲取同一個輸出而產生的值的時候.
Cache
有些類似於 Java 集合類中的 ConcurrentMap
, 但又不是完全相同.
最基本的不同之處在於 ConcurrentMap
會保存所有的元素直到這些元素被顯示的移除,
而 Cache
爲了限制內存佔用通常會被設置爲自動清理元素.在某些情況下,
儘管LoadingCache
從不回收元素,它也是很有用的,它會在必要的時候自動加載緩存.
總的來說, Guava 緩存適用於以下幾個方面:
- 你願意使用更多的內存來提升速度(空間換時間).
- 你預料到某些鍵將會被使用一次以上.
- 緩存數據量不會超過你的內存大小.(Guava caches 是你應用程序上單線程的本地緩存, 數據不會存儲到文件或者外部服務器中.
如果這滿足不了你的需求,請考慮一下比如 Memcached 的工具.(注:Redis 也可以))
如果你的應用場景符合上邊的每一條,Guava Caches 就非常滿足你的要求.
如同示例一樣,Cache
可以通過 CacheBuilder
來生成,但是自定義你的 Cache
纔是最有趣的一部分.
注:如果你不需要 Cache
的一些特性,ConcurrentHashMap
有更優的內存效率,
但是通過 ConcurrentMap
來複制(實現) Cache
的一些特性卻是極其困難或者說是不可能的.
Population – 成員
關於你的緩存,你首先應該問自己一個問題:有沒有一個 合理、默認 的方法去加載或者計算一個與鍵關聯的值?
如果有,你應該採用 CacheLoader
,如果沒有,或者你想要重寫默認的 加載-計算 方法,而且希望保有 獲取緩存-若沒有-進行計算
[get-if-absent-compute] 的原始語義(實現思路), 你應該在調用get
方法時傳入(pass)一個Callable
實例.
我們可以將元素通過 Cache.put
方法直接插入,但是採用自動加載仍然是首選方案,因爲它可以更容易的推斷緩存內容的一致性.
From a CacheLoader – Cache加載器
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
可能拋出 Exception
,
LoadingCache.get(K)
也會拋出一個ExecutionException
(如果緩存加載器拋出一個非檢查時異常,
那麼get(K)
就會返回一個包裝過的UncheckedExecutionException
異常)。
如果你的 CacheLoader
沒有聲明任何檢查時異常, 你可以使用 getUnchecked(K)
(包裝了所有的UncheckedExecutionException
).
如果它聲明瞭檢查時異常,那麼這就會造成一些令人驚訝的(難以解決)的問題(不能使用 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
來對每一個key加載緩存項.你可以重寫 [CacheLoader.loadAll
] 去使一個批量加載的效率高於多個單獨加載.
getAll(Iterable)
的性能也會相應的提高。
注:你可以用一個 CacheLoader.loadAll
來實現爲“沒有明確請求的鍵”加載緩存值的功能。例如,
你要計算一個可以提供所有鍵值的組中的任意鍵的值, 採用loadAll
就可以同時獲取到組中的其他鍵值。
From a Callable – 回調
所有的 Guava caches, 不管有沒有自動加載,都支持 [get(K, Callable<V>)
] 方法.
這個方法返回了緩存中相對應的值,或者對特定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());
}
Inserted Directly – 顯示插入
值可以通過 [cache.put(key, value)
] 方法顯示的插入到緩存中.這會覆蓋掉這個key以前所映射的任何的值.
使用 Cache.asMap()
提供的任何 ConcurrentMap
方法也能修改緩存。注:asMap
中的任何方法都不能使緩存項自動的加載到緩存中.
進一步來說,視圖的原子運算是在緩存的自動加載範圍之外的, 所以使用CacheLoader
或者 Callable
加載緩存項的時候,
Cache.get(K, Callable<V>)
總是優先於 Cache.asMap().putIfAbsent
被使用。
Eviction – 內存回收
現在有個非常殘酷的現實:那就是肯定沒有足夠的內存來緩存我們需要緩存的內容.
你必須要決定某個項什麼時候不需要保留了? Guava Cache 提供了三種內存回收的方式:
基於內存大小的回收、超時回收和基於引用的回收。
Size-based Eviction – 基於內存大小的回收(超出你設置的內存大小)
你可以使用 [CacheBuilder.maximumSize(long)
] 來確定緩存空間的大小.
緩存會嘗試清除最近沒有使用或者不常使用的緩存項.警告: 在緩存值達到你設定的限額之前,
緩存也可能會開始清除操作——這通常發生在緩存量接近設定值的時候.
另一種方式來說,不同的緩存項擁有不同的“權重”. 舉個栗子來說:如果你的緩存值佔據着不同的內存空間,
你可以使用 [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
提供了超時回收的兩種方式:
- [
expireAfterAccess(long, TimeUnit)
] 緩存項在限制時間內沒有讀/寫操作, 它就將被回收.注:這種方式下的回收順序與基於大小的回收 size-based eviction 相同。 - [
expireAfterWrite(long, TimeUnit)
] 緩存項在限制時間內沒有進行寫訪問(創建/覆蓋鍵值)則回收。如果緩存項被認爲在一段時間後變得陳舊不可用,就可以採用此種方式。
就像討論中的那樣, 超時回收週期性的在寫操作中執行, 偶爾也在讀操作中執行。
Testing Timed Eviction – 超時回收的測試
對於超時回收的測試, 不是一定需要痛苦的等待的…也就是說, 並不是一個兩秒的超時回收就一定要等待兩秒鐘去進行測試.
使用 Ticker 接口和 [CacheBuilder.ticker(Ticker)
] 方法在你的緩存中自定義一個時間源, 以此來代替系統時鐘。
Reference-based Eviction – 基於引用的回收
通過使用弱引用 weak references 來標記鍵和值、使用軟引用 soft references 來標記值,
Guava 允許你將緩存設置爲垃圾回收(將你的緩存項存入垃圾集合).
- [
CacheBuilder.weakKeys()
] 將鍵標記爲弱引用. 當這個鍵沒有其他的引用方式(強引用或軟引用)時,緩存項可以被垃圾回收.
因爲垃圾回收依賴於強一致(恆等), 所以這導致了這些 “弱引用鍵的緩存” 採用==
而不是equals()
來比較鍵。 - [
CacheBuilder.weakValues()
] 將值標記爲弱引用. 當這個值沒有其他的引用方式(強引用或軟引用)時,緩存項可以被垃圾回收.
因爲垃圾回收依賴於強一致(恆等), 所以這導致了這些 “弱引用鍵的緩存” 採用==
而不是equals()
來比較值。 - [
CacheBuilder.softValues()
] 將值標記爲軟引用. 軟引用只是在需要釋放內存時才進行垃圾回收, 而且是選擇全局最近最少使用的緩存項.
考慮到使用軟引用時導致的一些性能影響, 我們建議採用更有預測性的方法如 設定緩存最大值(基於大小的回收策略)
來進行一些限定。softValues()
同樣採用==
而不是equals()
來比較值。
Explicit Removals – 顯示移除
在任何時間, 你都可以指定移除某一緩存項, 而不是等待它被系統回收,你可以採用以下幾個方法:
- 單項移除, 使用 [
Cache.invalidate(key)
] 方法 - 部分移除, 使用 [
Cache.invalidateAll(keys)
] 方法 - 全部移除, 使用 [
Cache.invalidateAll()
] 方法
Removal Listeners – 移除時監聽器
你可以通過 [CacheBuilder.removalListener(RemovalListener)
] 聲明一個 移除時監聽器,
這可以讓你在移除一個緩存項的時候做點其他的事。通過 [RemovalNotification
] 可以獲取一個 [RemovalListener
],
需要一個[RemovalCause
]移除原因、key和value, 如下面的代碼:
注:RemovalListener
拋出的任何異常,都會在記錄到日誌中(使用 Logger
持久化)後被丟棄(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()
// 超時回收 2分鐘
.expireAfterWrite(2, TimeUnit.MINUTES)
.removalListener(removalListener)
.build(loader);
警告:默認情況下,移除監聽器的觸發是和緩存項移除同步進行的, 此時, 性能開銷巨大的監聽器會拉低緩存效率!
而此時, 你應該使用 [RemovalListeners.asynchronous(RemovalListener, Executor)
] 來將監聽器 RemovalListener
裝飾爲異步操作。
清理(內存釋放)會在什麼時候發生?
使用 CacheBuilder
構建的緩存不會自動的進行清理或者回收值的操作, 也不會在超時後立即處理, 也沒有如上所說的清理機制.
相反的是, 它會在執行寫操作的時候進行一小部分的維護工作, 如果寫操作實在太少, 那麼它也會偶爾在讀操作的時候這樣做.
這樣做的原因在於: 如果我們想不斷的對緩存進行維護, 我們需要創建一個線程, 這個線程會和用戶操作(讀/寫)競爭共享鎖.
此外, 在某些環境下會限制線程的創建, 那在這樣的環境中 CacheBuilder
就不能用了。
對此, 我們將選擇權交到你的手裏. 如果你的緩存是高吞吐量, 那麼你完全不用考慮超時清理等類似的維護工作;
如果你的緩存只有一些小量的寫操作而你又不希望維護線程阻礙你的讀操作, 你就可以創建一個你自己的維護線程定期調用 [Cache.cleanUp()
] 來進行維護.
如果你想要你的維護線程只在少量的寫操作時執行規定的緩存維護任務, [ScheduledExecutorService
] 會給你提供有效的幫助.
Refresh – 刷新
刷新操作並不是與回收操作同步進行的. 正如 [LoadingCache.refresh(K)
] 中指定聲明的那樣: 刷新指的是爲一個 key 加載新的 value,
可能是異步執行的.刷新過程中, 老的 value 仍然可以被返回(從緩衝中獲取), 直到刷新完成; 不像回收, 讀取緩存值必須要等回收結束.
如果在刷新過程中拋出了一個異常, 那麼舊值會被繼續保留, 異常在記錄到日誌中後被丟棄。
CacheLoader
支持開發者重寫 [CacheLoader.reload(K, V)
] 時加入個性化的操作, 比如允許你在計算新值時採用舊值數據。
// 有些鍵不需要刷新 所以我們希望刷新是異步操作的。
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(
new CacheLoader<Key, Graph>() {
public Graph load(Key key) { // 沒有檢查異常
return getGraphFromDatabase(key);
}
public ListenableFuture<Graph> reload(final Key key, Graph prevGraph) {
if (neverNeedsRefresh(key)) {
return Futures.immediateFuture(prevGraph);
} else {
// 異步操作!!!+-
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
通過定時刷新使緩存項在一定時間內可用, 但是緩存項只有在 key
被檢索時纔會真正的刷新(如果 CacheLoader.reload
已經實現異步, 那麼檢索性能/速度不會因爲刷新而減慢).
所以,你可以在同一個緩存上同時使用 refreshAfterWrite
和 expireAfterWrite
, 緩存項不會因爲觸發刷新而盲目的重置,
因爲如果緩存項沒有被檢索, 那麼刷新就不會真正的生效, 緩存項在過期之後也可以被回收。
Features – 特性
Statistics – 統計(對Caches工作狀態的統計)
通過使用 [CacheBuilder.recordStats()
] 方法開啓 Guava cache 的統計功能。統計開啓後,
[Cache.stats()
]方法返回一個[CacheStats
]對象,裏面提供瞭如下的統計信息:
- [
hitRate()
],返回緩存的命中率 - [
averageLoadPenalty()
], 返回加載新值的平均時間,單位爲納秒(nanoseconds) - [
evictionCount()
], 被回收的緩存數量。
還有很多其他的統計信息,這些信息對於調整緩存設置至關重要,在一些特別要求性能的應用中,我們應該對此保持密切關注。
asMap
– asMap視圖
你可以使用 ConcurrentMap
的 asMap
來創建一個緩存視圖,但是對於 asMap
視圖和緩存的交互機制,這裏要作一些解釋:
cache.asMap()
包含所有加載到緩存中的項,比如cache.asMap().keySet()
包含了所有已經加載到緩存中的 key 值。asMap().get(key)
本質上相當於cache.getIfPresent(key)
,而且不會引起緩存項得加載, 這與Map
語義約定一致。- 所有緩存項的讀寫操作都會重置相關緩存項的讀取時間(包括
Cache.asMap().get(Object)
和Cache.asMap().put(K, V)
),
但是containsKey(Object)
方法不包括在其中,同樣也不包含Cache.asMap()
方法在集合視圖上的操作.
比如遍歷cache.asMap().entrySet()
就不會重置緩存項的讀取時間。
Interruption – 中斷
緩存加載方法(比如 get
)從來不會拋出 InterruptedException
異常.我們應該設計一些方法去支持 InterruptedException
,
但是這種支持通常是不完善的,強迫增加所有使用者的開銷,可是卻只有少部分獲益.
get
請求到未緩存的值時通常是因爲兩個方面:一個是 當前線程加載值;二是 等待另一個加載值的線程.
所以我們支持中斷的兩種方式是不一樣的.等待另一個加載值的線程 是較爲簡單的一種情況:這裏我們可以加入一箇中斷等待;
當前線程加載值 的中斷就比較困難:線程運行在用戶提供的CacheLoader
中,如果它是可中斷的,我們就可以實現對中斷的支持,
如果不可中斷,那麼就不行.
所以爲什麼用戶提供的CacheLoader
是可中斷的,而 get
卻不提供顯示的支持? 某種意義上來說,我們提供了支持:
如果一個 CacheLoader
拋出了一個 InterruptedException
異常,get
方法將立刻返回 key 值(與其他的異常情況相同).
此外,在正在執行的線程中,get
捕捉到InterruptedException
後將會恢復中斷,其他的線程則將 InterruptedException
包裝成 ExecutionException
.
原則上來說,我們可以將 ExecutionException
接封裝爲 InterruptedException
,但是這會導致所有的 LoadingCache
的使用者都要處理異常,
儘管大部分的CacheLoader
的實現都沒有拋出這個異常. 你可能認爲所有的非加載線程 的等待都應該可以被中斷,
這種想法是很有價值的. 但是很多情況下,緩存只使用在單個線程中, 它們的用戶仍然需要catch(捕獲)那個不可能被拋出的 InterruptedException
異常.
那些跨線程共享緩存的用戶也只是在有的時候中斷它們的get
調用,這個時間取決於哪個線程先發出了請求。
我們的一個設計原則是:讓緩存看上去只是在當前線程中加載值。這個原則使得 每次將caching引入代碼預先計算它的值 變得容易實現.
如果老代碼不能被中斷,那麼新的代碼同樣是不能被中斷的。
所以說在某種意義上我們(Guava) 支持中斷, 而在另一個意義上來說, 我們(Guava)又不支持, 這使得 LoadingCache
是一個有漏洞的抽象:
如果加載中線程被中斷了, 我們將它當做其他異常一樣進行處理, 這在某些情況下來說是可以的; 但是當多個get
線程等待加載同一個緩存項時,
就是不正確的, 這種情況下, 即使這個計算中線程被中斷了, 其他的線程(也捕獲到了InterruptedException
異常)也不應都失敗,
正確的行爲是讓某個線程重新加載。爲此,我們記錄了一個 bug. 然而,
逾期冒風險修復這個bug,不如花更多的精力去設計一個 AsyncLoadingCache
(同步的), 這個實現會返回一個具有可中斷行爲的 Future
對象。
後記:我將所有內容都發布在 Github 上,您可以加入我們,也可以提出問題共同討論,下面是我Github 的項目地址:【guava-jch】
~如果能點個 star 就更好了