還在用 Guava Cache?它才是 Java 本地緩存之王!

2021-02-20 Android編程精選

作者丨rickiyang
www.cnblogs.com/rickiyang/p/11074158.html

Guava Cache 的優點是封裝了get,put操作;提供線程安全的緩存操作;提供過期策略;提供回收策略;緩存監控。當緩存的數據超過最大值時,使用LRU算法替換。

這一篇我們將要談到一個新的本地緩存框架:Caffeine Cache。它也是站在巨人的肩膀上-Guava Cache,借著它的思想優化了算法發展而來。

本篇博文主要介紹Caffine Cache 的使用方式。另外,Java 緩存系列面試題和答案我都整理好了,關注下公眾號Java技術棧,在後臺回復 "面試" 進行獲取。

1. Caffine Cache 在算法上的優點-W-TinyLFU

說到優化,Caffine Cache到底優化了什麼呢?我們剛提到過LRU,常見的緩存淘汰算法還有FIFO,LFU:

FIFO:先進先出,在這種淘汰算法中,先進入緩存的會先被淘汰,會導致命中率很低。LRU:最近最少使用算法,每次訪問數據都會將其放在我們的隊尾,如果需要淘汰數據,就只需要淘汰隊首即可。仍然有個問題,如果有個數據在 1 分鐘訪問了 1000次,再後 1 分鐘沒有訪問這個數據,但是有其他的數據訪問,就導致了我們這個熱點數據被淘汰。LFU:最近最少頻率使用,利用額外的空間記錄每個數據的使用頻率,然後選出頻率最低進行淘汰。這樣就避免了 LRU 不能處理時間段的問題。

上面三種策略各有利弊,實現的成本也是一個比一個高,同時命中率也是一個比一個好。Guava Cache雖然有這麼多的功能,但是本質上還是對LRU的封裝,如果有更優良的算法,並且也能提供這麼多功能,相比之下就相形見絀了。

LFU的局限性:在 LFU 中只要數據訪問模式的概率分布隨時間保持不變時,其命中率就能變得非常高。比如有部新劇出來了,我們使用 LFU 給他緩存下來,這部新劇在這幾天大概訪問了幾億次,這個訪問頻率也在我們的 LFU 中記錄了幾億次。

但是新劇總會過氣的,比如一個月之後這個新劇的前幾集其實已經過氣了,但是他的訪問量的確是太高了,其他的電視劇根本無法淘汰這個新劇,所以在這種模式下是有局限性。

LRU的優點和局限性:LRU可以很好的應對突發流量的情況,因為他不需要累計數據頻率。但LRU通過歷史數據來預測未來是局限的,它會認為最後到來的數據是最可能被再次訪問的,從而給與它最高的優先級。

在現有算法的局限性下,會導致緩存數據的命中率或多或少的受損,而命中略又是緩存的重要指標。HighScalability網站刊登了一篇文章,由前Google工程師發明的W-TinyLFU——一種現代的緩存 。

Caffine Cache就是基於此算法而研發。Caffeine 因使用 Window TinyLfu 回收策略,提供了一個近乎最佳的命中率

當數據的訪問模式不隨時間變化的時候,LFU的策略能夠帶來最佳的緩存命中率。然而LFU有兩個缺點:

首先,它需要給每個記錄項維護頻率信息,每次訪問都需要更新,這是個巨大的開銷;

其次,如果數據訪問模式隨時間有變,LFU的頻率信息無法隨之變化,因此早先頻繁訪問的記錄可能會佔據緩存,而後期訪問較多的記錄則無法被命中。

因此,大多數的緩存設計都是基於LRU或者其變種來進行的。相比之下,LRU並不需要維護昂貴的緩存記錄元信息,同時也能夠反應隨時間變化的數據訪問模式。然而,在許多負載之下,LRU依然需要更多的空間才能做到跟LFU一致的緩存命中率。因此,一個「現代」的緩存,應當能夠綜合兩者的長處。

