CaffeineCache Api介紹以及與Guava Cache性能對比| 京東物流技術團隊

一、簡單介紹:

CaffeineCache和Guava的Cache是應用廣泛的本地緩存。

在開發中,爲了達到降低依賴、提高訪問速度的目的。會使用它存儲一些維表接口的返回值和數據庫查詢結果,在有些場景下也會在分佈式緩存上再加上一層本地緩存,用來減少對遠程服務和數據庫的請求次數。

CaffeineCache是以Guava Cache爲原型庫開發和擴展的一種本地緩存,並且適配Guava Cache的Api,但是CaffeineCache的性能更好。

二、CaffeineCache的使用:

CaffeineCache官方介紹有提供一些例子,不過這些例子不能直接運行。

下面圍繞比較常用的API介紹下CaffeineCache的使用,列舉一些可直接執行的Demo,看起來明瞭一些。

1.存數據:

Caffeine提供了四種緩存添加策略:手動加載,自動加載,手動異步加載和自動異步加載。

1.1手動加載:

        Cache<String, String> cache = Caffeine.newBuilder()
                 //過期時間 
                .expireAfterWrite(10, TimeUnit.MINUTES)
                 //最大容量 
                .maximumSize(10_000)
                .build();
        String key = "test";
        // 查找一個緩存元素, 沒有查找到的時候返回null 
        String res = cache.get(key, k -> createValue(key));
        // 添加或者更新一個緩存元素 
        cache.put(key, "testValue"); 
        // 移除一個緩存元素
        cache.invalidate(key);
    }

     // 模擬從外部數據源加載數據的邏輯 
    static String createValue(String key) {
        return "value";
    }

推薦使用 get(K var1, Function<? super K, ? extends V> var2);

get方法可以在緩存中不存在該key對應的值時進行計算,生成並直接寫入至緩存內,最後將結果返回,而當該key對應的值存在時將會直接返回值。

注意到createValue方法有可能會出現異常,根據官網所說:“當緩存的元素無法生成或者在生成的過程中拋出異常而導致生成元素失敗,cache.get 也許會返回 null ”,那麼實際情況怎麼樣呢?我們來試一下。

public class TestCaffeineCache {
    public static void main(String[] args) {
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)
                .build();
        String key = "test";
        String res = cache.get(key, k -> createValue(key));
        System.out.println(res);
    }
    
    // 模擬從外部數據源加載數據的邏輯 
    static String createValue(String key) {
        //模擬異常情況
        int a = 1/0;
        return "";
    }
}

運行結果:





 

可以看到,執行cache.get時,在生成結果的過程中如果出現異常了,cache.get不會返回null,仍會直接報錯。

1.2自動加載

public class TestCaffeineCache {
    public static void main(String[] args) {
         LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String key) {
                        return getValue(key);
                    }
                });

        String value = cache.get("key");
        System.out.println(value);
    }

    // 模擬從外部數據源加載數據的邏輯
    private static String getValue(String key) {
        // 實際情況下,這裏會有從數據庫、遠程服務加載數據的邏輯
        return "value";
    }
}

可以用lambda簡化它:

public class TestCaffeineCache {
    public static void main(String[] args) {
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(key -> getValue(key));

        String value = cache.get("key");
        System.out.println(value);
    }

    // 模擬從外部數據源加載數據的邏輯
    private static String getValue(String key) {
        return "value";
    }
}

上面的示例中, build方法傳入的CacheLoader定義了加載緩存的邏輯。調用cache.get("key")時,如果緩存中不存在對應的值,CacheLoader會調用load方法來加載和緩存值。

可以通過重寫和CacheLoader.load和loadAll並手動調用,在LoadingCache創建之前提前加載一些數據。

    public static void main(String[] args) throws Exception {
        CacheLoader loader = new CacheLoader<String,String>() {
            @Override
            public String load( String s) throws Exception {
                return getValue(s);
            }
            @Override
            public Map<String, String> loadAll(Iterable<? extends String> keys) throws Exception {
                Map currentMap = new HashMap<String,String>();
                for (String key : keys) {
                    currentMap.put(key, getValue(key));
                }
                return currentMap;
            }
        };

        loader.load("key1");
        loader.loadAll(new ArrayList( Arrays.asList("key2","key3")));
        LoadingCache<String, String> cache = Caffeine.newBuilder().build(loader);
        String value = cache.get("key1");
        String value2 = cache.get("key2");
        System.out.println(value+value2);
    }

    // 模擬從外部數據源加載數據的邏輯
    private static String getValue(String key) {
        return "value";
    }

