解讀JVM級別本地快取Caffeine青出於藍的要訣3 —— 講透Caffeine的資料驅逐淘汰機制與用法

架構悟道發表於2022-12-23

大家好,又見面了。


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


上一篇文章中,我們聊了下Caffeine的同步、非同步的資料回源方式。本篇文章我們再一起研討下Caffeine的多種不同的資料淘汰驅逐機制,以及對應的實際使用。

Caffeine的非同步淘汰清理機制

在惰性刪除實現機制這邊,Caffeine做了一些改進最佳化以提升在併發場景下的效能表現。我們可以和Guava Cache的基於容量大小的淘汰處理做個對比。

當限制了Guava Cache最大容量之後,有新的記錄寫入超過了總大小,會理解觸發資料淘汰策略,然後騰出空間給新的記錄寫入。比如下面這段邏輯:

public static void main(String[] args) {
    Cache<String, String> cache = CacheBuilder.newBuilder()
            .maximumSize(1)
            .removalListener(notification -> System.out.println(notification.getKey() + "被移除,原因:" + notification.getCause()))
            .build();
    cache.put("key1", "value1");
    System.out.println("key1寫入後,當前快取內的keys:" + cache.asMap().keySet());
    cache.put("key2", "value1");
    System.out.println("key2寫入後,當前快取內的keys:" + cache.asMap().keySet());
}

其執行後的結果顯示如下,可以很明顯的看出,超出容量之後繼續寫入,會在寫入前先執行快取移除操作。

key1寫入後,當前快取內的keys:[key1]
key1被移除,原因:SIZE
key2寫入後,當前快取內的keys:[key2]

同樣地,我們看下使用Caffeine實現一個限制容量大小的快取物件的處理表現,程式碼如下:

public static void main(String[] args) {
    Cache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(1)
            .removalListener((key, value, cause) -> System.out.println(key + "被移除,原因:" + cause))
            .build();
    cache.put("key1", "value1");
    System.out.println("key1寫入後,當前快取內的keys:" + cache.asMap().keySet());
    cache.put("key2", "value1");
    System.out.println("key2寫入後,當前快取內的keys:" + cache.asMap().keySet());
}

執行這段程式碼,會發現Caffeine的容量限制功能似乎“失靈”了!從輸出結果看並沒有限制住

key1寫入後,當前快取內的keys:[key1]
key2寫入後,當前快取內的keys:[key1, key2]

什麼原因呢?

Caffeine為了提升讀寫操作的併發效率而將資料淘汰清理操作改為了非同步處理,而非同步處理時會有微小的延時,由此導致了上述看到的容量控制“失靈”現象。為了證實這一點,我們對上述的測試程式碼稍作修改,列印下呼叫執行緒與資料淘汰清理執行緒的執行緒ID,並且最後新增一個sleep等待操作:

public static void main(String[] args) throws Exception {
    System.out.println("當前主執行緒:" + Thread.currentThread().getId());
    Cache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(1)
            .removalListener((key, value, cause) ->
                    System.out.println("資料淘汰執行執行緒:" + Thread.currentThread().getId()
                            + "," + key + "被移除,原因:" + cause))
            .build();
    cache.put("key1", "value1");
    System.out.println("key1寫入後,當前快取內的keys:" + cache.asMap().keySe());
    cache.put("key2", "value1");
    Thread.sleep(1000L); // 等待一段時間時間,等待非同步清理操作完成
    System.out.println("key2寫入後,當前快取內的keys:" + cache.asMap().keySet());
}

再次執行上述測試程式碼,發現結果變的符合預期了,也可以看出Caffeine的確是另起了獨立執行緒去執行資料淘汰操作的。

當前主執行緒:1
key1寫入後,當前快取內的keys:[key1]
資料淘汰執行執行緒:13,key1被移除,原因:SIZE
key2寫入後,當前快取內的keys:[key2]

深扒一下原始碼的實現,可以發現Caffeine在讀寫操作時會使用獨立執行緒池執行對應的清理任務,如下圖中的呼叫鏈執行鏈路 —— 這也證實了上面我們的分析。

所以,嚴格意義來說,Caffeine的大小容量限制並不能夠保證完全精準的小於設定的值,會存在短暫的誤差,但是作為一個以高併發吞吐量為優先考量點的元件而言,這一點點的誤差也是可以接受的。關於這一點,如果閱讀原始碼仔細點的小夥伴其實也可以發現在很多場景的註釋中,Caffeine也都會有明確的說明。比如看下面這段從原始碼中摘抄的描述,就清晰的寫著“如果有同步執行的插入或者移除操作,實際的元素數量可能會出現差異”。