TinyLFU維護了近期訪問記錄的頻率信息,作為一個過濾器,當新記錄來時,只有滿足TinyLFU要求的記錄才可以被插入緩存。如前所述,作為現代的緩存,它需要解決兩個挑戰:

一個是如何避免維護頻率信息的高開銷;

另一個是如何反應隨時間變化的訪問模式。

首先來看前者,TinyLFU藉助了數據流Sketching技術,Count-Min Sketch顯然是解決這個問題的有效手段,它可以用小得多的空間存放頻率信息,而保證很低的False Positive Rate。

但考慮到第二個問題,就要複雜許多了,因為我們知道,任何Sketching數據結構如果要反應時間變化都是一件困難的事情,在Bloom Filter方面,我們可以有Timing Bloom Filter,但對於CMSketch來說,如何做到Timing CMSketch就不那麼容易了。

TinyLFU採用了一種基於滑動窗口的時間衰減設計機制,藉助於一種簡易的reset操作:每次添加一條記錄到Sketch的時候,都會給一個計數器上加1,當計數器達到一個尺寸W的時候,把所有記錄的Sketch數值都除以2,該reset操作可以起到衰減的作用 。

W-TinyLFU主要用來解決一些稀疏的突發訪問元素。在一些數目很少但突發訪問量很大的場景下,TinyLFU將無法保存這類元素,因為它們無法在給定時間內積累到足夠高的頻率。因此W-TinyLFU就是結合LFU和LRU,前者用來應對大多數場景,而LRU用來處理突發流量。

在處理頻率記錄的方案中,你可能會想到用hashMap去存儲,每一個key對應一個頻率值。那如果數據量特別大的時候,是不是這個hashMap也會特別大呢。由此可以聯想到 Bloom Filter,對於每個key,用n個byte每個存儲一個標誌用來判斷key是否在集合中。原理就是使用k個hash函數來將key散列成一個整數。

在W-TinyLFU中使用Count-Min Sketch記錄我們的訪問頻率,而這個也是布隆過濾器的一種變種。如下圖所示:

如果需要記錄一個值,那我們需要通過多種Hash算法對其進行處理hash,然後在對應的hash算法的記錄中+1,為什麼需要多種hash算法呢?

由於這是一個壓縮算法必定會出現衝突,比如我們建立一個byte的數組,通過計算出每個數據的hash的位置。

比如張三和李四,他們兩有可能hash值都是相同,比如都是1那byte[1]這個位置就會增加相應的頻率,張三訪問1萬次,李四訪問1次那byte[1]這個位置就是1萬零1,如果取李四的訪問評率的時候就會取出是1萬零1,但是李四命名只訪問了1次啊。

為了解決這個問題,所以用了多個hash算法可以理解為long[][]二維數組的一個概念,比如在第一個算法張三和李四衝突了,但是在第二個,第三個中很大的概率不衝突,比如一個算法大概有1%的概率衝突,那四個算法一起衝突的概率是1%的四次方。通過這個模式我們取李四的訪問率的時候取所有算法中,李四訪問最低頻率的次數。所以他的名字叫Count-Min Sketch。

福利:Spring Boot 學習筆記,這個太全了。

2. 使用

Caffeine Cache 的github地址:

https://github.com/ben-manes/caffeine

目前的最新版本是:

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

2.1 緩存填充策略

Caffeine Cache提供了三種緩存填充策略:手動、同步加載和異步加載。

1.手動加載

在每次get key的時候指定一個同步的函數,如果key不存在就調用這個函數生成一個值。