1.3手動異步加載:

    public static void main(String[] args) throws Exception {

        AsyncCache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000).buildAsync();
        String key ="test";
        CompletableFuture<String> res = cache.get(key,k-> getValue(key));
        res.thenAccept(result -> System.out.println(result));
    }

    // 模擬從外部數據源加載數據的邏輯
    private static String getValue(String key) {
        return "value";
    }
    

異步加載使用的類是AsyncCache,使用方法和Cache類似。cache.get(key, k -> getValue(key))將會返回一個CompletableFuture,這一步驟會在一個異步任務中執行,而不會阻塞主線程。res.thenAccept方法將在數據加載完成後得到結果。

1.4自動異步加載:


    public static void main(String[] args) throws Exception {
        Executor executor = Executors.newFixedThreadPool(5);
        AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)
                //異步的封裝一段同步操作來生成緩存元素
                .buildAsync(key -> getValue(key))
                //OR建一個異步緩存元素操作並返回一個future
                .buildAsync((key,executor1) -> getValue(key,executor));
        String key = "test";
        CompletableFuture<String> res = cache.get(key);
        res.thenAccept(result -> System.out.println(result));
    }

    // 模擬從外部數據源加載數據的邏輯
    private static CompletableFuture<String> getValue(String key,Executor executor) {
        return CompletableFuture.supplyAsync(() -> "value for " + key, executor);
    }
     private static String getValue(String key) { 
         return "value"; 
    }
   
     

自動異步加載使用方法和手動異步加載類似,getValue可接收一個Executor對象,用於自定義執行異步操作的線程池。

2.驅逐:

2.1基於容量:

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

最常用的驅逐策略了,Caffeine提供多種算法根據最近使用頻率和使用時間來驅逐元素 ref:Window-TinyLFU

2.2基於權重:

class Product {
    private String name;
    private int weight;

    public Product(String s, int i) {
        name=s;
        weight=i;
    }
    public String getName() {
        return name;
    }
    public int getWeight() {
        return weight;
    }
    @Override
    public String toString(){
        return getName();
    }
}

public class TestCaffeineCache {
    public static void main(String[] args) {
        Cache<String, Product> cache = Caffeine.newBuilder()
                .maximumWeight(1000)
                .weigher((String key, Product value) -> value.getWeight())
                //使用當前線程進行驅逐和刷新
                .executor(runnable -> runnable.run())
                //監聽器,如果有元素被驅逐則會輸出
                .removalListener(((key, value, cause) -> {
                    System.out.printf("Key %s was evicted (%s)%n", key, cause);
                }))
                .build();
        // 向緩存中添加商品信息
        cache.put("product1", new Product("Product 1", 200));
        cache.put("product2", new Product("Product 2", 400));
        cache.put("product3", new Product("Product 3", 500));
        // 獲取緩存中的商品信息
        System.out.println(cache.getIfPresent("product1"));
        System.out.println(cache.getIfPresent("product2"));
        System.out.println(cache.getIfPresent("product3"));
    }
}





 

.weigher((String key, Product value) -> value.getWeight()) 制定了一個權重計算器,Product對象的getWeight()方法來計算權重。

通過示例中的返回結果可以看到,當product3被put後,總容量超過了1000,product1就被驅逐了。

2.3基於時間:

附上官方的例子:

// 基於固定的過期時間驅逐策略
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));

// 基於不同的過期驅逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfter(new Expiry<Key, Graph>() {
      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);
      }
      public long expireAfterUpdate(Key key, Graph graph, 
          long currentTime, long currentDuration) {
        return currentDuration;
      }
      public long expireAfterRead(Key key, Graph graph,
          long currentTime, long currentDuration) {
        return currentDuration;
      }
    })
    .build(key -> createExpensiveGraph(key));

Caffeine提供了三種方法進行基於時間的驅逐——官方的解釋:

expireAfterAccess(long, TimeUnit): 一個元素在上一次讀寫操作後一段時間之後,在指定的時間後沒有被再次訪問將會被認定爲過期項。在當被緩存的元素時被綁定在一個session上時,當session因爲不活躍而使元素過期的情況下,這是理想的選擇。