public interface Cache<K, V> {
    /**
     * Returns the approximate number of entries in this cache. The value returned is an estimate; the
     * actual count may differ if there are concurrent insertions or removals, or if some entries are
     * pending removal due to expiration or weak/soft reference collection. In the case of stale
     * entries this inaccuracy can be mitigated by performing a {@link #cleanUp()} first.
     *
     * @return the estimated number of mappings
     */
    @NonNegative
    long estimatedSize();

  // 省略其餘內容...
}

同樣道理,不管是基於大小、還是基於過期時間或基於引用的資料淘汰策略,由於資料淘汰處理是非同步進行的,都會存在短暫不夠精確的情況。

多種淘汰機制

上面提到並演示了Caffeine基於整體容量進行的資料驅逐策略。除了基於容量大小之外,Caffeine還支援基於時間與基於引用等方式來進行資料驅逐處理。

基於時間

Caffine支援基於時間進行資料的淘汰驅逐處理。這部分的能力與Guava Cache相同,支援根據記錄建立時間以及訪問時間兩個維度進行處理。

資料的過期時間在建立快取物件的時候進行指定,Caffeine在建立快取物件的時候提供了3種設定過期策略的方法。

方式 具體說明
expireAfterWrite 基於建立時間進行過期處理
expireAfterAccess 基於最後訪問時間進行過期處理
expireAfter 基於個性化定製的邏輯來實現過期處理(可以定製基於新增讀取更新等場景的過期策略,甚至支援為不同記錄指定不同過期時間

下面逐個看下。

expireAfterWrite

expireAfterWrite用於指定資料建立之後多久會過期,使用方式舉例如下:

Cache<String, User> userCache = 
    Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .build();
userCache.put("123", new User("123", "張三"));

當記錄被寫入快取之後達到指定的時間之後,就會被過期淘汰(惰性刪除,並不會立即從記憶體中移除,而是在下一次操作的時候觸發清理操作)。

expireAfterAccess

expireAfterAccess用於指定快取記錄多久沒有被訪問之後就會過期。使用方式與expireAfterWrite類似:

Cache<String, User> userCache = 
    Caffeine.newBuilder()
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .build();
    userCache.get("123", s -> userDao.getUser(s));

這種是基於最後一次訪問時間來計算資料是否過期,如果一個資料一直被訪問,則其就不會過期。比較適用於熱點資料的儲存場景,可以保證較高的快取命中率。同樣地,資料過期時也不會被立即從記憶體中移除,而是基於惰性刪除機制進行處理。

expireAfter

上面兩種設定過期時間的策略與Guava Cache是相似的。為了提供更為靈活的過期時間設定能力,Caffeine提供了一種全新的的過期時間設定方式,也即這裡要介紹的expireAfter方法。其支援傳入一個自定義的Expiry物件,自行實現資料的過期策略,甚至是針對不同的記錄來定製不同的過期時間。

先看下Expiry介面中需要實現的三個方法:

方法名稱 含義說明
expireAfterCreate 指定一個過期時間,從記錄建立的時候開始計時,超過指定的時間之後就過期淘汰,效果類似expireAfterWrite,但是支援更靈活的定製邏輯。
expireAfterUpdate 指定一個過期時間,從記錄最後一次被更新的時候開始計時,超過指定的時間之後就過期。每次執行更新操作之後,都會重新計算過期時間。
expireAfterRead 指定一個過期時間,從記錄最後一次被訪問的時候開始計時,超過指定時間之後就過期。效果類似expireAfterAccess,但是支援更高階的定製邏輯。

比如下面的程式碼中,定製了expireAfterCreate方法的邏輯,根據快取key來決定過期時間,如果key以字母A開頭則設定1s過期,否則設定2s過期:

public static void main(String[] args) {
    try {
        LoadingCache<String, User> userCache = Caffeine.newBuilder()
                .removalListener((key, value, cause) -> {
                    System.out.println(key + "移除,原因:" + cause);
                })
                .expireAfter(new Expiry<String, User>() {
                    @Override
                    public long expireAfterCreate(@NonNull String key, @NonNullUser value, long currentTime) {
                        if (key.startsWith("A")) {
                            return TimeUnit.SECONDS.toNanos(1);
                        } else {
                            return TimeUnit.SECONDS.toNanos(2);
                        }
                    }
                    @Override
                    public long expireAfterUpdate(@NonNull String key, @NonNullUser value, long currentTime,
                                                  @NonNegative longcurrentDuration) {
                        return Long.MAX_VALUE;
                    }
                    @Override
                    public long expireAfterRead(@NonNull String key, @NonNull Uservalue, long currentTime,
                                                @NonNegative long currentDuration){
                        return Long.MAX_VALUE;
                    }
                })
                .build(key -> userDao.getUser(key));
        userCache.put("123", new User("123", "123"));
        userCache.put("A123", new User("A123", "A123"));
        Thread.sleep(1100L);
        System.out.println(userCache.get("123"));
        System.out.println(userCache.get("A123"));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

執行程式碼進行測試,可以發現,不同的key擁有了不同的過期時間

User(userName=123, userId=123, departmentId=null)
A123移除,原因:EXPIRED
User(userName=A123, userId=A123, departmentId=null)

除了根據key來定製不同的過期時間,也可以根據value的內容來指定不同的過期時間策略。也可以同時定製上述三個方法,搭配來實現更復雜的過期策略。

按照這種方式來定時過期時間的時候需要注意一點,如果不需要設定某一維度的過期策略的時候,需要將對應實現方法的返回值設定為一個非常大的數值,比如可以像上述示例程式碼中一樣,指定為Long.MAX_VALUE值。

基於大小

除了前面提到的基於訪問時間或者建立時間來執行資料過期淘汰的方式之外,Caffeine還支援針對快取總體容量大小進行限制,如果容量滿的時候,基於W-TinyLFU演算法,淘汰最不常被使用的資料,騰出空間給新的記錄寫入。

Caffeine支援按照Size(記錄條數)或者按照Weighter(記錄權重)值進行總體容量的限制。關於Size和Weighter的區別,之前的文章中有介紹過,如果不清楚的小夥伴們可以檢視下《重新認識下JVM級別的本地快取框架Guava Cache(2)——深入解讀其容量限制與資料淘汰策略》。

maximumSize

在建立Caffeine快取物件的時候,可以透過maximumSize來指定允許快取的最大條數。

比如下面這段程式碼:

Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumSize(1000L) // 限制最大快取條數
        .build();

maximumWeight

在建立Caffeine快取物件的時候,可以透過maximumWeightweighter組合的方式,指定按照權重進行限制快取總容量。比如一個字串value值的快取場景下,我們可以根據字串的長度來計算權重值,最後根據總權重大小來限制容量。

程式碼示意如下:

Cache<Integer, String> cache = Caffeine.newBuilder()
        .maximumWeight(1000L) // 限制最大權重值
        .weigher((key, value) -> (String.valueOf(value).length() / 1000) + 1)
        .build();

使用注意點

需要注意一點:如果建立的時候指定了weighter,則必須同時指定maximumWeight值,如果不指定、或者指定了maximumSize,會報錯(這一點與Guava Cache一致):

java.lang.IllegalStateException: weigher requires maximumWeight
	at com.github.benmanes.caffeine.cache.Caffeine.requireState(Caffeine.java:201)
	at com.github.benmanes.caffeine.cache.Caffeine.requireWeightWithWeigher(Caffeine.java:1215)
	at com.github.benmanes.caffeine.cache.Caffeine.build(Caffeine.java:1099)
	at com.veezean.skills.cache.caffeine.CaffeineCacheService.main(CaffeineCacheService.java:254)

基於引用

基於引用回收的策略,核心是利用JVM虛擬機器的GC機制來達到資料清理的目的。當一個物件不再被引用的時候,JVM會選擇在適當的時候將其回收。Caffeine支援三種不同的基於引用的回收方法:

方法 具體說明
weakKeys 採用弱引用方式儲存key值內容,當key物件不再被引用的時候,由GC進行回收
weakValues 採用弱引用方式儲存value值內容,當value物件不再被引用的時候,由GC進行回收
softValues 採用軟引用方式儲存value值內容,當記憶體容量滿時基於LRU策略進行回收

下面逐個介紹下。

weakKeys

預設情況下,我們建立出一個Caffeine快取物件並寫入key-value對映資料時,key和value都是以強引用的方式儲存的。而使用weakKeys可以指定將快取中的key值以弱引用(WeakReference)的方式進行儲存,這樣一來,如果程式執行時沒有其它地方使用或者依賴此快取值的時候,該條記錄就可能會被GC回收掉。

 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakKeys()
                .build(key -> userDao.getUser(key));

小夥伴們應該都有個基本的認知,就是兩個物件進行比較是否相等的時候,要使用equals方法而非==。而且很多時候我們會主動去覆寫hashCode方法與equals方法來指定兩個物件的相等判斷邏輯。但是基於引用的資料淘汰策略,關注的是引用地址值而非實際內容值,也即一旦使用weakKeys指定了基於引用方式回收,那麼查詢的時候將只能是使用同一個key物件(記憶體地址相同)才能夠查詢到資料,因為這種情況下查詢的時候,使用的是==判斷是否為同一個key。

看下面的例子:

public static void main(String[] args) {
    Cache<String, String> cache = Caffeine.newBuilder()
            .weakKeys()
            .build();
    String key1 = "123";
    cache.put(key1, "value1");
    System.out.println(cache.getIfPresent(key1));
    String key2 = new String("123");
    System.out.println("key1.equals(key2) : " + key1.equals(key2));
    System.out.println("key1==key2 : " + (key1==key2));
    System.out.println(cache.getIfPresent(key2));
}

執行之後,會發現使用存入時的key1進行查詢的時候是可以查詢到資料的,而使用key2去查詢的時候並沒有查詢到記錄,雖然key1與key2的值都是字串123!

value1
key1.equals(key2) : true
key1==key2 : false
null

在實際使用的時候,這一點務必需要注意,對於新手而言,很容易踩進坑裡

weakValues

與weakKeys類似,我們可以在建立快取物件的時候使用weakValues指定將value值以弱引用的方式儲存到快取中。這樣當這條快取記錄的物件不再被引用依賴的時候,就會被JVM在適當的時候回收釋放掉。

 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakValues()
                .build(key -> userDao.getUser(key));

實際使用的時候需要注意weakValues不支援AsyncLoadingCache中使用。比如下面的程式碼:

public static void main(String[] args) {
    AsyncLoadingCache<String, User> cache = Caffeine.newBuilder()
            .weakValues()
            .buildAsync(key -> userDao.getUser(key));
}

啟動執行的時候,就會報錯:

Exception in thread "main" java.lang.IllegalStateException: Weak or soft values cannot be combined with AsyncLoadingCache
	at com.github.benmanes.caffeine.cache.Caffeine.requireState(Caffeine.java:201)
	at com.github.benmanes.caffeine.cache.Caffeine.buildAsync(Caffeine.java:1192)
	at com.github.benmanes.caffeine.cache.Caffeine.buildAsync(Caffeine.java:1167)
	at com.veezean.skills.cache.caffeine.CaffeineCacheService.main(CaffeineCacheService.java:297)

當然咯,很多時候也可以將weakKeysweakValues組合起來使用,這樣可以獲得到兩種能力的綜合加成。

 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .weakKeys()
                .weakValues()
                .build(key -> userDao.getUser(key));

softValues

softValues是指將快取內容值以軟引用的方式儲存在快取容器中,當記憶體容量滿的時候Caffeine會以LRU(least-recently-used,最近最少使用)順序進行資料淘汰回收。對比下其與weakValues的差異:

方式 具體描述
weakValues 弱引用方式儲存,一旦不再被引用,則會被GC回收
softValues 軟引用方式儲存,不會被GC回收,但是在記憶體容量滿的時候,會基於LRU策略資料回收

具體使用的時候,可以在建立快取物件的時候進行指定基於軟引用方式資料淘汰:

 LoadingCache<String,  User> loadingCache = Caffeine.newBuilder()
                .softValues()
                .build(key -> userDao.getUser(key));

與weakValues一樣,需要注意softValues不支援AsyncLoadingCache中使用。此外,還需要注意softValuesweakValues兩者也不可以一起使用。

public static void main(String[] args) {
    LoadingCache<String, User> cache = Caffeine.newBuilder()
            .weakKeys()
            .weakValues()
            .softValues()
            .build(key -> userDao.getUser(key));
}

啟動執行的時候,也會報錯:

Exception in thread "main" java.lang.IllegalStateException: Value strength was already set to WEAK
	at com.github.benmanes.caffeine.cache.Caffeine.requireState(Caffeine.java:201)
	at com.github.benmanes.caffeine.cache.Caffeine.softValues(Caffeine.java:572)
	at com.veezean.skills.cache.caffeine.CaffeineCacheService.main(CaffeineCacheService.java:297)

小結回顧

好啦,關於Caffeine Cache資料淘汰驅逐策略的實現原理與使用方式的闡述,就介紹到這裡了。至此呢,關於Caffeine相關的內容就全部結束了,透過與Caffeine相關的這三篇文章,我們介紹完了Caffeine的整體情況、與Guava Cache相比的改進點、Caffeine的專案中使用,以及Caffeine在資料回源、資料驅逐等方面的展開探討。關於Caffeine Cache,你是否有自己的一些想法與見解呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。

說起JAVA的本地快取,除了此前提及的Guava Cache和這裡介紹的Caffeine,還有一個同樣無法被忽視的存在 —— Ehcache!作為被Hibernate選中的預設快取實現框架,它究竟有什麼魅力?它與Caffeine又有啥區別呢?接下來的文章中,我們就一起來認識下Ehcache,嘗試找尋出答案。

? 補充說明1

本文屬於《深入理解快取原理與實戰設計》系列專欄的內容之一。該專欄圍繞快取這個宏大命題進行展開闡述,全方位、系統性地深度剖析各種快取實現策略與原理、以及快取的各種用法、各種問題應對策略,並一起探討下快取設計的哲學。

如果有興趣,也歡迎關注此專欄。

? 補充說明2

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。

相關文章