天天看點

高并發之——Guava Cache

最近需要用到緩存來存放臨時資料,又不想采用Redis,Java自帶的Map功能太少,發現Google的Guava提供的Cache子產品功能很強大,于是選擇使用它。

本地緩存

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

1.并發性:由于目前的應用大都是多線程的,是以緩存需要支援并發的寫入。

2.過期政策:在某些場景中,我們可能會希望緩存的資料有一定“保存期限”,過期政策可以固定時間,例如緩存寫入10分鐘後過期。也可以是相對時間,例如10分鐘内未通路則使緩存過期(類似于servlet中的session)。在java中甚至可以使用軟引用,弱引用的過期政策。

3.淘汰政策:由于本地緩存是存放在記憶體中,我們往往需要設定一個容量上限和淘汰政策來防止出現記憶體溢出的情況。

緩存應當具備的屬性為:

1、能夠配置緩存的大小,保持可控的Memory。

2、适應多種場景的資料expire政策。

3、在高并發情況下、能夠正常緩存的更新以及傳回。

Guava Cache适用于:

你願意消耗一些記憶體空間來提升速度。

你預料到某些鍵會被查詢一次以上。

緩存中存放的資料總量不會超出記憶體容量

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

由于本地緩存是将計算結果緩存到記憶體中,是以我們往往需要設定一個最大容量來防止出現記憶體溢出的情況。這個容量可以是緩存對象的數量,也可以是一個具體的記憶體大小。在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淘汰算法,而且在不修改源碼的情況下也不支援自定義淘汰算法。

使用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");      

Cache與LoadingCache

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

Cache

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

// 放入/覆寫一個緩存
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;
    }
});      

java8也可以采用lambda表達式來代替匿名内部類

Object getValue = cache.get("k1", () -> {
    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即可,maximumSize 設定了該緩存的最大存儲機關(key)個數:

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

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

maximumWeigh;用于指定最大容量,maximumWeight 是根據設定緩存資料的最大值。

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.invalidate(key)​​
  • 批量清除:​​Cache.invalidateAll(keys)​​
  • 清除所有緩存項:​​Cache.invalidateAll()​​
// 建構一個緩存
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();      

移除監聽器

通過​​CacheBuilder.removalListener(RemovalListener)​​​,你可以聲明一個監聽器,以便緩存項被移除時做一些額外操作。緩存項被移除時,​​RemovalListener​​​<會擷取移除通知[​​RemovalNotification​​​],其中包含移除原因[​​RemovalCause​​]、鍵和值。

請注意,RemovalListener抛出的任何異常都會在記錄到日志後被丢棄[swallowed]。

CacheLoader<Key, DatabaseConnection> loader = new CacheLoader<Key, DatabaseConnection> () {
    public DatabaseConnection load(Key key) throws Exception {
        return openConnection(key);
    }
};

RemovalListener<Key, DatabaseConnection> removalListener = new RemovalListener<Key, DatabaseConnection>() {
    public void onRemoval(RemovalNotification<Key, DatabaseConnection> removal) {
        DatabaseConnection conn = removal.getValue();
        conn.close(); // tear down properly
    }
};

return CacheBuilder.newBuilder()
    .expireAfterWrite(2, TimeUnit.MINUTES)
    .removalListener(removalListener)
    .build(loader);      

警告:預設情況下,監聽器方法是在移除緩存時同步調用的。因為緩存的維護和請求響應通常是同時進行的,代價高昂的監聽器方法在同步模式下會拖慢正常的緩存請求。在這種情況下,你可以使用​​RemovalListeners.asynchronous(RemovalListener, Executor)​​把監聽器裝飾為異步操作。

緩存的過期政策與重新整理

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");      

統計

​​CacheBuilder.recordStats()​​​用來開啟Guava Cache的統計功能。統計打開後,​​Cache.stats()​​​方法會傳回​​CacheStats​​對象以提供如下統計資訊:

  • ​​hitRate()​​:緩存命中率;
  • ​​averageLoadPenalty()​​:加載新值的平均時間,機關為納秒;
  • ​​evictionCount()​​:緩存項被回收的總數,不包括顯式清除。

此外,還有其他很多統計資訊。這些統計資訊對于調整緩存設定是至關重要的,在性能要求高的應用中我們建議密切關注這些資料。

Guava 提供了recordStats()方法,相當于啟動了記錄模式,通過Cache.stats()方法可以擷取CacheStats對象,裡面存儲着緩存的使用情況,通過觀察它就可以知道緩存的命中率,加載耗時等資訊,有了這些資料的回報就可以調整的緩存的大小以及其他的優化工作了。

asMap視圖

asMap視圖提供了緩存的ConcurrentMap形式,但asMap視圖與緩存的互動需要注意:

  • cache.asMap()包含目前所有加載到緩存的項。是以相應地,cache.asMap().keySet()包含目前所有已加載鍵;
  • asMap().get(key)實質上等同于cache.getIfPresent(key),而且不會引起緩存項的加載。這和Map的語義約定一緻。
  • 所有讀寫操作都會重置相關緩存項的通路時間,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合視圖上的操作。比如,周遊Cache.asMap().entrySet()不會重置緩存項的讀取時間。

常見問題

繼續閱讀