expireAfterWrite(long, TimeUnit): 一個元素將會在其創建或者最近一次被更新之後的一段時間後被認定爲過期項。在對被緩存的元素的時效性存在要求的場景下,這是理想的選擇。

expireAfter(Expiry): 一個元素將會在指定的時間後被認定爲過期項。當被緩存的元素過期時間收到外部資源影響的時候,這是理想的選擇。

寫一個Demo舉例expireAfterAccess和expireAfterWrite

    public static void main(String[] args) {
        //模擬時間,使用的com.google.common.testing.FakeTicker;
        FakeTicker ticker = new FakeTicker();
        Cache<String, String> cache = Caffeine.newBuilder()
                 //創建20分鐘後元素被刪除
                .expireAfterWrite(20, TimeUnit.MINUTES)
                 //沒有讀取10分鐘後元素被刪除 
                .expireAfterAccess(10, TimeUnit.MINUTES)
                .executor(Runnable::run)
                .ticker(ticker::read)
                .build();
        cache.put("key1","value1");
        cache.put("key2","value2");
        ticker.advance(5, TimeUnit.MINUTES);
        System.out.println("5分鐘都不刪除,訪問一次key2:"+cache.getIfPresent("key2"));
        ticker.advance(5, TimeUnit.MINUTES);
        System.out.println("10分鐘key1被刪除,因爲它已經10分鐘沒有被訪問過了:"+cache.getIfPresent("key1"));
        System.out.println("10分鐘key2沒有被刪除,因爲它在5分鐘時被訪問過了:"+cache.getIfPresent("key2"));
        ticker.advance(10, TimeUnit.MINUTES);
        System.out.println("20分鐘key2也被刪除:"+cache.getIfPresent("key2"));
    }

這個例子設定元素創建20分鐘或者沒有讀取10分鐘後被刪除。

key1和key2在創建後。5分鐘時訪問一次key2,十分鐘時key1被刪除,key2沒有被刪除,20分鐘時key2也被刪除。

運行結果正如我們期待的:





 

舉例expireAfter:

    public static void main(String[] args) {
        //模擬時間,使用的com.google.common.testing.FakeTicker;
        FakeTicker ticker = new FakeTicker();
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfter(new Expiry<String, String>() {
                    public long expireAfterCreate(String key, String value, long currentTime) {
                        // 在創建後的24小時後過期
                        return TimeUnit.HOURS.toNanos(24);
                    }
                    public long expireAfterUpdate(String key, String value, long currentTime, long currentDuration) {
                        // 在更新後如果值爲"1234",則立馬過期
                        if("1234".equals(value)){
                            return 0;
                        }
                        // 在更新後的1小時後過期
                        return TimeUnit.HOURS.toNanos(1);
                    }
                    public long expireAfterRead(String key, String value, long currentTime, long currentDuration) {
                        // 在讀取後的20小時後過期
                        return TimeUnit.HOURS.toNanos(20);
                    }
                })
                .executor(Runnable::run)
                .ticker(ticker::read)
                .build();
        cache.put("AfterCreateKey","AfterCreate");
        cache.put("AfterUpdate1234Key","1234key");
        cache.put("AfterUpdateKey","AfterUpdate");
        cache.put("AfterReadKey","AfterRead");
        //AfterUpdate1234Key值更新爲1234
        cache.put("AfterUpdate1234Key","1234");
        System.out.println("AfterUpdate1234Key在更新後值爲1234,立馬過期:"+cache.getIfPresent("AfterUpdate1234Key"));
        System.out.println("AfterReadKey讀取一次:"+cache.getIfPresent("AfterReadKey"));
        //AfterUpdateKey更新一次
        cache.put("AfterUpdateKey","AfterUpdate");
        ticker.advance(1, TimeUnit.HOURS);
        System.out.println("AfterUpdateKey更新了一個小時了,被刪除:"+cache.getIfPresent("AfterUpdateKey"));
        ticker.advance(19, TimeUnit.HOURS);
        System.out.println("AfterReadKey再讀取一次已經刪除了,因爲上一次讀取已經過了20小時:"+cache.getIfPresent("AfterReadKey"));
        ticker.advance(4, TimeUnit.HOURS);
        System.out.println("AfterCreateKey被刪除了,距離創建已經24小時了:"+cache.getIfPresent("AfterCreateKey"));
    }

這個例子設定了元素在以下四種情況會過期:

創建後的24小時
更新後值爲"1234"
更新後的1小時
在讀取後的20小時

