[譯]高效能快取庫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
關注筆者公眾號,推送各類原創/優質技術文章 ️