Caffeine 使用與原理

caffeine、GuavaCache、EhCache 比較

Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。

EhCache 是一個純Java的進程內緩存框架,具有快速、精幹等特點,是Hibernate中默認的CacheProvider。

Caffeine是使用Java8對Guava緩存的重寫版本,在Spring Boot 2.0中將取代,基於LRU算法實現,支持多種緩存過期策略。

場景1:8個線程讀,100%的讀操作
在這裏插入圖片描述
場景2:6個線程讀,2個線程寫,也就是75%的讀操作,25%的寫操作
在這裏插入圖片描述
場景3:8個線程寫,100%的寫操作
在這裏插入圖片描述

Caffeine 基礎使用

SpringBoot 集成

  1. 添加pom文件
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
  1. 添加配置文件
spring.cache.cache-names=ConfigCache
spring.cache.caffeine.spec=initialCapacity=50,maximumSize=500,expireAfterWrite=4s
spring.cache.type=caffeine

Application.class中添加 @EnableCaching 註解

@SpringBootApplication
@EnableCaching
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application .class, args);
	}
}
  1. 添加需要緩存的方法
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class CaffeineService {

    @Cacheable(value = "IZUUL", key = "#key")
    public String cacheIZUUL(String key) {
        log.info("cacheIZUUL()方法執行");
        return getCache(key);
    }

    @CachePut(value = "IZUUL", key = "#key")
    public String cachePutIZUUL(String key) {
        log.info("cachePutIZUUL()方法執行");
        return "cachePutIZUUL--" + key;
    }

    private String getCache(String key) {
        try {
            log.info("getCache()方法執行");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return key;
    }
}
  1. 添加Controller 進行測試
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CaffeineController {

    @Autowired
    private CaffeineService caffeineService;

    @GetMapping("/cache-izuul/{key}")
    public String cacheIZUUL(@PathVariable String key) {

        return caffeineService.cacheIZUUL(key);
    }

    @GetMapping("/cache-put-izuul/{key}")
    public String cachePutIZUUL(@PathVariable String key) {

        return caffeineService.cachePutIZUUL(key);
    }

}

Caffeine配置

  • initialCapacity=[integer]: 初始的緩存空間大小
  • maximumSize=[long]: 緩存的最大條數
  • maximumWeight=[long]: 緩存的最大權重
  • expireAfterAccess=[duration]: 最後一次寫入或訪問後經過固定時間過期
  • expireAfterWrite=[duration]: 最後一次寫入後經過固定時間過期
  • refreshAfterWrite=[duration]: 創建緩存或者最近一次更新緩存後經過固定的時間間隔,刷新緩存
  • weakKeys: 打開key的弱引用
  • weakValues:打開value的弱引用
  • softValues:打開value的軟引用
  • recordStats:開發統計功能

注意:
expireAfterWrite和expireAfterAccess同事存在時,以expireAfterWrite爲準。
maximumSize和maximumWeight不可以同時使用
weakValues和softValues不可以同時使用

註解

  • @Cacheable 觸發緩存入口(這裏一般放在創建和獲取的方法上)
  • @CacheEvict 觸發緩存的eviction(用於刪除的方法上)
  • @CachePut 更新緩存且不影響方法執行(用於修改的方法上,該註解下的方法始終會被執行)
  • @Caching 將多個緩存組合在一個方法上(該註解可以允許一個方法同時設置多個註解)
  • @CacheConfig 在類級別設置一些緩存相關的共同配置(與其它緩存配合使用)

@Cacheable

先看看它的源碼

public @interface Cacheable {

    /**
     * 設定要使用的cache的名字,必須提前定義好緩存
     */
    @AliasFor("cacheNames")
    String[] value() default {};

    /**
     * 同value(),決定要使用那個/些緩存
     */
    @AliasFor("value")
    String[] cacheNames() default {};

    /**
     * 使用SpEL表達式來設定緩存的key,如果不設置默認方法上所有參數都會作爲key的一部分
     */
    String key() default "";

    /**
     * 用來生成key,與key()不可以共用
     */
    String keyGenerator() default "";

    /**
     * 設定要使用的cacheManager,必須先設置好cacheManager的bean,這是使用該bean的名字
     */
    String cacheManager() default "";

    /**
     * 使用cacheResolver來設定使用的緩存,用法同cacheManager,但是與cacheManager不可以同時使用
     */
    String cacheResolver() default "";

    /**
     * 使用SpEL表達式設定出發緩存的條件,在方法執行前生效
     */
    String condition() default "";

    /**
     * 使用SpEL設置出發緩存的條件,這裏是方法執行完生效,所以條件中可以有方法執行後的value
     */
    String unless() default "";

    /**
     * 用於同步的,在緩存失效(過期不存在等各種原因)的時候,如果多個線程同時訪問被標註的方法
     * 則只允許一個線程通過去執行方法
     */
    boolean sync() default false;

}

使用示例

    /**
     * condition條件判斷是否要走緩存,無法使用方法中出現的值(返回結果等),條件爲true放入緩存
     * unless是方法執行後生效,決定是否放入緩存,返回true的放緩存
     * */
    @Cacheable(cacheNames = "outLimit",key = "#name",condition = "#value != null ")
    public String getCaffeineServiceTest(String name,Integer age){
        String value = name + " nihao "+ age;
        logger.info("getCaffeineServiceTest value = {}",value);
        return value;
    }

sync 屬性
用於保證緩存需要加載時,只會有一個線程計算數據,其他線程阻塞。caffeine 本身也有類似機制,但是使用 sync 屬性,其他線程由 spring 阻塞 ,而不是 caffeine。因爲 caffeine 的阻塞機制中 ,每個阻塞的線程仍要重複 “獲取鎖,計算加載緩存,釋放鎖” 類似的過程,而由 spring 阻塞,阻塞的線程會待計算數據的線程加載完緩存後,直接從緩存中獲取數據。

unless 屬性
用於否決(veto)緩存,緩存計算結束後判斷,若滿足該表達式,則計算結果不會加入緩存中。如 unless = “#result == null” ,表示若計算結果爲空,則不加入緩存。

@CachePut

使用示例

  @CachePut(value = "outLimit", key = "#key")
    public String cachePutIZUUL(String key) {
        log.info("cachePutIZUUL()方法執行");
        return "cachePutIZUUL--" + key;
    }

這是個一般用於修改方法上的註解,它的代碼跟Cacheable基本相同,這裏不做介紹。
現在說下CachePut和Cacheable的主要區別。

@Cacheable:它的註解的方法是否被執行取決於Cacheable中的條件,方法很多時候都可能不被執行。 
@CachePut:這個註解不會影響方法的執行,也就是說無論它配置的條件是什麼,方法都會被執行,更多的時候是被用到修改上。

@CacheEvict

    /**
     * CacheEvict刪除key,會調用cache的evict
     * */
    @CacheEvict(cacheNames = "outLimit",key = "#name")
    public String deleteCaffeineServiceTest(String name){
        String value = name + " nihao";
        logger.info("deleteCaffeineServiceTest value = {}",value);
        return value;
    }

跟上邊的兩個註解相比,源碼中多了兩個屬性

public @interface CacheEvict {


    /**
     * 是否刪除緩存中的所有數據,默認爲false,只會刪除被註解方法中傳入的key的緩存
     */
    boolean allEntries() default false;

    /**
     * 設置緩存的刪除在方法執行前執行還是執行後執行。如果設置true,則無論該方法是否正常結束,緩存中的值都會被刪除。
     */
    boolean beforeInvocation() default false;

}

@Caching

它是個組合上面三個註解的註解,之前我並沒有用到,現在結合spring文檔簡單說下。
源碼

public @interface Caching {
    Cacheable[] cacheable() default {};
    CachePut[] put() default {};
    CacheEvict[] evict() default {};
}

它只是給出了三種註解的組合,並沒有給出限制條件,所以其使用也很簡單,如下

@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@Caching

類級別的註解,可以設置某類中所有註解的相同部分,這個可以參考spring的類級別的@Mapping來理解。
其代碼很簡單

public @interface CacheConfig {
    String[] cacheNames() default {};
    String keyGenerator() default "";
    String cacheManager() default "";
    String cacheResolver() default "";
}

使用如下

@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
    @Cacheable
    public Book findBook(ISBN isbn) {...}
}

