使用Guava cache構建本地緩存


前言

最近在一個項目中需要用到本地緩存,在網上調研后,發現谷歌的Guva提供的cache模塊非常的不錯。簡單易上手的api;靈活強大的功能,再加上谷歌這塊金字招牌,讓我毫不猶豫的選擇了它。僅以此博客記錄我在使用過程中的點滴。

什么是本地緩存

在我們的應用中,大部分的計算是昂貴的,而且是可復用的,並且計算結果不會經常發生改變。這時候我們就可以將這些昂貴的計算結果緩存到內存中,下次使用的時候直接取出即可,而不用重新計算。這樣可以節省大量的cpu和內存資源,提高系統的吞吐量。

本地緩存作用就是提高系統的運行速度,是一種空間換時間的取舍。它實質上是一個做key-value查詢的字典,但是相對於我們常用HashMap它又有以下特點:

  1. 並發性;由於目前的應用大都是多線程的,所以緩存需要支持並發的寫入。
  2. 過期策略;在某些場景中,我們可能會希望緩存的數據有一定“保質期”,過期策略可以固定時間,例如緩存寫入10分鍾后過期。也可以是相對時間,例如10分鍾內未訪問則使緩存過期(類似於servlet中的session)。在java中甚至可以使用軟引用,弱引用的過期策略。
  3. 淘汰策略;由於本地緩存是存放在內存中,我們往往需要設置一個容量上限和淘汰策略來防止出現內存溢出的情況。

 

這是我們的項目中用到的:

   private DriverInfoServiceImpl(DriverClient driverClient, DriverRedisClient driverRedisClient) {
        this.driverClient = driverClient;
        this.driverCacheLoader = new DriverCacheLoader(driverClient);
        this.driverRedisClient = driverRedisClient;
        loadingCache = CacheBuilder
                .newBuilder()
                .concurrencyLevel(5) //同事允許5個線程修改緩存
                .recordStats() //打開緩存記錄器
                .expireAfterWrite(1, TimeUnit.MINUTES)//過期時間
                .build(driverCacheLoader);
    }

 

 /**
     * 獲取queryDriverCache接口查詢到的司機緩存
     *
     * @param driverId
     * @return
     */
    private DriverSnapshoot getDriverCache(String driverId) {
        try {
            if (MccConfig.getENABLE_DRIVER_DATA_GUAVA_CACHE() == 1) {
                Optional<DriverSnapshoot> optional = loadingCache.get(driverId);
                if (MccConfig.getENABLE_GUAVA_STATS_LOGS() == Constants.SWITCH_OPEN) {
                    log.info("guava 緩存命中率={}", loadingCache.stats().hitRate());
                }
                if (optional.isPresent()) {
                    return optional.get();
                }
                log.warn("loadingCache.getDriverCache 為空");
                return null;
            } else {
                //清空全部緩存
                if (loadingCache.size() > 0) {
                    loadingCache.invalidateAll();
                }
                Optional<DriverSnapshoot> optional = driverCacheLoader.load(driverId);
                if (optional.isPresent()) {
                    return optional.get();
                }
                log.warn("driverCacheLoader.load 為空");
                return null;
            }

        } catch (Exception e) {
            throw QcsServiceErrorEnum.DATA_MANAGE_PART_FAIL.formException(String.format("queryDriverCache失敗,driverId:%s", driverId), e);
        }
    }

 

緩存的最大容量與淘汰策略

由於本地緩存是將計算結果緩存到內存中,所以我們往往需要設置一個最大容量來防止出現內存溢出的情況。這個容量可以是緩存對象的數量,也可以是一個具體的內存大小。在Guva中僅支持設置緩存對象的數量。

