[譯]高性能緩存庫Caffeine介紹及實踐

概覽

本文我們將介紹Caffeine-一個Java高性能緩存庫。緩存和Map之間的一個根本區別是緩存會將儲存的元素逐出。逐出策略決定了在什麼時間應該刪除哪些對象,逐出策略直接影響緩存的命中率,這是緩存庫的關鍵特徵。Caffeine使用Window TinyLfu逐出策略,該策略提供了接近最佳的命中率。

添加依賴

首先在pom.xml文件中添加Caffeine相關依賴:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.5.5</version>
</dependency>

您可以在Maven Central上找到最新版本的Caffeine。

緩存填充

讓我們集中討論Caffeine的三種緩存填充策略:手動,同步加載和異步加載。

首先,讓我們創建一個用於存儲到緩存中的DataObject類:

class DataObject {
    private final String data;
 
    private static int objectCounter = 0;
    // standard constructors/getters
     
    public static DataObject get(String data) {
        objectCounter++;
        return new DataObject(data);
    }
}

手動填充

在這種策略中,我們手動將值插入緩存中,並在後面檢索它們。

讓我們初始化緩存:

Cache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .maximumSize(100)
  .build();

現在,我們可以使用getIfPresent方法從緩存中獲取值。如果緩存中不存在該值,則此方法將返回null:

String key = "A";
DataObject dataObject = cache.getIfPresent(key);
 
assertNull(dataObject);

我們可以使用put方法手動將值插入緩存:

cache.put(key, dataObject);
dataObject = cache.getIfPresent(key);
 
assertNotNull(dataObject);

我們還可以使用get方法獲取值,該方法將Lambda函數和鍵作為參數。如果緩存中不存在此鍵,則此Lambda函數將用於提供返回值,並且該返回值將在計算後插入緩存中:

dataObject = cache
  .get(key, k -> DataObject.get("Data for A"));
 
assertNotNull(dataObject);
assertEquals("Data for A", dataObject.getData());

get方法以原子方式(atomically)執行計算。這意味着計算將只進行一次,即使多個線程同時請求該值。這就是為什麼使用get比getIfPresent更好。

有時我們需要手動使某些緩存的值無效:

cache.invalidate(key);
dataObject = cache.getIfPresent(key);
 
assertNull(dataObject);

同步加載

這種加載緩存的方法具有一個函數,該函數用於初始化值,類似於手動策略的get方法。讓我們看看如何使用它。

首先,我們需要初始化緩存:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

現在,我們可以使用get方法檢索值:

DataObject dataObject = cache.get(key);
 
assertNotNull(dataObject);
assertEquals("Data for " + key, dataObject.getData());

我們還可以使用getAll方法獲得一組值:

Map<String, DataObject> dataObjectMap 
  = cache.getAll(Arrays.asList("A", "B", "C"));
 
assertEquals(3, dataObjectMap.size());

從傳遞給build方法的初始化函數中檢索值。這樣就可以通過緩存在來裝飾訪問值。

異步加載

該策略與先前的策略相同,但是異步執行操作,並返回保存實際值的CompletableFuture:

AsyncLoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .expireAfterWrite(1, TimeUnit.MINUTES)
  .buildAsync(k -> DataObject.get("Data for " + k));

考慮到它們返回CompletableFuture的事實,我們可以以相同的方式使用get和getAll方法:

String key = "A";
 
cache.get(key).thenAccept(dataObject -> {
    assertNotNull(dataObject);
    assertEquals("Data for " + key, dataObject.getData());
});
 
cache.getAll(Arrays.asList("A", "B", "C"))
  .thenAccept(dataObjectMap -> assertEquals(3, dataObjectMap.size()));

CompletableFuture具有豐富而有用的API,您可以在本文中了解更多信息。

逐出元素

Caffeine具有三種元素逐出策略:基於容量,基於時間和基於引用。

基於容量的逐出

這種逐出發生在超過配置的緩存容量大小限制時。有兩種獲取容量當前佔用量的方法,計算緩存中的對象數量或獲取它們的權重。

讓我們看看如何處理緩存中的對象。初始化高速緩存時,其大小等於零:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(1)
  .build(k -> DataObject.get("Data for " + k));
 
assertEquals(0, cache.estimatedSize());

當我們添加一個值時,大小顯然會增加:

cache.get("A");
 
assertEquals(1, cache.estimatedSize());

我們可以將第二個值添加到緩存中,從而導致刪除第一個值:

cache.get("B");
cache.cleanUp();
 
assertEquals(1, cache.estimatedSize());

值得一提的是,在獲取緩存大小之前,我們先調用cleanUp方法。這是因為緩存逐出是異步執行的,並且此方法有助於等待逐出操作的完成。

我們還可以傳遞一個*weigher*函數來指定緩存值的權重大小:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumWeight(10)
  .weigher((k,v) -> 5)
  .build(k -> DataObject.get("Data for " + k));
 
assertEquals(0, cache.estimatedSize());
 
cache.get("A");
assertEquals(1, cache.estimatedSize());
 
cache.get("B");
assertEquals(2, cache.estimatedSize());

當權重超過10時,將按照時間順序從緩存中刪除多餘的值:

cache.get("C");
cache.cleanUp();
 
assertEquals(2, cache.estimatedSize());

基於時間的逐出

此逐出策略基於元素的到期時間,並具有三種類型:

  • Expire after access — 自上次讀取或寫入發生以來,經過過期時間之後該元素到期。
  • Expire after write — 自上次寫入以來,在經過過期時間之後該元素過期。
  • Custom policy — 通過Expiry實現分別計算每個元素的到期時間。

讓我們使用expireAfterAccess方法配置訪問後過期策略:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterAccess(5, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

要配置寫後過期策略,我們使用expireAfterWrite方法:

cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));

要初始化自定義策略,我們需要實現Expiry接口:

cache = Caffeine.newBuilder().expireAfter(new Expiry<String, DataObject>() {
    @Override
    public long expireAfterCreate(
      String key, DataObject value, long currentTime) {
        return value.getData().length() * 1000;
    }
    @Override
    public long expireAfterUpdate(
      String key, DataObject value, long currentTime, long currentDuration) {
        return currentDuration;
    }
    @Override
    public long expireAfterRead(
      String key, DataObject value, long currentTime, long currentDuration) {
        return currentDuration;
    }
}).build(k -> DataObject.get("Data for " + k));

基於引用的逐出

我們可以將緩存配置為允許垃圾回收緩存的鍵或值。為此,我們將為鍵和值配置WeakRefence的用法,並且我們只能為值的垃圾收集配置為SoftReference。

當對象沒有任何強引用時,WeakRefence用法允許對對象進行垃圾回收。 SoftReference允許根據JVM的全局「最近最少使用」策略對對象進行垃圾收集。有關Java引用的更多詳細信息,請參見此處

我們應該使用Caffeine.weakKeys(),Caffeine.weakValues()和Caffeine.softValues()來啟用每個選項:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .weakKeys()
  .weakValues()
  .build(k -> DataObject.get("Data for " + k));
 
cache = Caffeine.newBuilder()
  .expireAfterWrite(10, TimeUnit.SECONDS)
  .softValues()
  .build(k -> DataObject.get("Data for " + k));

刷新緩存

可以將緩存配置為在定義的時間段後自動刷新元素。讓我們看看如何使用refreshAfterWrite方法執行此操作:

Caffeine.newBuilder()
  .refreshAfterWrite(1, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

在這裡,我們應該了解expireAfter和refreshAfter之間的區別。前者當請求過期元素時,執行將阻塞,直到build()計算出新值為止。

但是後者將返回舊值並異步計算出新值並插入緩存中,此時被刷新的元素的過期時間將重新開始計時計算。

統計

Caffeine可以記錄有關緩存使用情況的統計信息:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .maximumSize(100)
  .recordStats()
  .build(k -> DataObject.get("Data for " + k));
cache.get("A");
cache.get("A");
 
assertEquals(1, cache.stats().hitCount());
assertEquals(1, cache.stats().missCount());

我們將recordStats傳遞給它,recordStats創建StatsCounter的實現。每次與統計相關的更改都將推送給此對象。

總結

在本文中,我們熟悉了Java的Caffeine緩存庫。我們了解了如何配置和填充緩存,以及如何根據需要選擇適當的過期或刷新策略。


🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟

歡迎訪問筆者博客:blog.dongxishaonian.tech

關注筆者公眾號,推送各類原創/優質技術文章 ⬇️

WechatIMG6