其他功能

監聽器(Removal )

您可以通過Caffeine.removalListener(RemovalListener) 爲緩存指定一個刪除偵聽器,以便在刪除數據時執行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。

統計(Statistics)

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

  • hitRate():返回命中與請求的比率
  • hitCount(): 返回命中緩存的總數
  • evictionCount():緩存逐出的數量
  • averageLoadPenalty():加載新值所花費的平均時間

補充說明

spring cache 使用基於動態生成子類的代理機制來對方法的調用進行切面,如果緩存的方法是內部調用而不是外部引用,會導致代理失敗,切面失效。

Caffeine 策略分析

過期策略

在Caffeine中分爲兩種緩存,一個是有界緩存,一個是無界緩存,無界緩存不需要過期並且沒有界限。在有界緩存中提供了三個過期API:

  • expireAfterWrite:代表着寫了之後多久過期。
  • expireAfterAccess: 代表着最後一次訪問了之後多久過期。
  • expireAfter:在expireAfter中需要自己實現Expiry接口,這個接口支持create,update,以及access了之後多久過期。注意這個API和前面兩個API是互斥的。這裏和前面兩個API不同的是,需要你告訴緩存框架,他應該在具體的某個時間過期,也就是通過前面的重寫create,update,以及access的方法,獲取具體的過期時間。