當緩存數量逼近或大於我們所設置的最大容量時,為了將緩存數量控制在我們所設定的閾值內,就需要丟棄掉一些數據。由於緩存的最大容量恆定,為了提高緩存的命中率,我們需要盡量丟棄那些我們之后不再經常訪問的數據,保留那些即將被訪問的數據。為了達到以上目的,我們往往會制定一些緩存淘汰策略,常用的緩存淘汰策略有以下幾種:

  1. FIFO:First In First Out,先進先出。
    一般采用隊列的方式實現。這種淘汰策略僅僅是保證了緩存數量不超過我們所設置的閾值,而完全沒有考慮緩存的命中率。所以在這種策略極少被使用。
  2. LRU:Least Recently Used,最近最少使用;
    該算法其核心思想是“如果數據最近被訪問過,那么將來被訪問的幾率也更高”。
    所以該算法是淘汰最后一次使用時間離當前最久的緩存數據,保留最近訪問的數據。所以該種算法非常適合緩存“熱點數據”。
    但是該算法在緩存周期性數據時,就會出現緩存污染,也就是淘汰了即將訪問的數據,反而把不常用的數據讀取到緩存中。
    為了解決這個問題,后續也出現了如LRU-K,Two queues,Multi Queue等進階算法。
  3. LFU:Least Frequently Used,最不經常使用。
    該算法的核心思想是“如果數據在以前被訪問的次數最多,那么將來被訪問的幾率就會更高”。所以該算法淘汰的是歷史訪問次數最少的數據。
    一般情況下,LFU效率要優於LRU,且能夠避免周期性或者偶發性的操作導致緩存命中率下降的問題。但LFU需要記錄數據的歷史訪問記錄,一旦數據訪問模式改變,LFU需要更長時間來適用新的訪問模式,即:LFU存在歷史數據影響將來數據的“緩存污染”效用。
    后續出現LFU*,LFU-Aging,Window-LFU等改進算法。

合理的使用淘汰算法能夠很明顯的提升緩存命中率,但是也不應該一味的追求命中率,而是應在命中率和資源消耗中找到一個平衡。

在guava中默認使用LRU淘汰算法,而且在不修改源碼的情況下也不支持自定義淘汰算法,這算是一種小小的遺憾吧。

Guva和它的cache

Guva是google開源的一個公共java庫,類似於Apache Commons,它提供了集合,反射,緩存,科學計算,xml,io等一些工具類庫。

cache只是其中的一個模塊。使用Guva cache能夠方便快速的構建本地緩存。

使用Guava構建第一個緩存

首先需要在maven項目中加入guava依賴

<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>25.0-jre</version> </dependency>

然后便可以通過Guava創建一個緩存,例如:

// 通過CacheBuilder構建一個緩存實例 Cache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(100) // 設置緩存的最大容量 .expireAfterWrite(1, TimeUnit.MINUTES) // 設置緩存在寫入一分鍾后失效 .concurrencyLevel(10) // 設置並發級別為10 .recordStats() // 開啟緩存統計 .build(); // 放入緩存 cache.put("key", "value"); // 獲取緩存 String value = cache.getIfPresent("key");

Guava的緩存有許多配置選項,所以為了簡化緩的創建過程,使用了Builder設計模式;Builder使用的是鏈式編程的思想,也就是每次調用方法后返回的是對象本生,這樣可以極大的簡化配置過程。

上面的代碼演示了使用Guava創建了一個基於內存的本地緩存,並指定了一些緩存的參數,如緩存容量、緩存過期時間、並發級別等,隨后通過put方法放入一個緩存並使用getIfPresent來獲取它。

Cache與LoadingCache

使用CacheBuilder我們能構建出兩種類型的cache,他們分別是Cache與LoadingCache。

Cache

Cache是通過CacheBuilder的build()方法構建,它是Gauva提供的最基本的緩存接口,並且它提供了一些常用的緩存api:

Cache<Object, Object> cache = CacheBuilder.newBuilder().build();
// 放入/覆蓋一個緩存 cache.put("k1", "v1"); // 獲取一個緩存,如果該緩存不存在則返回一個null值 Object value = cache.getIfPresent("k1"); // 獲取緩存,當緩存不存在時,則通Callable進行加載並返回。該操作是原子 Object getValue = cache.get("k1", new Callable<Object>() { @Override public Object call() throws Exception { return null; } });

LoadingCache

LoadingCache繼承自Cache,在構建LoadingCache時,需要通過CacheBuilder的build(CacheLoader<? super K1, V1> loader)方法構建:

CacheBuilder.newBuilder()
		.build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 緩存加載邏輯 ... } });

