Java 類庫中的瑞士軍刀:Google Guava 緩存
Google Guava 被譽為是JAVA類庫中的瑞士軍刀。能顯著簡化代碼,讓代碼易寫、易讀、易于維護。同時可以大幅提高程序員的工作效率,讓我們從大量重復的底層代碼中脫身。
由于 Google Guava 類庫包含大量非常有用的特性,無法在一篇文章中盡述。本篇僅簡單介紹 Google Guava 中的緩存工具的使用。
依賴
使用 Maven 進行項目構建時,添加下面的依賴:
- <dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
- <version>29.0-jre</version>
- <!-- or, for Android: -->
- <version>29.0-android</version>
- </dependency>
使用 Gradle 進行項目構建時,添加下面的依賴:
- dependencies {
- // Pick one:
- // 1. Use Guava in your implementation only:
- implementation("com.google.guava:guava:29.0-jre")
- // 2. Use Guava types in your public API:
- api("com.google.guava:guava:29.0-jre")
- // 3. Android - Use Guava in your implementation only:
- implementation("com.google.guava:guava:29.0-android")
- // 4. Android - Use Guava types in your public API:
- api("com.google.guava:guava:29.0-android")
- }
示例
- 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);
- }
- });
適用性
緩存有非常廣泛的應用場景。比如,你應該為那些計算或者查詢代價高昂的數據使用緩存,或者你需要某個輸入數據很多次的場景。
一個 `Cache` 類似于 `ConcurrentMap`,不過并不完全相同。基本的差異在于, `ConcurrentMap` 持久化所有添加進來的元素直到它們被顯式刪除。另一方面,通常將 `Cache` 配置為自動淘汰條目,以限制其內存占用量。在某些情況下, `LoadingCache` 會很有用,雖然它不淘汰條目,但是可以自動加載緩存。
通常,Guava 緩存工具可以適用于下列場景:
- 你希望使用一些內存空間來改善速度。
- 您希望多次查詢某些鍵。
- 您的緩存將不需要存儲超出 RAM 容量的數據。(Guava 緩存的作用范圍局限于在應用程序的一次運行中。它們不將數據存儲在文件中或外部服務器上。如果這不符合您的需求,請考慮使 Memcached)
如果這些都適用于您的應用場景,那么 Guava 緩存實用程序將很適合您!
如上面的示例代碼所示,使用 `CacheBuilder` 生成器模式可以獲取 `Cache`,但是自定義緩存是有趣的部分。
注意:如果不需要 `Cache` 的功能,則 `ConcurrentHashMap` 的內存使用效率更高——但是很難用任何舊的 `ConcurrentMap`來復制大多數 `Cache` 的功能。
填充
你需要問自己有關緩存的第一個問題是:是否有一些合理的默認函數來加載或計算與鍵關聯的值?如果是這樣,您應該使用 `CacheLoader`。如果不是這樣,或者如果您需要覆蓋默認值,但是仍然需要原子的 "get-if-absent-compute" 語義,則應該將 `Callable` 傳遞給 `get` 調用。可以使用 `Cache.put` 直接插入元素,但是首選自動加載緩存,因為這樣可以更輕松地推斷所有緩存內容的一致性。
使用 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`。(如果緩存加載器拋出 unchecked 異常,則`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` 可能會同時加載其余組。
使用 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());
- }
直接插入
可以直接使用 `cache.put(key, value)` 。這將覆蓋高速緩存中指定鍵的任何先前條目。也可以使用 `Cache.asMap()` 視圖公開的任何 `ConcurrentMap` 方法對緩存進行更改。注意,`asMap` 視圖上的任何方法都不會導致條目自動加載到緩存中。此外,該視圖上的原子操作在自動緩存加載范圍之外運行,因此在使用 `CacheLoader` 或 `Callable` 加載值的緩存中,始終應優先選擇 `Cache.get(K, Callable<V>)` 而不是 `Cache.asMap().putIfAbsent` 。
驅逐
冷酷的現實是,我們幾乎肯定沒有足夠的內存來緩存我們可以緩存的所有內容。您必須決定:什么時候不值得保留緩存條目?Guava 提供三種基本的驅逐類型:基于大小的驅逐,基于時間的驅逐和基于引用的驅逐。
基于大小的驅逐
如果你的緩存在達到某個大小之后就不應該繼續增長,可以使用 `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);
- }
- });
基于時間的驅逐
- `CacheBuilder` 提供了兩種基于時間的驅逐方法:
- `expireAfterAccess(long, TimeUnit)` 僅在自從上次通過讀取或寫入訪問條目以來經過指定的持續時間后,條目才到期。請注意,驅逐條目的順序將類似于基于大小的驅逐。
- `expireAfterWrite(long, TimeUnit)` 自創建條目以來經過指定的時間或該值的最新替換之后,使條目過期。如果經過一定時間后緩存的數據持續增長,則可能需要這樣做。
定時到期是在寫入過程中進行定期維護的,偶爾在讀取過程中進行維護,如下所述。
基于引用的驅逐
Guava 允許你設置你的緩存以允許數據實體的垃圾收集,通過對鍵或者值使用的 weak references ,或者對值使用的 soft references 進行設置。
- `CacheBuilder.weakKeys()` 使用弱引用存儲鍵。這允許實體在沒有其他引用(強引用或者軟引用)指向其鍵時被垃圾收集。由于垃圾收集基于 id 相等規則,這就導致整個緩存多需要使用 id (`==`)相等來比較鍵,而不是使用 `equals()`。
- `CacheBuilder.weakValues()` 使用弱引用存儲值。這允許實體在沒有其他引用(強引用或者軟引用)指向其值時被垃圾收集。由于垃圾收集基于 id 相等規則,這就導致整個緩存多需要使用 id (`==`)相等來比較值,而不是使用 `equals()`。
- `CacheBuilder.softValues()` 將值包裝進入軟引用。軟引用對象以全局最近最少使用規則進行垃圾收集,以響應內存需求。由于使用軟引用可能會有些性能問題,我們通常推薦使用更加容易預測的 maximum cache size 替代。使用 `softValues()` 將導致值被通過 id (`==`) 相等比較,而不是使用 `equals()`。
顯式刪除
任何時刻,你都可以顯式廢除緩存實體,而不需要等待實體被驅逐。可以通過以下方法:
- 單個廢除,使用 `Cache.invalidate(key)`
- 批量廢除,使用 `Cache.invalidateAll(keys)`
- 全部廢除,使用 `Cache.invalidateAll()`
清理何時發生?
用 `CacheBuilder` 構建的緩存不會“自動”或在值過期后立即執行清除和逐出值,或類似的任何操作。取而代之的是,它在寫操作期間或偶爾進行的讀操作(如果很少進行寫操作)中執行少量維護。
這樣做的原因如下:如果我們要連續執行 `Cache` 維護,則需要創建一個線程,并且該線程的操作將與用戶操作競爭共享鎖。另外,某些環境限制了線程的創建,這會使 `CacheBuilder` 在該環境中無法使用。
相反,我們會將選擇權交給您。如果您的緩存是高吞吐量的,那么您不必擔心執行緩存維護以清理過期的條目等。 如果您的緩存確實很少寫入,并且您不想清理來阻止緩存讀取,則您可能希望創建自己的維護線程,該線程定期調用 `Cache.cleanUp()`。
如果要為很少寫入的緩存安排定期的緩存維護,只需使用 `ScheduledExecutorService` 調度維護操作。
刷新
刷新與驅逐并不完全相同。如 `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`,以便只要條目符合刷新資格,就不會盲目地重置條目的過期計時器,因此,如果在符合刷新資格后不查詢條目,則允許它過期。