前言
Google 出的 Guava 是 Java 核心增強的庫,應用非常廣泛。
我平時用的也挺頻繁,這次就藉助日常使用的 Cache 元件來看看 Google 大牛們是如何設計的。
快取
本次主要討論快取。
快取在日常開發中舉足輕重,如果你的應用對某類資料有著較高的讀取頻次,並且改動較小時那就非常適合利用快取來提高效能。
快取之所以可以提高效能是因為它的讀取效率很高,就像是 CPU 的 L1、L2、L3
快取一樣,級別越高相應的讀取速度也會越快。
但也不是什麼好處都佔,讀取速度快了但是它的記憶體更小資源更寶貴,所以我們應當快取真正需要的資料。
其實也就是典型的空間換時間。
下面談談 Java 中所用到的快取。
JVM 快取
首先是 JVM 快取,也可以認為是堆快取。
其實就是建立一些全域性變數,如 Map、List
之類的容器用於存放資料。
這樣的優勢是使用簡單但是也有以下問題:
- 只能顯式的寫入,清除資料。
- 不能按照一定的規則淘汰資料,如
LRU,LFU,FIFO
等。 - 清除資料時的回撥通知。
- 其他一些定製功能等。
Ehcache、Guava Cache
所以出現了一些專門用作 JVM 快取的開源工具出現了,如本文提到的 Guava Cache。
它具有上文 JVM 快取不具有的功能,如自動清除資料、多種清除演算法、清除回撥等。
但也正因為有了這些功能,這樣的快取必然會多出許多東西需要額外維護,自然也就增加了系統的消耗。
分散式快取
剛才提到的兩種快取其實都是堆內快取,只能在單個節點中使用,這樣在分散式場景下就招架不住了。
於是也有了一些快取中介軟體,如 Redis、Memcached,在分散式環境下可以共享記憶體。
具體不在本次的討論範圍。
Guava Cache 示例
之所以想到 Guava 的 Cache,也是最近在做一個需求,大體如下:
從 Kafka 實時讀取出應用系統的日誌資訊,該日誌資訊包含了應用的健康狀況。
如果在時間視窗 N 內發生了 X 次異常資訊,相應的我就需要作出反饋(報警、記錄日誌等)。
對此 Guava 的 Cache 就非常適合,我利用了它的 N 個時間內不寫入資料時快取就清空的特點,在每次讀取資料時判斷異常資訊是否大於 X 即可。
虛擬碼如下:
@Value("${alert.in.time:2}")
private int time ;
@Bean
public LoadingCache buildCache(){
return CacheBuilder.newBuilder()
.expireAfterWrite(time, TimeUnit.MINUTES)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long key) throws Exception {
return new AtomicLong(0);
}
});
}
/**
* 判斷是否需要報警
*/
public void checkAlert() {
try {
if (counter.get(KEY).incrementAndGet() >= limit) {
LOGGER.info("***********報警***********");
//將快取清空
counter.get(KEY).getAndSet(0L);
}
} catch (ExecutionException e) {
LOGGER.error("Exception", e);
}
}
首先是構建了 LoadingCache 物件,在 N 分鐘內不寫入資料時就回收快取(當通過 Key 獲取不到快取時,預設返回 0)。
然後在每次消費時候呼叫 checkAlert()
方法進行校驗,這樣就可以達到上文的需求。
我們來設想下 Guava 它是如何實現過期自動清除資料,並且是可以按照 LRU 這樣的方式清除的。
大膽假設下:
內部通過一個佇列來維護快取的順序,每次訪問過的資料移動到佇列頭部,並且額外開啟一個執行緒來判斷資料是否過期,過期就刪掉。有點類似於我之前寫過的 動手實現一個 LRU cache
胡適說過:大膽假設小心論證
下面來看看 Guava 到底是怎麼實現。
原理分析
看原理最好不過是跟程式碼一步步走了:
示例程式碼在這裡:
為了能看出 Guava 是怎麼刪除過期資料的在獲取快取之前休眠了 5 秒鐘,達到了超時條件。
最終會發現在 com.google.common.cache.LocalCache
類的 2187 行比較關鍵。
再跟進去之前第 2182 行會發現先要判斷 count 是否大於 0,這個 count 儲存的是當前快取的數量,並用 volatile 修飾保證了可見性。
更多關於 volatile 的相關資訊可以檢視 你應該知道的 volatile 關鍵字
接著往下跟到:
2761 行,根據方法名稱可以看出是判斷當前的 Entry 是否過期,該 entry 就是通過 key 查詢到的。
這裡就很明顯的看出是根據根據構建時指定的過期方式來判斷當前 key 是否過期了。
如果過期就往下走,嘗試進行過期刪除(需要加鎖,後面會具體討論)。
到了這裡也很清晰了:
- 獲取當前快取的總數量
- 自減一(前面獲取了鎖,所以執行緒安全)
- 刪除並將更新的總數賦值到 count。
其實大體上就是這個流程,Guava 並沒有按照之前猜想的另起一個執行緒來維護過期資料。
應該是以下原因:
- 新起執行緒需要資源消耗。
- 維護過期資料還要獲取額外的鎖,增加了消耗。
而在查詢時候順帶做了這些事情,但是如果該快取遲遲沒有訪問也會存在資料不能被回收的情況,不過這對於一個高吞吐的應用來說也不是問題。
總結
最後再來總結下 Guava 的 Cache。
其實在上文跟程式碼時會發現通過一個 key 定位資料時有以下程式碼:
如果有看過 ConcurrentHashMap 的原理 應該會想到這其實非常類似。
其實 Guava Cache 為了滿足併發場景的使用,核心的資料結構就是按照 ConcurrentHashMap 來的,這裡也是一個 key 定位到一個具體位置的過程。
先找到 Segment,再找具體的位置,等於是做了兩次 Hash 定位。
上文有一個假設是對的,它內部會維護兩個佇列 accessQueue,writeQueue
用於記錄快取順序,這樣才可以按照順序淘汰資料(類似於利用 LinkedHashMap 來做 LRU 快取)。
同時從上文的構建方式來看,它也是構建者模式來建立物件的。
因為作為一個給開發者使用的工具,需要有很多的自定義屬性,利用構建則模式再合適不過了。
Guava 其實還有很多東西沒談到,比如它利用 GC 來回收記憶體,移除資料時的回撥通知等。之後再接著討論。
掃碼關注微信公眾號,第一時間獲取訊息。