/**
* 手動加載
* @param key
* @return
*/
public Object manulOperator(String key) {
    Cache<String, Object> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build();
    //如果一個key不存在,那麼會進入指定的函數生成value
    Object value = cache.get(key, t -> setValue(key).apply(key));
    cache.put("hello",value);

    //判斷是否存在如果不存返回null
    Object ifPresent = cache.getIfPresent(key);
    //移除一個key
    cache.invalidate(key);
    return value;
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

2. 同步加載

構造Cache時候,build方法傳入一個CacheLoader實現類。實現load方法,通過key加載value。

/**
* 同步加載
* @param key
* @return
*/
public Object syncOperator(String key){
    LoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(k -> setValue(key).apply(key));
    return cache.get(key);
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

3. 異步加載

AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法並返回一個CompletableFuture。異步加載緩存使用了響應式編程模型。

如果要以同步方式調用時,應提供CacheLoader。要以異步表示時,應該提供一個AsyncCacheLoader,並返回一個CompletableFuture。

/**
* 異步加載
*
* @param key
* @return
*/
public Object asyncOperator(String key){
    AsyncLoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .buildAsync(k -> setAsyncValue(key).get());

    return cache.get(key);
}

public CompletableFuture<Object> setAsyncValue(String key){
    return CompletableFuture.supplyAsync(() -> {
        return key + "value";
    });
}

2.2 回收策略

Caffeine提供了3種回收策略:基於大小回收,基於時間回收,基於引用回收。

1. 基於大小的過期方式

基於大小的回收策略有兩種方式:一種是基於緩存大小,一種是基於權重。

// 根據緩存的計數進行驅逐
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10000)
    .build(key -> function(key));


// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐)
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .maximumWeight(10000)
    .weigher(key -> function1(key))
    .build(key -> function(key));

maximumWeight與maximumSize不可以同時使用。

2.基於時間的過期方式
// 基於固定的到期策略進行退出
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> function(key));
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> function(key));

// 基於不同的到期策略進行退出
LoadingCache<String, Object> cache2 = Caffeine.newBuilder()
    .expireAfter(new Expiry<String, Object>() {
        @Override
        public long expireAfterCreate(String key, Object value, long currentTime) {
            return TimeUnit.SECONDS.toNanos(seconds);
        }

        @Override
        public long expireAfterUpdate(@Nonnull String s, @Nonnull Object o, long l, long l1) {
            return 0;
        }

        @Override
        public long expireAfterRead(@Nonnull String s, @Nonnull Object o, long l, long l1) {
            return 0;
        }
    }).build(key -> function(key));

Caffeine提供了三種定時驅逐策略:

expireAfterAccess(long, TimeUnit):在最後一次訪問或者寫入後開始計時,在指定的時間後過期。假如一直有請求訪問該key,那麼這個緩存將一直不會過期。

expireAfterWrite(long, TimeUnit): 在最後一次寫入緩存後開始計時,在指定的時間後過期。

expireAfter(Expiry): 自定義策略,過期時間由Expiry實現獨自計算。

緩存的刪除策略使用的是惰性刪除和定時刪除。這兩個刪除策略的時間複雜度都是O(1)。

3. 基於引用的過期方式

Java中四種引用類型

引用類型被垃圾回收時間用途生存時間強引用 Strong Reference從來不會對象的一般狀態JVM停止運行時終止軟引用 Soft Reference在內存不足時對象緩存內存不足時終止弱引用 Weak Reference在垃圾回收時對象緩存gc運行後終止虛引用 Phantom Reference從來不會可以用虛引用來跟蹤對象被垃圾回收器回收的活動,當一個虛引用關聯的對象被垃圾收集器回收之前會收到一條系統通知JVM停止運行時終止
// 當key和value都沒有引用時驅逐緩存
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> function(key));

// 當垃圾收集器需要釋放內存時驅逐
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
    .softValues()
    .build(key -> function(key));

注意:AsyncLoadingCache不支持弱引用和軟引用。

Caffeine.weakKeys():使用弱引用存儲key。如果沒有其他地方對該key有強引用,那麼該緩存就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個緩存使用身份 (==) 相等來比較 key,而不是使用 equals()。

Caffeine.weakValues() :使用弱引用存儲value。如果沒有其他地方對該value有強引用,那麼該緩存就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個緩存使用身份 (==) 相等來比較 key,而不是使用 equals()。

Caffeine.softValues() :使用軟引用存儲value。當內存滿了過後,軟引用的對象以將使用最近最少使用(least-recently-used ) 的方式進行垃圾回收。由於使用軟引用是需要等到內存滿了才進行回收,所以我們通常建議給緩存配置一個使用內存的最大值。softValues() 將使用身份相等(identity) (==) 而不是equals() 來比較值。

Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。

3. 移除事件監聽
Cache<String, Object> cache = Caffeine.newBuilder()
    .removalListener((String key, Object value, RemovalCause cause) ->
                     System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();

4. 寫入外部存儲

CacheWriter 方法可以將緩存中所有的數據寫入到第三方。

LoadingCache<String, Object> cache2 = Caffeine.newBuilder()
    .writer(new CacheWriter<String, Object>() {
        @Override public void write(String key, Object value) {
            // 寫入到外部存儲
        }
        @Override public void delete(String key, Object value, RemovalCause cause) {
            // 刪除外部存儲
        }
    }).build(key -> function(key));

如果你有多級緩存的情況下,這個方法還是很實用。

注意:CacheWriter不能與弱鍵或AsyncLoadingCache一起使用。

5. 統計

與Guava Cache的統計一樣。

Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

通過使用Caffeine.recordStats(), 可以轉化成一個統計的集合. 通過 Cache.stats() 返回一個CacheStats。CacheStats提供以下統計方法:

hitRate(): 返回緩存命中率

evictionCount(): 緩存回收數量

averageLoadPenalty(): 加載新值的平均時間

相關焦點

  • 為什麼強烈推薦 Java 程式設計師使用 Google Guava 編程!
    更加重要的是,guava提供的Joiner/Splitter是經過充分測試,它的穩定性和效率要比apache高出不少,這個你可以自行測試下~發現沒有我們想對String做什麼操作,就是生成自己定製化的Joiner/Splitter,多麼直白,簡單,流暢的API!
  • 使用 Google Guava 快樂編程
    更加重要的是,guava提供的Joiner/Splitter是經過充分測試,它的穩定性和效率要比apache高出不少,這個你可以自行測試下~發現沒有我們想對String做什麼操作,就是生成自己定製化的Joiner/Splitter,多麼直白,簡單,流暢的API!
  • Google 開源的 Guava 工具庫,太強大了~
    更加重要的是,guava提供的Joiner/Splitter是經過充分測試,它的穩定性和效率要比apache高出不少,這個你可以自行測試下~推薦閱讀:試試 StringJoiner,真香!發現沒有我們想對String做什麼操作,就是生成自己定製化的Joiner/Splitter,多麼直白,簡單,流暢的API!
  • 來自未來的緩存 Caffeine,帶你揭開它的神秘面紗
    Caffeine是使用jdk 1.8對Guava cache的重寫版本,基於LRU算法實現,支持多種緩存過期策略。那麼先來看看Caffeine和其他的進程緩存的區別,為什麼叫它來自未來的緩存呢?2、其他進程緩存的簡單介紹Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。
  • Django開發中使用Cache緩存提升10倍效率
    緩解資料庫壓力的有效方法就是加緩存 其實當初在寫這段代碼的時候就考慮到了用緩存,之所以沒有用的主要是因為:在項目設計的過程中我提倡儘量減少依賴,不過度設計,以實現需求為目標,儘量讓項目簡單,這樣協作的小夥伴看起代碼來不費勁,出了問題還容易查找原因。
  • 你用什麼軟體做筆記?
    更加重要的是,guava提供的Joiner/Splitter是經過充分測試,它的穩定性和效率要比apache高出不少,這個你可以自行測試下~發現沒有我們想對String做什麼操作,就是生成自己定製化的Joiner/Splitter,多麼直白,簡單,流暢的API!
  • 理解Java Integer的緩存策略
    這種 Integer 緩存策略僅在自動裝箱(autoboxing)的時候有用,使用構造器創建的 Integer 對象不能被緩存。Java 編譯器把原始類型自動轉換為封裝類的過程稱為自動裝箱(autoboxing),這相當於調用 valueOf 方法
  • Cache 和 Buffer 都是緩存,主要區別是什麼?
    為了說明這個問題,讓我將他們分開來說:read cache(讀緩存),read buffer(讀緩衝),write cache(寫緩存),write buffer(寫緩衝)。無論緩存還是緩衝,其實本質上解決的都是讀寫速度不匹配的問題,從這個角度,他們非常相似。首先討論讀緩存跟讀緩衝。
  • 聊一聊緩存雙一致問題以及spring cache
    很多人的用法是用Spring cache整合Redis然後在service層方法上用註解的方式做緩存,可以說非常的方便。然而,你確定真的用對緩存了嗎?在高並發下又怎樣保證緩存中的數據和資料庫中的數據一致呢?現在我們就來嘮一嘮。
  • java retry(重試) spring retry, guava retrying 詳解
    它用於Spring批處理、Spring集成、Apache Hadoop(等等)的Spring。在分布式系統中,為了保證數據分布式事務的強一致性,大家在調用RPC接口或者發送MQ時,針對可能會出現網絡抖動請求超時情況採取一下重試操作。大家用的最多的重試方式就是MQ了,但是如果你的項目中沒有引入MQ,那就不方便了。還有一種方式,是開發者自己編寫重試機制,但是大多不夠優雅。
  • 吊打面試官系列:說說Integer緩存範圍
    平時不管是入坑多年的小夥伴還在入坑路上的小夥伴,都應該知道的使用頻率是相當高。     * JLS協議要求緩存在-128到127之間(包含邊界值)     *     * The cache is initialized on first usage.
  • 3分鐘短文:說說Laravel通用緩存Cache的使用技巧
    所以,你看到Session Cache Cookie 這些緩存數據類,基本上除了底層的驅動, 數據結構,過期特性等等,都集成了系統數組類Arr的操作方法。所以上述三種緩存在 操作方法上有很多相同之處。cache緩存的配置文件在 config/cache.php內,支持的驅動默認是 file, 也就是文本文件存儲。
  • Guava - 拯救垃圾代碼,寫出優雅高效,效率提升N倍
    Guava 項目是 Google 公司開源的 Java 核心庫,它主要是包含一些在 Java 開發中經常使用到的功能,如數據校驗、不可變集合、計數集合,集合增強操作、I/O、緩存、字符串操作等。-- https://mvnrepository.com/artifact/com.google.guava/guava --><dependency>    <groupId>com.google.guava</groupId>    <artifactId>guava</artifactId>
  • 你真的理解 Integer 的緩存問題嗎?|CSDN 博文精選
    問原因則隨口就說」Integer緩存了-128到127之間的整數對象「,為什麼會緩存?還有其他答案?可能就不知道了。what??? 難道這不是標準答案?還想咋地?分析運行想知道答案很容易,直接運行,結果是 true ,false。
  • Guava 23.3 版本發布,Google 的 Java 核心庫
    Guava 23.3 已發布,Guava 是 Google 的一個開源項目,包含許多 Google 核心的 Java 常用庫,如:集合 [collections] 、緩存 [caching]
  • 問號臉為什麼 Java 中1000==1000 為 false而 100==100 為 true
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫為什麼 Java 中「1000==1000」為false,而」100==100「
  • 如何解決多線程高並發場景下的 Java 緩存問題?
    在多線程高並發場景中往往是離不開cache的,需要根據不同的應用場景來需要選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進程內)緩存如ehcache、GuavaCache、Caffeine。
  • 為什麼Java中1000==1000為false,而100==100為true?
    如果兩個引用指向不同的對象,用 == 表示它們是不相等的,即使它們的內容相同。因此,後面一條語句也應該是 false 。這就是它有趣的地方了。如果你看去看 Integer.java 類,你會發現有一個內部私有類,IntegerCache.java,它緩存了從 - 128 到 127 之間的所有的整數對象。
  • 為什麼 Java 中 100==100 為 true?
    如果兩個引用指向不同的對象,用 == 表示它們是不相等的,即使它們的內容相同。因此,後面一條語句也應該是 false 。這就是它有趣的地方了。如果你看去看 Integer.java 類,你會發現有一個內部私有類,IntegerCache.java,它緩存了從 - 128 到 127 之間的所有的整數對象。