更新策略

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

但是實際使用中,你設置了一天刷新,但是一天後你發現緩存並沒有刷新。這是因爲必有在1天后這個緩存再次訪問才能刷新,如果沒人訪問,那麼永遠也不會刷新。你明白了嗎?

我們來看看自動刷新他是怎麼做的呢?自動刷新只存在讀操作之後,也就是我們afterRead()這個方法,其中有個方法叫refreshIfNeeded,他會根據你是同步還是異步然後進行刷新處理。

填充策略

同步加載(Loading)

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

String key = "name1";
// 採用同步方式去獲取一個緩存和上面的手動方式是一個原理。在build Cache的時候會提供一個createExpensiveGraph函數。
// 查詢並在缺失的情況下使用同步的方式來構建一個緩存
Object graph = loadingCache.get(key);

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

異步加載(Asynchronously Loading)

AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .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";

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

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

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

synchronous()這個方法返回了一個LoadingCacheView視圖,LoadingCacheView也繼承自LoadingCache。調用該方法後就相當於你將一個異步加載的緩存AsyncLoadingCache轉換成了一個同步加載的緩存LoadingCache。

默認使用ForkJoinPool.commonPool()來執行異步線程,但是我們可以通過Caffeine.executor(Executor) 方法來替換線程池。

驅逐策略

Caffeine提供三類驅逐策略:基於大小(size-based),基於時間(time-based)和基於引用(reference-based)。

基於大小(size-based)

基於大小驅逐,有兩種方式:一種是基於緩存大小,一種是基於權重。

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

// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));

我們可以使用Caffeine.maximumSize(long)方法來指定緩存的最大容量。當緩存超出這個容量的時候,會使用Window TinyLfu策略來刪除緩存。我們也可以使用權重的策略來進行驅逐,可以使用Caffeine.weigher(Weigher) 函數來指定權重,使用Caffeine.maximumWeight(long) 函數來指定緩存最大權重值。

讓我們看看如何計算緩存中的對象。當緩存初始化時,其大小等於零:

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

當我們添加一個值時,大小明顯增加:

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

我們可以將第二個值添加到緩存中,這導致第一個值被刪除:

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

基於時間(Time-based)

// 基於固定的到期策略進行退出
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));

// 要初始化自定義策略,我們需要實現 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));

基於引用(reference-based)

強引用,軟引用,弱引用概念說明請點擊連接,這裏說一下各各引用的區別:

引用類型 被垃圾回收時間 用途 生存時間
強引用 從來不會 對象的一般狀態 JVM停止運行時終止
軟引用 在內存不足時 對象緩存 內存不足時終止
弱引用 被垃圾回收時 對象緩存 GC運行後終止
虛引用 Unknown Unknown Unknown
// 當key和value都沒有引用時驅逐緩存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .weakKeys()
                                          .weakValues()
                                          .build(key -> createExpensiveGraph(key));