LoadingCache,顧名思義,它能夠通過CacheLoader自發的加載緩存:

 LoadingCache<Object, Object> loadingCache = CacheBuilder.newBuilder().build(new CacheLoader<Object, Object>() { @Override public Object load(Object key) throws Exception { return null; } }); // 獲取緩存,當緩存不存在時,會通過CacheLoader自動加載,該方法會拋出ExecutionException異常 loadingCache.get("k1"); // 以不安全的方式獲取緩存,當緩存不存在時,會通過CacheLoader自動加載,該方法不會拋出異常 loadingCache.getUnchecked("k1");

緩存的並發級別

Guava提供了設置並發級別的api,使得緩存支持並發的寫入和讀取。同ConcurrentHashMap類似Guava cache的並發也是通過分離鎖實現。在一般情況下,將並發級別設置為服務器cpu核心數是一個比較不錯的選擇。

CacheBuilder.newBuilder()
		// 設置並發級別為cpu核心數 .concurrencyLevel(Runtime.getRuntime().availableProcessors()) .build();

緩存的初始容量

我們在構建緩存時可以為緩存設置一個合理大小初始容量,由於Guava的緩存使用了分離鎖的機制,擴容的代價非常昂貴。所以合理的初始容量能夠減少緩存容器的擴容次數。

CacheBuilder.newBuilder()
		// 設置初始容量為100 .initialCapacity(100) .build();

緩存的回收

在前文提到過,在構建本地緩存時,我們應該指定一個最大容量來防止出現內存溢出的情況。在guava中除了提供基於數量,和基於內存容量兩種回收策略外,還提供了基於引用的回收。

基於數量/容量的回收

基於最大數量的回收策略非常簡單,我們只需指定緩存的最大數量maximumSize即可:

CacheBuilder.newBuilder()
		.maximumSize(100) // 緩存數量上限為100 .build();

使用基於最大容量的的回收策略時,我們需要設置2個必要參數:

  • maximumWeigh;用於指定最大容量。
  • Weigher;在加載緩存時用於計算緩存容量大小。

這里我們例舉一個key和value都是String類型緩存:

CacheBuilder.newBuilder()
		.maximumWeight(1024 * 1024 * 1024) // 設置最大容量為 1M // 設置用來計算緩存容量的Weigher .weigher(new Weigher<String, String>() { @Override public int weigh(String key, String value) { return key.getBytes().length + value.getBytes().length; } }).build();

當緩存的最大數量/容量逼近或超過我們所設置的最大值時,Guava就會使用LRU算法對之前的緩存進行回收。

基於軟/弱引用的回收

基於引用的回收策略,是java中獨有的。在java中有對象自動回收機制,依據程序員創建對象的方式不同,將對象由強到弱分為強引用、軟引用、弱引用、虛引用。對於這幾種引用他們有以下區別:

強引用

強引用是使用最普遍的引用。如果一個對象具有強引用,那垃圾回收器絕不會回收它。

Object o=new Object(); // 強引用 

當內存空間不足,垃圾回收器不會自動回收一個被引用的強引用對象,而是會直接拋出OutOfMemoryError錯誤,使程序異常終止。

軟引用

相對於強引用,軟引用是一種不穩定的引用方式,如果一個對象具有軟引用,當內存充足時,GC不會主動回收軟引用對象,而當內存不足時軟引用對象就會被回收。

SoftReference<Object> softRef=new SoftReference<Object>(new Object()); // 軟引用 Object object = softRef.get(); // 獲取軟引用

使用軟引用能防止內存泄露,增強程序的健壯性。但是一定要做好null檢測。

弱引用

弱引用是一種比軟引用更不穩定的引用方式,因為無論內存是否充足,弱引用對象都有可能被回收。

WeakReference<Object> weakRef = new WeakReference<Object>(new Object()); // 弱引用 Object obj = weakRef.get(); // 獲取弱引用

虛引用

而虛引用這種引用方式就是形同虛設,因為如果一個對象僅持有虛引用,那么它就和沒有任何引用一樣。在實踐中也幾乎沒有使用。

在Guava cache中支持,軟/弱引用的緩存回收方式。使用這種方式能夠極大的提高內存的利用率,並且不會出現內存溢出的異常。