以下是運行結果





 

2.4基於引用:

基於引用的過期驅逐策略不常用,這裏附上官方的例子和解釋:

// 當key和緩存元素都不再存在其他強引用的時候驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

// 當進行GC的時候進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

Caffeine 允許你配置緩存,以便GC去幫助清理緩存當中的元素,其中key支持弱引用,而value則支持弱引用和軟引用。AsyncCache不支持軟引用和弱引用。

Caffeine.weakKeys() 在保存key的時候將會進行弱引用。這允許在GC的過程中,當key沒有被任何強引用指向的時候去將緩存元素回收。由於GC只依賴於引用相等性。這導致在這個情況下,緩存將會通過引用相等(==)而不是對象相等 equals()去進行key之間的比較。

Caffeine.weakValues()在保存value的時候將會使用弱引用。這允許在GC的過程中,當value沒有被任何強引用指向的時候去將緩存元素回收。由於GC只依賴於引用相等性。這導致在這個情況下,緩存將會通過引用相等(==)而不是對象相等 equals()去進行value之間的比較。

Caffeine.softValues()在保存value的時候將會使用軟引用。爲了相應內存的需要,在GC過程中被軟引用的對象將會被通過LRU算法回收。由於使用軟引用可能會影響整體性能,我們還是建議通過使用基於緩存容量的驅逐策略代替軟引用的使用。同樣的,使用 softValues() 將會通過引用相等(==)而不是對象相等 equals()去進行value之間的比較。

3.移除:

3.1移除的三個方法:

可以調用以下三個方法移除緩存中的元素

// 失效key
void invalidate(@CompatibleWith("K") @NonNull Object var1);
// 批量失效key
void invalidateAll(@NonNull Iterable<?> var1);
// 失效所有的key
void invalidateAll();



3.2監聽器removalListener和evictionListener

當元素從緩存中被移除時,這兩個監聽器可以進行指定的操作,具體有什麼區別呢?先上例子:

        Cache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(2)
                .executor(Runnable::run)
                //驅逐,刪除key時會輸出
                .removalListener(((key, value, cause) -> {
                    System.out.printf("removalListener—>Key %s was evicted (%s)%n", key, cause);
                }))
                 //驅逐key時會輸出 
                .evictionListener(((key, value, cause) -> {
                    System.out.printf("evictionListener->Key %s was evicted (%s)%n", key, cause);
                }))
                .build();
        // 向緩存中添加商品信息
        cache.put("product1", "product1");
        cache.put("product2", "product2");
        cache.put("product3", "product3");
        // 獲取緩存中的商品信息
        System.out.println(cache.getIfPresent("product1"));
        System.out.println(cache.getIfPresent("product2"));
        System.out.println(cache.getIfPresent("product3"));
        cache.invalidateAll();

結果:





 

可以發現,當元素被驅逐,或者被手動移除時,removalListener都會執行指定的操作。而evictionListener只會在元素被驅逐時執行指定的操作。

4.刷新:

4.1自動刷新:

可以使用refreshAfterWrite在元素寫入一段時間後刷新元素,先上代碼

    public static void main(String[] args) {
        
        FakeTicker ticker = new FakeTicker();
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .refreshAfterWrite(5, TimeUnit.SECONDS) // 在寫入後5秒鐘自動刷新
                .ticker(ticker::read)
                .executor(Runnable::run)
                .build(key -> getVale(key)); // 提供加載方法

        System.out.println("Initial value for key1: " + cache.get("key1"));

        // 超過自動刷新時間
        ticker.advance(7, TimeUnit.SECONDS);

        System.out.println(cache.get("key1")); // 真正執行刷新
        System.out.println(cache.get("key1")); // 輸出自動刷新後的值
    }

    private static String getVale(String key) {
        // 這裏簡單地返回一個當前時間的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }

輸出結果:





 

可以發現過了刷新時間後,第一次訪問key1並沒有返回新值,第二次訪問key1時纔會將刷新後的數據返回,官方的解釋是元素過了刷新時間不會立即刷新,而是在在訪問時纔會刷新,並且沒有刷新完畢,其舊值將仍被返回,直到該元素的刷新完畢後結束後纔會返回刷新後的新值。

4.2手動刷新:

可以使用refresh(Key)方法進行手動刷新

    public static void main(String[] args) {
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(key -> getVale(key)); // 提供加載方法

        System.out.println("Initial value for key1: " + cache.get("key1"));
        cache.refresh("key1");
        System.out.println(cache.get("key1")); // 輸出自動刷新後的值
    }

    private static String getVale(String key) {
        // 這裏簡單地返回一個當前時間的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }





 

4.3刷新自定義處理:

可以使用CacheLoader.reload(K, V)來自定義刷新前後值的處理,下面這個例子重寫了reload方法,將新值和舊值用"|"分開

    public static void main(String[] args) {
        FakeTicker ticker = new FakeTicker();
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .refreshAfterWrite(5, TimeUnit.SECONDS) // 在寫入後5秒鐘自動刷新
                .ticker(ticker::read)
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String s) throws Exception {
                        return getVale(s);
                    }
                    //將刷新前後的數據都獲取出來了
                    @Override
                    public String reload(String s,String v){
                        return getVale(s)+"|"+v;
                    }
                }); // 提供加載方法
        System.out.println("Initial value for key1: " + cache.get("key1"));
        // 等待超過自動刷新時間
        ticker.advance(7, TimeUnit.SECONDS);
        cache.get("key1");
        System.out.println(cache.get("key1")); // 輸出自動刷新後的值
    }

    private static String getVale(String key) {
        // 這裏簡單地返回一個當前時間的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }

結果:





 



三、性能對比:

我們知道,Caffeine的性能比Guava Cache要好,可以寫一個demo簡單對比一下:

1.Caffeine Demo:

package test;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

public class CaffeineCacheTest {

    public static void main(String[] args) throws Exception {
        Cache<Integer, Integer> loadingCache = Caffeine.newBuilder()
                .build();

        // 開始時間
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.put(i, i);
        }
        // 存完成時間
        Long writeFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.getIfPresent(i);
        }
        // 讀取完成時間
        Long readFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.invalidate(i);
        }
        // 刪除完成時間
        Long deleteFinishTime = System.currentTimeMillis();
        System.out.println("CaffeineCache存用時:" + (writeFinishTime - start));
        System.out.println("CaffeineCache讀用時:" + (readFinishTime - writeFinishTime));
        System.out.println("CaffeineCache刪用時:" + (deleteFinishTime - readFinishTime));
    }
}


運行結果:





 

2.Guava Cache Demo:

使用幾乎一致的API,換成Guava Cache再試一次:


package test;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

public class GuavaCacheTest {

    public static void main(String[] args) throws Exception {
        Cache<Integer, Integer> loadingCache = CacheBuilder.newBuilder()
                .build();

        // 開始時間
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.put(i, i);
        }
        // 存完成時間
        Long writeFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.getIfPresent(i);
        }
        // 讀取完成時間
        Long readFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.invalidate(i);
        }
        // 刪除完成時間
        Long deleteFinishTime = System.currentTimeMillis();
        System.out.println("GuavaCache存用時:"+(writeFinishTime-start));
        System.out.println("GuavaCache取用時:"+(readFinishTime-writeFinishTime));
        System.out.println("GuavaCache刪用時:"+(deleteFinishTime-readFinishTime));
    }

}

運行結果:





 

3.多組測試結果:

運行環境:處理器:Apple M3 ,內存:18 GB,JDK1.8

更改循環次數,多組測試結果如下(單位ms):

緩存 Caffeine Guava Cache Caffeine Guava Cache Caffeine Guava Cache Caffeine Guava Cache
次數 100 100 10000 10000 1000000 1000000 5000000 5000000
存用時 1 2 17 51 113 279 3802 2458
取用時 0 0 9 16 25 141 47 531
刪用時 0 11 7 25 35 176 89 1073

可以看出Caffeine的總體性能是比Guava Cache要好的。

當然,基於本地單機的簡單測試,結果受處理器,線程,內存等影響較大。可以參考下官方的測試,有更高的參考意義:官方測試。

四、總結:

本文舉了很多的例子,介紹了Caffeine支持的多種基礎的操作,包括存、取、刪等。以及異步、監聽、刷新等更多拓展的操作,能夠覆蓋大部分需要本地緩存的開發場景。

Caffeine的性能比Guava Cache更好,並列舉了一個性能測試demo,Caffeine兼容Guava Cache的API,所以從Guava Cache遷移至Caffeine也比較容易

最後附上Caffeine的官方網址:官方網址(中文)。



作者:京東物流 殷世傑

來源:京東雲開發者社區

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