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

来源:https://www.cnblogs.com/softwarearch/archive/2022/12/06/16927942.html
-Advertisement-
Play Games

繼Guava Cache之後,我們再來聊一下各方面表現都更佳的Caffeine,看一下其具體使用方式、核心的優化改進點,窺探其青出於藍的秘密所在。 ...


大家好,又見面了。


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


在前面的幾篇文章中,我們一起聊了下本地緩存的動手實現、本地緩存相關的規範等,也聊了下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同步、非同步回源操作的各種不同實現,以及對應的實現與底層設計邏輯。如有興趣,歡迎關註後續更新。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 在過往,我們想要實現一個圖片的漸隱消失。最常見的莫過於整體透明度的變化,像是這樣: <div class="img"></div> div { width: 300px; height: 300px; background: url(image.jpg); transition: .4s; } .i ...
  • 我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:正則 作為一名前端開發人員,平時開發中使用最多的就是 Chrome devtools,但可能很多同學像我一樣平時用的最多也就 Console、Elements ...
  • 軟體設計模式(Design pattern),又稱設計模式,是一套被反覆使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結。使用設計模式是為了可重用代碼、讓代碼更容易被他人理解、保證代碼可靠性、程式的重用性。 ...
  • 摘要:華為雲Solution as Code推出基於Ploto構建自動駕駛平臺解決方案。 本文分享自華為雲社區《基於Ploto構建自動駕駛平臺》,作者:阿米托福 。 2022年6月15日,主題為“因聚而生 為你所能”的華為伙伴暨開發者大會 2022 正式開啟,在自動駕駛專場中,華為雲攜手合作伙伴聯合 ...
  • JSON&Ajax01 JSON 線上文檔 AJAX 線上文檔 1.JSON介紹 JSON指的是JavaScript對象表示法( JavaScript Object Notation),JSON的本質仍然是JavaScript對象 JSON是輕量級的文本數據交互格式,也是前後端進行數據通訊的一種格式 ...
  • pycharm社區版可用於商業項目 pycharm社區版可用於商業項目,來源於官方的回答:Can I use Community Editions of JetBrains IDEs for developing commercial proprietary software? – Licensin ...
  • 歡迎關註專欄【JAVA併發】 前言 開篇一個例子,我看看都有誰會?如果不會的,或者不知道原理的,還是老老實實看完這篇文章吧。 @Slf4j(topic = "c.VolatileTest") public class VolatileTest { static boolean run = true; ...
  • 本文主要介紹面向具體類編程帶來的耦合度問題,再使用面向介面編程進行進一步的解耦並將控制權轉移出去,從而介紹IOC的概念並實現基本使用。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...