CacheBuilder.newBuilder()
		.weakKeys() // 使用弱引用存儲鍵。當鍵沒有其它(強或軟)引用時,該緩存可能會被回收。 .weakValues() // 使用弱引用存儲值。當值沒有其它(強或軟)引用時,該緩存可能會被回收。 .softValues() // 使用軟引用存儲值。當內存不足並且該值其它強引用引用時,該緩存就會被回收 .build();

通過軟/弱引用的回收方式,相當於將緩存回收任務交給了GC,使得緩存的命中率變得十分的不穩定,在非必要的情況下,還是推薦基於數量和容量的回收。

顯式回收

在緩存構建完畢后,我們可以通過Cache提供的接口,顯式的對緩存進行回收,例如:

// 構建一個緩存 Cache<String, String> cache = CacheBuilder.newBuilder().build(); // 回收key為k1的緩存 cache.invalidate("k1"); // 批量回收key為k1、k2的緩存 List<String> needInvalidateKeys = new ArrayList<>(); needInvalidateKeys.add("k1"); needInvalidateKeys.add("k2"); cache.invalidateAll(needInvalidateKeys); // 回收所有緩存 cache.invalidateAll();

緩存的過期策略與刷新

Guava也提供了緩存的過期策略和刷新策略。

緩存過期策略

緩存的過期策略分為固定時間和相對時間。

固定時間一般是指寫入后多長時間過期,例如我們構建一個寫入10分鍾后過期的緩存:

CacheBuilder.newBuilder()
		.expireAfterWrite(10, TimeUnit.MINUTES) // 寫入10分鍾后過期 .build(); // java8后可以使用Duration設置 CacheBuilder.newBuilder() .expireAfterWrite(Duration.ofMinutes(10)) .build();

相對時間一般是相對於訪問時間,也就是每次訪問后,會重新刷新該緩存的過期時間,這有點類似於servlet中的session過期時間,例如構建一個在10分鍾內未訪問則過期的緩存:

CacheBuilder.newBuilder()
		.expireAfterAccess(10, TimeUnit.MINUTES) //在10分鍾內未訪問則過期 .build(); // java8后可以使用Duration設置 CacheBuilder.newBuilder() .expireAfterAccess(Duration.ofMinutes(10)) .build();

緩存刷新

在Guava cache中支持定時刷新和顯式刷新兩種方式,其中只有LoadingCache能夠進行定時刷新。

定時刷新

在進行緩存定時刷新時,我們需要指定緩存的刷新間隔,和一個用來加載緩存的CacheLoader,當達到刷新時間間隔后,下一次獲取緩存時,會調用CacheLoader的load方法刷新緩存。例如構建個刷新頻率為10分鍾的緩存:

CacheBuilder.newBuilder()
		// 設置緩存在寫入10分鍾后,通過CacheLoader的load方法進行刷新 .refreshAfterWrite(10, TimeUnit.SECONDS) // jdk8以后可以使用 Duration // .refreshAfterWrite(Duration.ofMinutes(10)) .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 緩存加載邏輯 ... } });

顯式刷新

在緩存構建完畢后,我們可以通過Cache提供的一些借口方法,顯式的對緩存進行刷新覆蓋,例如:

// 構建一個緩存 Cache<String, String> cache = CacheBuilder.newBuilder().build(); // 使用put進行覆蓋刷新 cache.put("k1", "v1"); // 使用Map的put方法進行覆蓋刷新 cache.asMap().put("k1", "v1"); // 使用Map的putAll方法進行批量覆蓋刷新 Map<String,String> needRefreshs = new HashMap<>(); needRefreshs.put("k1", "v1"); cache.asMap().putAll(needRefreshs); // 使用ConcurrentMap的replace方法進行覆蓋刷新 cache.asMap().replace("k1", "v1");

對於LoadingCache,由於它能夠自動的加載緩存,所以在進行刷新時,不需要顯式的傳入緩存的值:

LoadingCache<String, String> loadingCache = CacheBuilder
			.newBuilder()
			.build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 緩存加載邏輯 return null; } }); // loadingCache 在進行刷新時無需顯式的傳入 value loadingCache.refresh("k1");


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM