Caffeine Cache 進(jìn)程緩存之王

1. 前言

互聯(lián)網(wǎng)軟件神速發(fā)展,用戶的體驗(yàn)度是判斷一個(gè)軟件好壞的重要原因,所以緩存就是必不可少的一個(gè)神器。在多線程高并發(fā)場(chǎng)景中往往是離不開(kāi)cache的,需要根據(jù)不同的應(yīng)用場(chǎng)景來(lái)需要選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進(jìn)程內(nèi))緩存如ehcache、GuavaCache、Caffeine。

說(shuō)起Guava Cache,很多人都不會(huì)陌生,它是Google Guava工具包中的一個(gè)非常方便易用的本地化緩存實(shí)現(xiàn),基于LRU算法實(shí)現(xiàn),支持多種緩存過(guò)期策略。由于Guava的大量使用,Guava Cache也得到了大量的應(yīng)用。但是,Guava Cache的性能一定是最好的嗎?也許,曾經(jīng),它的性能是非常不錯(cuò)的。但所謂長(zhǎng)江后浪推前浪,總會(huì)有更加優(yōu)秀的技術(shù)出現(xiàn)。今天,我就來(lái)介紹一個(gè)比Guava Cache性能更高的緩存框架:Caffeine。

2. 比較

Google Guava工具包中的一個(gè)非常方便易用的本地化緩存實(shí)現(xiàn),基于LRU算法實(shí)現(xiàn),支持多種緩存過(guò)期策略。

EhCache 是一個(gè)純Java的進(jìn)程內(nèi)緩存框架,具有快速、精干等特點(diǎn),是Hibernate中默認(rèn)的CacheProvider。

Caffeine是使用Java8對(duì)Guava緩存的重寫(xiě)版本,在Spring Boot 2.0中將取代,基于LRU算法實(shí)現(xiàn),支持多種緩存過(guò)期策略。

2.1 官方性能比較

場(chǎng)景1:8個(gè)線程讀,100%的讀操作

場(chǎng)景二:6個(gè)線程讀,2個(gè)線程寫(xiě),也就是75%的讀操作,25%的寫(xiě)操作

場(chǎng)景三:8個(gè)線程寫(xiě),100%的寫(xiě)操作

可以清楚的看到Caffeine效率明顯的高于其他緩存。

3. 如何使用

public static void main(String[] args) {
      LoadingCache<String, String> build = CacheBuilder.newBuilder().initialCapacity(1).maximumSize(100).expireAfterWrite(1, TimeUnit.DAYS)
          .build(new CacheLoader<String, String>() {
             //默認(rèn)的數(shù)據(jù)加載實(shí)現(xiàn),當(dāng)調(diào)用get取值的時(shí)候,如果key沒(méi)有對(duì)應(yīng)的值,就調(diào)用這個(gè)方法進(jìn)行加載
             @Override
             public String load(String key)  {
                  return "";
             }
         });
}

參數(shù)方法

  • initialCapacity(1) 初始緩存長(zhǎng)度為1
  • maximumSize(100) 最大長(zhǎng)度為100
  • expireAfterWrite(1, TimeUnit.DAYS) 設(shè)置緩存策略在1天未寫(xiě)入過(guò)期緩存(后面講緩存策略)

4. 過(guò)期策略

在Caffeine中分為兩種緩存,一個(gè)是有界緩存,一個(gè)是無(wú)界緩存,無(wú)界緩存不需要過(guò)期并且沒(méi)有界限。在有界緩存中提供了三個(gè)過(guò)期API:

  • expireAfterWrite:代表著寫(xiě)了之后多久過(guò)期。(上面列子就是這種方式)
  • expireAfterAccess: 代表著最后一次訪問(wèn)了之后多久過(guò)期。
  • expireAfter:在expireAfter中需要自己實(shí)現(xiàn)Expiry接口,這個(gè)接口支持create,update,以及access了之后多久過(guò)期。注意這個(gè)API和前面兩個(gè)API是互斥的。這里和前面兩個(gè)API不同的是,需要你告訴緩存框架,他應(yīng)該在具體的某個(gè)時(shí)間過(guò)期,也就是通過(guò)前面的重寫(xiě)create,update,以及access的方法,獲取具體的過(guò)期時(shí)間。

4. 更新策略

何為更新策略?就是在設(shè)定多長(zhǎng)時(shí)間后會(huì)自動(dòng)刷新緩存。

Caffeine提供了refreshAfterWrite()方法來(lái)讓我們進(jìn)行寫(xiě)后多久更新策略:

LoadingCache<String, String> build = CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)
   .build(new CacheLoader<String, String>() {
          @Override
          public String load(String key)  {
             return "";
          }
    });
}

上面的代碼我們需要建立一個(gè)CacheLodaer來(lái)進(jìn)行刷新,這里是同步進(jìn)行的,可以通過(guò)buildAsync方法進(jìn)行異步構(gòu)建。在實(shí)際業(yè)務(wù)中這里可以把我們代碼中的mapper傳入進(jìn)去,進(jìn)行數(shù)據(jù)源的刷新。

但是實(shí)際使用中,你設(shè)置了一天刷新,但是一天后你發(fā)現(xiàn)緩存并沒(méi)有刷新。這是因?yàn)楸赜性?天后這個(gè)緩存再次訪問(wèn)才能刷新,如果沒(méi)人訪問(wèn),那么永遠(yuǎn)也不會(huì)刷新。你明白了嗎?

我們來(lái)看看自動(dòng)刷新他是怎么做的呢?自動(dòng)刷新只存在讀操作之后,也就是我們afterRead()這個(gè)方法,其中有個(gè)方法叫refreshIfNeeded,他會(huì)根據(jù)你是同步還是異步然后進(jìn)行刷新處理。

5. 填充策略(Population)

Caffeine 為我們提供了三種填充策略:手動(dòng)、同步和異步

5.1 手動(dòng)加載(Manual)

// 初始化緩存
Cache<String, Object> manualCache = Caffeine.newBuilder()
           .expireAfterWrite(10, TimeUnit.MINUTES)
           .maximumSize(10_000)
           .build();

String key = "name1";
// 根據(jù)key查詢一個(gè)緩存,如果沒(méi)有返回NULL
graph = manualCache.getIfPresent(key);
// 如果緩存中不存在該鍵,createExpensiveGraph函數(shù)將用于提供回退值,該值在計(jì)算后插入緩存中
graph = manualCache.get(key, k -> createExpensiveGraph(k));
// 使用 put 方法手動(dòng)填充緩存,如果以前有值就覆蓋以前的值
manualCache.put(key, graph);
// 刪除一個(gè)緩存
manualCache.invalidate(key);

ConcurrentMap<String, Object> map = manualCache.asMap();
cache.invalidate(key);

Cache接口允許顯式的去控制緩存的檢索,更新和刪除。

我們可以通過(guò)cache.getIfPresent(key) 方法來(lái)獲取一個(gè)key的值,通過(guò)cache.put(key, value)方法顯示的將數(shù)控放入緩存,但是這樣子會(huì)覆蓋緩原來(lái)key的數(shù)據(jù)。更加建議使用cache.get(key,k - > value) 的方式,get 方法將一個(gè)參數(shù)為 key 的 Function (createExpensiveGraph) 作為參數(shù)傳入。如果緩存中不存在該鍵,則調(diào)用這個(gè) Function 函數(shù),并將返回值作為該緩存的值插入緩存中。get 方法是以阻塞方式執(zhí)行調(diào)用,即使多個(gè)線程同時(shí)請(qǐng)求該值也只會(huì)調(diào)用一次Function方法。這樣可以避免與其他線程的寫(xiě)入競(jìng)爭(zhēng),這也是為什么使用 get 優(yōu)于 getIfPresent 的原因。

注意:如果調(diào)用該方法返回NULL(如上面的 createExpensiveGraph 方法),則cache.get返回null,如果調(diào)用該方法拋出異常,則get方法也會(huì)拋出異常??梢允褂肅ache.asMap() 方法獲取ConcurrentMap進(jìn)而對(duì)緩存進(jìn)行一些更改。

5.2 同步加載(Loading)

// 初始化緩存
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
     .maximumSize(10_000)
     .expireAfterWrite(10, TimeUnit.MINUTES)
     .build(key -> createExpensiveGraph(key));

