解讀JVM級別本地快取Caffeine青出於藍的要訣 —— 緣何會更強、如何去上手

2022-12-06 21:00:59

大家好,又見面了。


本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會通過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。


在前面的幾篇文章中,我們一起聊了下本地快取的動手實現、本地快取相關的規範等,也聊了下Google的Guava Cache的相關原理與使用方式。比較心急的小夥伴已經坐不住了,提到本地快取,怎麼能不提一下「地上最強」的Caffeine Cache呢?

能被小夥伴稱之為「地上最強」,可見Caffeine的魅力之大!的確,提到JAVA中的本地快取框架,Caffeine是怎麼也沒法輕視的重磅嘉賓。前面幾篇文章中,我們一起探索了JVM級別的優秀快取框架Guava Cache,而相比之下,Caffeine可謂是站在巨人肩膀上,在很多方面做了深度的優化改良,可以說在效能表現命中率上全方位的碾壓Guava Cache,表現堪稱卓越。

下面就讓我們一起來解讀下Caffeine Cache的設計實現改進點原理,揭祕Caffeine Cache青出於藍的祕密所在,並看下如何在專案中快速的上手使用。

巨人肩膀上的產物

先來回憶下之前建立一個Guava cache物件時的程式碼邏輯:

public LoadingCache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .initialCapacity(1000)
            .maximumSize(10000L)
            .expireAfterWrite(30L, TimeUnit.MINUTES) 
            .concurrencyLevel(8)
            .recordStats()
            .build((CacheLoader<String, User>) key -> userDao.getUser(key));
}

而使用Caffeine來建立Cache物件的時候,我們可以這麼做:

public LoadingCache<String, User> createUserCache() {
    return Caffeine.newBuilder()
            .initialCapacity(1000)
            .maximumSize(10000L)
            .expireAfterWrite(30L, TimeUnit.MINUTES)
            //.concurrencyLevel(8)
            .recordStats()
            .build(key -> userDao.getUser(key));
}

可以發現,兩者的使用思路與方法定義非常相近,對於使用過Guava Cache的小夥伴而言,幾乎可以無門檻的直接上手使用。當然,兩者也還是有點差異的,比如Caffeine建立物件時不支援使用concurrencyLevel來指定並行量(因為改進了並行控制機制),這些我們在下面章節中具體介紹。

相較於Guava Cache,Caffeine在整體設計理念、實現策略以及介面定義等方面都基本繼承了前輩的優秀特性。作為新時代背景下的後來者,Caffeine也做了很多細節層面的優化,比如:

  • 基礎資料結構層面優化
    藉助JAVA8對ConcurrentHashMap底層由連結串列切換為紅黑樹、以及廢棄分段鎖邏輯的優化,提升了Hash衝突時的查詢效率以及並行場景下的處理效能。

  • 資料驅逐(淘汰)策略的優化
    通過使用改良後的W-TinyLFU演演算法,提供了更佳的熱點資料留存效果,提供了近乎完美的熱點資料命中率,以及更低消耗的過程維護

  • 非同步並行能力的全面支援
    完美適配JAVA8之後的並行程式設計場景,可以提供更為優雅的並行編碼體驗與並行效率。

通過各種措施的改良,成就了Caffeine在功能與效能方面不俗的表現。

Caffeine與Guava —— 是傳承而非競爭

很多人都知道Caffeine在各方面的表現都由於Guava Cache, 甚至對比之下有些小夥伴覺得Guava Cache簡直一無是處。但不可否認的是,在曾經的一段時光裡,Guava Cache提供了儘可能高效且輕量級的並行本地快取工具框架。技術總是在不斷的更新與迭代的,縱使優秀如Guava Cache這般,終究是難逃淪為時代眼淚的結局。

縱觀Caffeine,其原本就是基於Guava cache基礎上孵化而來的改良版本,眾多的特性與設計思路都完全沿用了Guava Cache相同的邏輯,且提供的介面與使用風格也與Guava Cache無異。所以,從這個層面而言,本人更願意將Caffeine看作是Guava Cache的一種優秀基因的傳承與發揚光大,而非是競爭與打壓關係。

那麼Caffeine能夠青出於藍的祕訣在哪呢?下面總結了其最關鍵的3大要點,一起看下。

貫穿始終的非同步策略

Caffeine在請求上的處理流程做了很多的優化,效果比較顯著的當屬資料淘汰處理執行策略的改進。之前在Guava Cache的介紹中,有提過Guava Cache的策略是在請求的時候同時去執行對應的清理操作,也就是讀請求中混雜著寫操作,雖然Guava Cache做了一系列的策略來減少其觸發的概率,但一旦觸發總歸是會對讀取操作的效能有一定的影響。

Caffeine則採用了非同步處理的策略,get請求中雖然也會觸發淘汰資料的清理操作,但是將清理任務新增到了獨立的執行緒池中進行非同步的不會阻塞 get 請求的執行與返回,這樣大大縮短了get請求的執行時長,提升了響應效能。

除了對自身的非同步處理優化,Caffeine還提供了全套的Async非同步處理機制,可以支援業務在非同步並行流水線式處理場景中使用以獲得更加絲滑的體驗。

Caffeine完美的支援了在非同步場景下的流水線處理使用場景,回源操作也支援非同步的方式來完成。CompletableFuture並行流水線能力,是JAVA8非同步程式設計領域的一個重大改進。可以將一系列耗時且無依賴的操作改為並行同步處理,並等待各自處理結果完成後繼續進行後續環節的處理,由此來降低阻塞等待時間,進而達到降低請求鏈路時長的效果。

比如下面這段非同步場景使用Caffeine並行處理的程式碼:

public static void main(String[] args) throws Exception {
    AsyncLoadingCache<String, String> asyncLoadingCache = buildAsyncLoadingCache();
    // 寫入快取記錄(value值為非同步獲取)
    asyncLoadingCache.put("key1", CompletableFuture.supplyAsync(() -> "value1"));
    // 非同步方式獲取快取值
    CompletableFuture<String> completableFuture = asyncLoadingCache.get("key1");
    String value = completableFuture.join();
    System.out.println(value);
}

ConcurrentHashMap優化特性

作為使用JAVA8新特性進行構建的Caffeine,充分享受了JAVA8語言層面優化改進所帶來的效能上的增益。我們知道ConcurrentHashMap是JDK原生提供的一個執行緒安全的HashMap容器型別,而Caffeine底層也是基於ConcurrentHashMap進行構建與資料儲存的。

JAVA7以及更早的版本中,ConcurrentHashMap採用的是分段鎖的策略來實現執行緒安全的(前面文章中我們講過Guava Cache採用的也是分段鎖的策略),分段鎖雖然在一定程度上可以降低鎖競爭的衝突,但是在一些極高並行場景下,或者並行請求分佈較為集中的時候,仍然會出現較大概率的阻塞等待情況。此外,這些版本中ConcurrentHashMap底層採用的是陣列+連結串列的儲存形式,這種情況在Hash衝突較為明顯的情況下,需要頻繁的遍歷連結串列操作,也會影響整體的處理效能。

JAVA8中對ConcurrentHashMap的實現策略進行了較大調整,大幅提升了其在的並行場景的效能表現。主要可以分為2個方面的優化。

  • 陣列+連結串列結構自動升級為陣列+紅黑樹

預設情況下,ConcurrentHashMap的底層結構是陣列+連結串列的形式,元素儲存的時候會先計算下key對應的Hash值來將其劃分到對應的陣列對應的連結串列中,而當連結串列中的元素個數超過8個的時候,連結串列會自動轉換為紅黑樹結構。如下所示:

在遍歷查詢方面,紅黑樹有著比連結串列要更加卓越的效能表現。

  • 分段鎖升級為synchronized+CAS

分段鎖的核心思想就是縮小鎖的範圍,進而降低鎖競爭的概率。當資料量特別大的時候,其實每個鎖涵蓋的資料範圍依舊會很大,如果並行請求量特別大的時候,依舊會出現很多執行緒搶奪同一把分段鎖的情況。

在JAVA8中,ConcurrentHashMap 廢棄分段鎖的概念,改為了synchronized+CAS的策略,藉助CAS的樂觀鎖策略,大大提升了讀多寫少場景下的並行能力。

得益於JAVA8對ConcurrentHashMap的優化,使得Caffeine在多執行緒並行場景下的表現非常的出色。

淘汰演演算法W-LFU的加持

常規的快取淘汰演演算法一般採用FIFOLRU或者LFU,但是這些演演算法在實際快取場景中都會存在一些弊端

演演算法 弊端說明
FIFO 先進先出策略,屬於一種最為簡單與原始的策略。如果快取使用頻率較高,會導致快取資料始終在不停的進進出出,影響效能,且命中率表現也一般。
LRU 最近最久未使用策略,保留最近被存取到的資料,而淘汰最久沒有被存取的資料。如果遇到偶爾的批次刷資料情況,很容易將其他快取內容都擠出記憶體,帶來快取擊穿的風險。
LFU 最近少頻率策略,這種根據存取次數進行淘汰,相比而言記憶體中儲存的熱點資料命中率會更高些,缺點就是需要維護獨立欄位用來記錄每個元素的存取次數,佔用記憶體空間。

為了保證命中率,一般快取框架都會選擇使用LRU或者LFU策略,很少會有使用FIFO策略進行資料淘汰的。Caffeine快取的LFU採用了Count-Min Sketch頻率統計演演算法(參見下圖示意,圖片來源:點此檢視),由於該LFU的計數器只有4bit大小,所以稱為TinyLFU。在TinyLFU演演算法基礎上引入一個基於LRU的Window Cache,這個新的演演算法叫就叫做W-TinyLFU


W-TinyLFU演演算法有效的解決了LRU以及LFU存在的弊端,為Caffeine提供了大部分場景下近乎完美命中率表現。

關於W-TinyLFU的具體說明,有興趣的話可以點此瞭解

如何選擇

在Caffeine與Guava Cache之間如何選擇?其實Spring已經給大家做了示範,從Spring5開始,其內建的本地快取框架由Guava Cache切換到了Caffeine。應用到專案中的快取選型,可以結合專案實際從多個方面進行抉擇。

  • 全新專案,閉眼選Caffeine
    Java8也已經被廣泛的使用多年,現在的新專案基本上都是JAVA8或以上的版本了。如果有新的專案需要做本地快取選型,閉眼選擇Caffeine就可以,錯不了。

  • 歷史低版本JAVA專案
    由於Caffeine對JAVA版本有依賴要求,對於一些歷史專案的維護而言,如果專案的JDK版本過低則無法使用Caffeine,這種情況下Guava Cache依舊是一個不錯的選擇。當然,也可以下定決心將專案的JDK版本升級到JDK1.8+版本,然後使用Caffeine來獲得更好的效能體驗 —— 但是對於一個歷史專案而言,升級基礎JDK版本帶來的影響可能會比較大,需要提前評估好。

  • 有同時使用Guava其它能力
    如果你的專案裡面已經有引入並使用了Guava提供的相關功能,這種情況下為了避免太多外部元件的引入,也可以直接使用Guava提供的Cache元件能力,畢竟Guava Cache的表現並不算差,應付常規場景的本都快取訴求完全足夠。當然,為了追求更加極致的效能表現,另外引入並使用Caffeine也完全沒有問題。

Caffeine使用

依賴引入

使用Caffeine,首先需要引入對應的庫檔案。如果是Maven專案,則可以在pom.xml中新增依賴宣告來完成引入。

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

注意,如果你的本地JDK版本比較低,引入上述較新版本的時候可能會編譯報錯:

遇到這種情況,可以考慮升級本地JDK版本(實際專案中升級可能有難度),或者將Caffeine版本降低一些,比如使用2.9.3版本。具體的版本列表,可以點選此處進行查詢。

這樣便大功告成啦。

容器建立

和之前我們聊過的Guava Cache建立快取物件的操作相似,我們可以通過構造器來方便的建立出一個Caffeine物件。

Cache<Integer, String> cache = Caffeine.newBuilder().build();

除了上述這種方式,Caffeine還支援使用不同的構造器方法,構建不同型別的Caffeine物件。對各種構造器方法梳理如下:

方法 含義說明
build() 構建一個手動回源的Cache物件
build(CacheLoader) 構建一個支援使用給定CacheLoader物件進行自動回源操作的LoadingCache物件
buildAsync() 構建一個支援非同步操作的非同步快取物件
buildAsync(CacheLoader) 使用給定的CacheLoader物件構建一個支援非同步操作的快取物件
buildAsync(AsyncCacheLoader) 與buildAsync(CacheLoader)相似,區別點僅在於傳入的引數型別不一樣。

為了便於非同步場景中處理,可以通過buildAsync()構建一個手動回源資料載入的快取物件:

public static void main(String[] args) {
    AsyncCache<String, User> asyncCache = Caffeine.newBuilder()
    .buildAsync();
    User user = asyncCache.get("123", s -> {
        System.out.println("非同步callable thread:" + Thread.currentThread().getId());
        return userDao.getUser(s);
    }).join();
}

當然,為了支援非同步場景中的自動非同步回源,我們可以通過buildAsync(CacheLoader)或者buildAsync(AsyncCacheLoader)來實現:

public static void main(String[] args) throws Exception{
    AsyncLoadingCache<String, User> asyncLoadingCache =
            Caffeine.newBuilder().maximumSize(1000L).buildAsync(key -> userDao.getUser(key));
    User user = asyncLoadingCache.get("123").join();
}

在建立快取物件的同時,可以指定此快取物件的一些處理策略,比如容量限制、比如過期策略等等。作為以替換Guava Cache為己任的後繼者,Caffeine在快取容器物件建立時的相關構建API也沿用了與Guava Cache相同的定義,常見的方法及其含義梳理如下:

方法 含義說明
initialCapacity 待建立的快取容器的初始容量大小(記錄條數
maximumSize 指定此快取容器的最大容量(最大快取記錄條數)
maximumWeight 指定此快取容器的最大容量(最大比重值),需結合weighter方可體現出效果
expireAfterWrite 設定過期策略,按照資料寫入時間進行計算
expireAfterAccess 設定過期策略,按照資料最後存取時間來計算
expireAfter 基於個性化客製化的邏輯來實現過期處理(可以客製化基於新增讀取更新等場景的過期策略,甚至支援為不同記錄指定不同過期時間
weighter 入參為一個函數式介面,用於指定每條存入的快取資料的權重佔比情況。這個需要與maximumWeight結合使用
refreshAfterWrite 快取寫入到快取之後
recordStats 設定開啟此容器的資料載入與快取命中情況統計

綜合上述方法,我們可以建立出更加符合自己業務場景的快取物件。

public static void main(String[] args) {
    AsyncLoadingCache<String, User> asyncLoadingCache = CaffeinenewBuilder()
            .initialCapacity(1000) // 指定初始容量
            .maximumSize(10000L) // 指定最大容量
            .expireAfterWrite(30L, TimeUnit.MINUTES) // 指定寫入30分鐘後過期
            .refreshAfterWrite(1L, TimeUnit.MINUTES) // 指定每隔1分鐘重新整理下資料內容
            .removalListener((key, value, cause) ->
                    System.out.println(key + "移除,原因:" + cause)) // 監聽記錄移除事件
            .recordStats() // 開啟快取運算元據統計
            .buildAsync(key -> userDao.getUser(key)); // 構建非同步CacheLoader載入型別的快取物件
}

業務使用

在上一章節建立快取物件的時候,Caffeine支援建立出同步快取非同步快取,也即CacheAsyncCache兩種不同型別。而如果指定了CacheLoader的時候,又可以細分出LoadingCache子型別與AsyncLoadingCache子型別。對於常規業務使用而言,知道這四種型別的快取型別基本就可以滿足大部分場景的正常使用了。但是Caffeine的整體快取型別其實是細分成了很多不同的具體型別的,從下面的UML圖上可以看出一二。

  • 同步快取

  • 非同步快取

業務層面對快取的使用,無外乎往快取裡面寫入資料、從快取裡面讀取資料。不管是同步還是非同步,常見的用於操作快取的方法梳理如下:

方法 含義說明
get 根據key獲取指定的快取值,如果沒有則執行回源操作獲取
getAll 根據給定的key列表批次獲取對應的快取值,返回一個map格式的結果,沒有命中快取的部分會執行回源操作獲取
getIfPresent 不執行回源操作,直接從快取中嘗試獲取key對應的快取值
getAllPresent 不執行回源操作,直接從快取中嘗試獲取給定的key列表對應的值,返回查詢到的map格式結果, 非同步場景不支援此方法
put 向快取中寫入指定的key與value記錄
putAll 批次向快取中寫入指定的key-value記錄集,非同步場景不支援此方法
asMap 將快取中的資料轉換為map格式返回

針對同步快取,業務程式碼中操作使用舉例如下:

public static void main(String[] args) throws Exception {
    LoadingCache<String, String> loadingCache = buildLoadingCache();
    loadingCache.put("key1", "value1");
    String value = loadingCache.get("key1");
    System.out.println(value);
}

同樣地,非同步快取的時候,業務程式碼中操作示意如下:

public static void main(String[] args) throws Exception {
    AsyncLoadingCache<String, String> asyncLoadingCache = buildAsyncLoadingCache();
    // 寫入快取記錄(value值為非同步獲取)
    asyncLoadingCache.put("key1", CompletableFuture.supplyAsync(() -> "value1"));
    // 非同步方式獲取快取值
    CompletableFuture<String> completableFuture = asyncLoadingCache.get("key1");
    String value = completableFuture.join();
    System.out.println(value);
}

小結回顧

好啦,關於Caffeine Cache的具體使用方式、核心的優化改進點相關的內容,以及與Guava Cache的比較,就介紹到這裡了。不知道小夥伴們是否對Caffeine Cache有了全新的認識了呢?而關於Caffeine Cache與Guava Cache的差別,你是否有自己的一些想法與見解呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。

下一篇文章中,我們將深入講解下Caffeine同步、非同步回源操作的各種不同實現,以及對應的實現與底層設計邏輯。如有興趣,歡迎關注後續更新。