// 當垃圾收集器需要釋放內存時驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
                                          .softValues()
                                          .build(key -> createExpensiveGraph(key));

我們可以將緩存的驅逐配置成基於垃圾回收器。當沒有任何對對象的強引用時,使用 WeakRefence 可以啓用對象的垃圾收回收。SoftReference 允許對象根據 JVM 的全局最近最少使用(Least-Recently-Used)的策略進行垃圾回收。

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

Caffeine 原理分析

緩存算法

緩存算法(FIFO 、LRU、LFU三種算法的區別)

FIFO

FIFO 算法是一種比較容易實現的算法。它的思想是先進先出(FIFO,隊列),這是最簡單、最公平的一種思想,即如果一個數據是最先進入的,那麼可以認爲在將來它被訪問的可能性很小。空間滿的時候,最先進入的數據會被最早置換(淘汰)掉。

FIFO 算法的描述:設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:

  1. set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將最先進入緩存的數據置換掉。
  2. get(key):返回key對應的value值。

實現:維護一個FIFO隊列,按照時間順序將各數據(已分配頁面)鏈接起來組成隊列,並將置換指針指向隊列的隊首。再進行置換時,只需把置換指針所指的數據(頁面)順次換出,並把新加入的數據插到隊尾即可。

缺點:判斷一個頁面置換算法優劣的指標就是缺頁率,而FIFO算法的一個顯著的缺點是,在某些特定的時刻,缺頁率反而會隨着分配頁面的增加而增加,這稱爲Belady現象。產生Belady現象現象的原因是,FIFO置換算法與進程訪問內存的動態特徵是不相容的,被置換的內存頁面往往是被頻繁訪問的,或者沒有給進程分配足夠的頁面,因此FIFO算法會使一些頁面頻繁地被替換和重新申請內存,從而導致缺頁率增加。因此,現在不再使用FIFO算法。

LRU

LRU(The Least Recently Used,最近最久未使用算法)是一種常見的緩存算法,在很多分佈式緩存系統(如Redis, Memcached)中都有廣泛使用。

LRU算法的思想是:如果一個數據在最近一段時間沒有被訪問到,那麼可以認爲在將來它被訪問的可能性也很小。因此,當空間滿時,最久沒有訪問的數據最先被置換(淘汰)。

LRU算法的描述: 設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:

  1. set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將最久未使用的數據置換掉。
  2. get(key):返回key對應的value值。

實現:最樸素的思想就是用數組+時間戳的方式,不過這樣做效率較低。因此,我們可以用雙向鏈表(LinkedList)+哈希表(HashMap)實現(鏈表用來表示位置,哈希表用來存儲和查找),在Java裏有對應的數據結構LinkedHashMap。

LFU

LFU(Least Frequently Used ,最近最少使用算法)也是一種常見的緩存算法。

顧名思義,LFU算法的思想是:如果一個數據在最近一段時間很少被訪問到,那麼可以認爲在將來它被訪問的可能性也很小。因此,當空間滿時,最小頻率訪問的數據最先被淘汰。

LFU 算法的描述:
設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:

  1. set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將訪問頻率最低的數據置換掉。
  2. get(key):返回key對應的value值。

算法實現策略:考慮到 LFU 會淘汰訪問頻率最小的數據,我們需要一種合適的方法按大小順序維護數據訪問的頻率。LFU 算法本質上可以看做是一個 top K 問題(K = 1),即選出頻率最小的元素,因此我們很容易想到可以用二項堆來選擇頻率最小的元素,這樣的實現比較高效。最終實現策略爲小頂堆+哈希表。

Caffeine 源碼分析

caffeine的load put 和invalidate操作都是原子的,這個意思是這3個操作是互斥的,load和put是不能同時執行的,load和invalidate也是不能同時執行的。
先load再invalidate,invalidate操作是要等load操作執行完的。如果load操作執行比較慢,那invalidate操作就要等很久了。
caffeine的存儲就是ConcurrentHashMap,利用了ConcurrentHashMap自己的node節點鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章