String key = "name1";
// 采用同步方式去獲取一個(gè)緩存和上面的手動(dòng)方式是一個(gè)原理。在build Cache的時(shí)候會(huì)提供一個(gè)createExpensiveGraph函數(shù)。
// 查詢并在缺失的情況下使用同步的方式來(lái)構(gòu)建一個(gè)緩存
Object graph = loadingCache.get(key);

// 獲取組key的值返回一個(gè)Map
List<String> keys = new ArrayList<>();
keys.add(key);
Map<String, Object> graphs = loadingCache.getAll(keys);

LoadingCache是使用CacheLoader來(lái)構(gòu)建的緩存的值。批量查找可以使用getAll方法,默認(rèn)情況下,getAll將會(huì)對(duì)緩存中沒(méi)有值的key分別調(diào)用CacheLoader.load方法來(lái)構(gòu)建緩存的值。我們可以重寫(xiě)CacheLoader.loadAll方法來(lái)提高getAll的效率。

注意:您可以編寫(xiě)一個(gè)CacheLoader.loadAll來(lái)實(shí)現(xiàn)為特別請(qǐng)求的key加載值。例如,如果計(jì)算某個(gè)組中的任何鍵的值將為該組中的所有鍵提供值,則loadAll可能會(huì)同時(shí)加載該組的其余部分。

5.3 異步加載(Asynchronously Loading)

AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        // Either: Build with a synchronous computation that is wrapped as asynchronous
        .buildAsync(key -> createExpensiveGraph(key));
        // Or: Build with a asynchronous computation that returns a future
        // .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));

String key = "name1";

// 查詢并在缺失的情況下使用異步的方式來(lái)構(gòu)建緩存
CompletableFuture<Object> graph = asyncLoadingCache.get(key);
// 查詢一組緩存并在缺失的情況下使用異步的方式來(lái)構(gòu)建緩存
List<String> keys = new ArrayList<>();
keys.add(key);
CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);
// 異步轉(zhuǎn)同步
loadingCache = asyncLoadingCache.synchronous();

AsyncLoadingCache是繼承自LoadingCache類(lèi)的,異步加載使用Executor去調(diào)用方法并返回一個(gè)CompletableFuture。異步加載緩存使用了響應(yīng)式編程模型。

如果要以同步方式調(diào)用時(shí),應(yīng)提供CacheLoader。要以異步表示時(shí),應(yīng)該提供一個(gè)AsyncCacheLoader,并返回一個(gè)CompletableFuture。

synchronous()這個(gè)方法返回了一個(gè)LoadingCacheView視圖,LoadingCacheView也繼承自LoadingCache。調(diào)用該方法后就相當(dāng)于你將一個(gè)異步加載的緩存AsyncLoadingCache轉(zhuǎn)換成了一個(gè)同步加載的緩存LoadingCache。

默認(rèn)使用ForkJoinPool.commonPool()來(lái)執(zhí)行異步線程,但是我們可以通過(guò)Caffeine.executor(Executor) 方法來(lái)替換線程池。

6. 驅(qū)逐策略(eviction)

緩存的驅(qū)逐策略是為了預(yù)測(cè)哪些數(shù)據(jù)在短期內(nèi)最可能被再次用到,從而提升緩存的命中率。LRU(Least Recently Used)策略或許是最流行的驅(qū)逐策略。但LRU通過(guò)歷史數(shù)據(jù)來(lái)預(yù)測(cè)未來(lái)是局限的,它會(huì)認(rèn)為最后到來(lái)的數(shù)據(jù)是最可能被再次訪問(wèn)的,從而給與它最高的優(yōu)先級(jí)。

Caffeine提供三類(lèi)驅(qū)逐策略:基于大?。╯ize-based),基于時(shí)間(time-based)和基于引用(reference-based)。

6.1 基于大?。╯ize-based)

基于大小驅(qū)逐,有兩種方式:一種是基于緩存大小,一種是基于權(quán)重。

// 根據(jù)緩存的計(jì)數(shù)進(jìn)行驅(qū)逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));

// 根據(jù)緩存的權(quán)重來(lái)進(jìn)行驅(qū)逐(權(quán)重只是用于確定緩存大小,不會(huì)用于決定該緩存是否被驅(qū)逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));

我們可以使用Caffeine.maximumSize(long)方法來(lái)指定緩存的最大容量。當(dāng)緩存超出這個(gè)容量的時(shí)候,會(huì)使用Window TinyLfu策略來(lái)刪除緩存。我們也可以使用權(quán)重的策略來(lái)進(jìn)行驅(qū)逐,可以使用Caffeine.weigher(Weigher) 函數(shù)來(lái)指定權(quán)重,使用Caffeine.maximumWeight(long) 函數(shù)來(lái)指定緩存最大權(quán)重值。

讓我們看看如何計(jì)算緩存中的對(duì)象。當(dāng)緩存初始化時(shí),其大小等于零:

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
                      .maximumSize(1)
                      .build(k -> DataObject.get("Data for " + k));    
assertEquals(0, cache.estimatedSize()); 

當(dāng)我們添加一個(gè)值時(shí),大小明顯增加:

cache.get("A");    
assertEquals(1, cache.estimatedSize()); 

我們可以將第二個(gè)值添加到緩存中,這導(dǎo)致第一個(gè)值被刪除:

cache.get("B"); 
assertEquals(1, cache.estimatedSize()); 

注意:maximumWeight與maximumSize不可以同時(shí)使用。

6.2 基于時(shí)間(Time-based)

// 基于固定的到期策略進(jìn)行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfterAccess(5, TimeUnit.MINUTES)
      .build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfterWrite(10, TimeUnit.MINUTES)
      .build(key -> createExpensiveGraph(key));

// 要初始化自定義策略,我們需要實(shí)現(xiàn) Expiry 接口
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
      .expireAfter(new Expiry<Key, Graph>() {
          @Override
          public long expireAfterCreate(Key key, Graph graph, long currentTime) {
            // Use wall clock time, rather than nanotime, if from an external resource
            long seconds = graph.creationDate().plusHours(5)
                   .minus(System.currentTimeMillis(), MILLIS)
                   .toEpochSecond();
            return TimeUnit.SECONDS.toNanos(seconds);
         }

          @Override
          public long expireAfterUpdate(Key key, Graph graph, 
            long currentTime, long currentDuration) {
            return currentDuration;
          }

          @Override
          public long expireAfterRead(Key key, Graph graph,
             long currentTime, long currentDuration) {
             return currentDuration;
          }
      })
      .build(key -> createExpensiveGraph(key));

6.3 基于引用(reference-based)

強(qiáng)引用,軟引用,弱引用概念說(shuō)明請(qǐng)點(diǎn)擊連接,這里說(shuō)一下各各引用的區(qū)別:

// 當(dāng)key和value都沒(méi)有引用時(shí)驅(qū)逐緩存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .weakKeys()
                                          .weakValues()
                                          .build(key -> createExpensiveGraph(key));

// 當(dāng)垃圾收集器需要釋放內(nèi)存時(shí)驅(qū)逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .softValues()
                                          .build(key -> createExpensiveGraph(key));

我們可以將緩存的驅(qū)逐配置成基于垃圾回收器。當(dāng)沒(méi)有任何對(duì)對(duì)象的強(qiáng)引用時(shí),使用 WeakRefence 可以啟用對(duì)象的垃圾收回收。SoftReference 允許對(duì)象根據(jù) JVM 的全局最近最少使用(Least-Recently-Used)的策略進(jìn)行垃圾回收。

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

7. 移除監(jiān)聽(tīng)器(Removal)

如果我們需要在緩存被移除的時(shí)候,得到通知產(chǎn)生回調(diào),并做一些額外處理工作。這個(gè)時(shí)候RemovalListener就派上用場(chǎng)了。

7.1 概念

驅(qū)逐(eviction):由于滿足了某種驅(qū)逐策略,后臺(tái)自動(dòng)進(jìn)行的刪除操作
無(wú)效(invalidation):表示由調(diào)用方手動(dòng)刪除緩存
移除(removal):監(jiān)聽(tīng)驅(qū)逐或無(wú)效操作的監(jiān)聽(tīng)器
手動(dòng)刪除緩存:在任何時(shí)候,您都可能明確地使緩存無(wú)效,而不用等待緩存被驅(qū)逐。

// individual key
cache.invalidate(key)
// bulk keys
cache.invalidateAll(keys)
// all keys
cache.invalidateAll()

7.2 Removal 監(jiān)聽(tīng)器

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .removalListener((Key key, Graph graph, RemovalCause cause) ->
        System.out.printf("Key %s was removed (%s)%n", key, cause))
    .build();

您可以通過(guò)Caffeine.removalListener(RemovalListener) 為緩存指定一個(gè)刪除偵聽(tīng)器,以便在刪除數(shù)據(jù)時(shí)執(zhí)行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。

刪除偵聽(tīng)器的里面的操作是使用Executor來(lái)異步執(zhí)行的。默認(rèn)執(zhí)行程序是ForkJoinPool.commonPool(),可以通過(guò)Caffeine.executor(Executor)覆蓋。當(dāng)操作必須與刪除同步執(zhí)行時(shí),請(qǐng)改為使用CacheWrite,CacheWrite將在下面說(shuō)明。

注意:由RemovalListener拋出的任何異常都會(huì)被記錄(使用Logger)并不會(huì)拋出。

7.3 移除監(jiān)聽(tīng)器應(yīng)用

public class Main {  
  
    // 創(chuàng)建一個(gè)監(jiān)聽(tīng)器  
    private static class MyRemovalListener implements RemovalListener<Integer, Integer> {  
    @Override  
    public void onRemoval(RemovalNotification<Integer, Integer> notification) {  
        String tips = String.format("key=%s,value=%s,reason=%s", notification.getKey(), notification.getValue(), notification.getCause());  
        System.out.println(tips);  
    }  
    }  
  
    public static void main(String[] args) {  
  
    // 創(chuàng)建一個(gè)帶有RemovalListener監(jiān)聽(tīng)的緩存  
    Cache<Integer, Integer> cache = CacheBuilder.newBuilder().removalListener(new MyRemovalListener()).build();  
  
    cache.put(1, 1);  
  
    // 手動(dòng)清除  
    cache.invalidate(1);  
  
    System.out.println(cache.getIfPresent(1)); // null  
    }  
  
}  

使用invalidate()清除緩存數(shù)據(jù)之后,注冊(cè)的回調(diào)被觸發(fā)了

8. 統(tǒng)計(jì)(Statistics)

Cache<Key, Graph> graphs = Caffeine.newBuilder()
      .maximumSize(10_000)
      .recordStats()
      .build();

使用Caffeine.recordStats(),您可以打開(kāi)統(tǒng)計(jì)信息收集。Cache.stats() 方法返回提供統(tǒng)計(jì)信息的CacheStats,如:

  • hitRate():返回命中與請(qǐng)求的比率
  • hitCount(): 返回命中緩存的總數(shù)
  • evictionCount():緩存逐出的數(shù)量
  • averageLoadPenalty():加載新值所花費(fèi)的平均時(shí)間
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 簡(jiǎn)介 在本文中,我們來(lái)看看 Caffeine — 一個(gè)高性能的 Java 緩存庫(kù)。 緩存和 Map 之間的一個(gè)根本...
    xiaolyuh閱讀 71,602評(píng)論 5 48
  • 1.確認(rèn)是否需要緩存 在使用緩存之前,需要確認(rèn)你的項(xiàng)目是否真的需要緩存。使用緩存會(huì)引入的一定的技術(shù)復(fù)雜度,后文也將...
    Java黎先生閱讀 7,951評(píng)論 2 22
  • 俗話說(shuō)得好,工欲善其事,必先利其器,有了好的工具肯定得知道如何用好這些工具,本篇將分為如下幾個(gè)方面介紹如何利用好緩...
    高級(jí)java架構(gòu)師閱讀 1,108評(píng)論 0 2
  • 想念兒時(shí)的桃樹(shù),我慈愛(ài)的爺爺奶奶。 清明夜半風(fēng)含悲, 想親念友思緒微, 人如螻蟻百年修, 行走路上婉轉(zhuǎn)回。 生命不...
    薔薇紫閱讀 259評(píng)論 0 1
  • 人其實(shí)從娘胎里就已形成了自己的性格特征,后天的成長(zhǎng)環(huán)境只能對(duì)其促進(jìn)或微調(diào)修正,所以在教育孩子上,順其自然比用力過(guò)猛...
    英特閱讀 447評(píng)論 0 1

友情鏈接更多精彩內(nèi)容