文章很長,建議收藏起來,慢慢讀! 瘋狂創客圈為小伙伴奉上以下珍貴的學習資源:
- 瘋狂創客圈 經典圖書 : 《Netty Zookeeper Redis 高並發實戰》 面試必備 + 大廠必備 + 漲薪必備
- 瘋狂創客圈 經典圖書 : 《SpringCloud、Nginx高並發核心編程》 面試必備 + 大廠必備 + 漲薪必備
- 資源寶庫:Java程序員必備 網盤資源大集合 價值>1000元 【隨便取 GO -》】
- 獨孤九劍:Netty靈魂實驗 : 本地 100W連接 高並發實驗,瞬間提升Java內力
Caffeine簡介
在本文中,我們來看看 Caffeine — 一個高性能的 Java 緩存庫。
Caffeine的底層數據存儲采用ConcurrentHashMap。因為Caffeine面向JDK8,在jdk8中ConcurrentHashMap增加了紅黑樹,在hash沖突嚴重時也能有良好的讀性能。
Caffeine VS guava
Caffeine是Spring 5默認支持的Cache,可見Spring對它的看重,Spring拋棄Guava轉向了Caffeine。
緩存和 Map 之間的一個根本區別在於緩存可以回收存儲的 item。
回收策略為在指定時間刪除哪些對象。此策略直接影響緩存的命中率 — 緩存庫的一個重要特征。
Caffeine 因使用 Window TinyLfu 回收策略,提供了一個近乎最佳的命中率。
Caffeine是Spring 5默認支持的Cache
Caffeine是Spring 5默認支持的Cache,可見Spring對它的看重,那么Spring為什么喜新厭舊的拋棄Guava而追求Caffeine呢?
它能提供高命中率和出色的並發能力。 緩存的淘汰策略是為了預測哪些數據在短期內最可能被再次用到,從而提升緩存的命中率。LRU由於實現簡單、高效的運行時表現以及在常規的使用場景下有不錯的命中率,或許是目前最佳的實現途徑。但 LRU 通過歷史數據來預測未來是局限的,它會認為最后到來的數據是最可能被再次訪問的,從而給與它最高的優先級。這樣就意味着淘汰真正熱點數據,為了解決這個問題業界運用一些數據結構上的改進巧妙的解決這個問題。
舉個例子
- Mysql的緩存池,內部實現是一個LRU,但是其內部有個**中間點,**指向倒數3/8,一半是old區,另一半是young區,新數據插入是直接插入young區,這樣就保護了真正的老數據不會被沖刷掉。
- 多級隊列的形式
LFU結合頻率這一屬性給予更好的預測緩存數據是否在未來被使用。
但是傳統LFU有其局限性:
- LFU實現需要維護大而復雜的元數據(頻次統計數據等)
- 大多數實際工作負載中,訪問頻率隨着時間的推移而發生根本變化,而傳統LFU無法周期衰減頻率
傳統LFU的實現通過外接一個HashMap統計頻率,但是HashMap存在Hash沖突,這會導致頻率統計的不准確。
為了解決這些問題,Caffeine提出一種新的算法W-TinyLFU,它可以解決頻率統計不准確以及訪問頻率衰減問題。這個方法讓我們從空間、效率、以及適配矩陣的長寬引起的哈希碰撞的錯誤率上做權衡。
傳統Hash存在Hash沖突的問題,使用LFU算法時候記錄頻率的話一旦發生hash沖突可能造成頻率的統計錯誤。
W-TinyLFU算法使用一種Count-Min Sketch解決維護空間大的問題,類似布隆過濾器,降低沖突可能性,原理是多次hash分散開來,取最小值作為頻率,一次Hash沖突的幾率是1%的話,4次Hash的幾率就是1%的4次方,大大降低的沖突可能性。
常見的緩存數據淘汰算法
一個緩存組件是否好用,其中一個重要的指標就是他的緩存命中率,而命中率又和緩存組件本身的緩存數據淘汰算法息息相關,本文意在講解一些業界內常見的頁面置換算法,以及介紹下Caffeine Cache的W-TinyLFU算法,以便於更好的理解緩存組件。
1 FIFO
FIFO(First in First out)先進先出。可以理解為是一種類似隊列的算法實現
- 算法:最先進來的數據,被認為在未來被訪問的概率也是最低的,因此,當規定空間用盡且需要放入新數據的時候,會優先淘汰最早進來的數據
- 優點:最簡單、最公平的一種數據淘汰算法,邏輯簡單清晰,易於實現
- 缺點:這種算法邏輯設計所實現的緩存的命中率是比較低的,因為沒有任何額外邏輯能夠盡可能的保證常用數據不被淘汰掉
下面簡單演示了FIFO的工作過程,假設存放元素尺寸是3,且隊列已滿,放置元素順序如下圖所示,當來了一個新的數據“ldy”后,因為元素數量到達了閾值,則首先要進行太淘汰置換操作,然后加入新元素,操作如圖展示:
2 LRU
LRU(The Least Recently Used)最近最久未使用算法。相比於FIFO算法智能些
- 算法:如果一個數據最近很少被訪問到,那么被認為在未來被訪問的概率也是最低的,當規定空間用盡且需要放入新數據的時候,會優先淘汰最久未被訪問的數據
- 優點:LRU可以有效的對訪問比較頻繁的數據進行保護,也就是針對熱點數據的命中率提高有明顯的效果。
- 缺點:對於周期性、偶發性的訪問數據,有大概率可能造成緩存污染,也就是置換出去了熱點數據,把這些偶發性數據留下了,從而導致LRU的數據命中率急劇下降。 下圖展示了LRU簡單的工作過程,訪問時對數據的提前操作,以及數據滿且添加新數據的時候淘汰的過程的展示如下:
此處介紹的LRU是有明顯的缺點,如上所述,對於偶發性、周期性的數據沒有良好的抵抗力,很容易就造成緩存的污染,影響命中率,因此衍生出了很多的LRU算法的變種,用以處理這種偶發冷數據突增的場景,比如:LRU-K、Two Queues等,目的就是當判別數據為偶發或周期的冷數據時,不會存入空間內,從而降低熱數據的淘汰率。
下圖展示了LRU-K的簡單工作過程,簡單理解,LRU中的K是指數據被訪問K次,傳統LRU與此對比則可以認為傳統LRU是LRU-1。可以看到LRU-K有兩個隊列,新來的元素先進入到歷史訪問隊列中,該隊列用於記錄元素的訪問次數,采用的淘汰策略是LRU或者FIFO,當歷史隊列中的元素訪問次數達到K的時候,才會進入緩存隊列。 下圖展示了Two Queues的工作過程,與LRU-K相比,他也同樣是兩個隊列,不同之處在於,他的隊列一個是緩存隊列,一個是FIFO隊列,當新元素進來的時候,首先進入FIFO隊列,當該隊列中的元素被訪問的時候,會進入LRU隊列,過程如下:
3 LFU
LFU(The Least Frequently Used)最近很少使用算法,與LRU的區別在於LRU是以時間衡量,LFU是以時間段內的次數
- 算法:如果一個數據在一定時間內被訪問的次數很低,那么被認為在未來被訪問的概率也是最低的,當規定空間用盡且需要放入新數據的時候,會優先淘汰時間段內訪問次數最低的數據
- 優點:LFU也可以有效的保護緩存,相對場景來講,比LRU有更好的緩存命中率。因為是以次數為基准,所以更加准確,自然能有效的保證和提高命中率
- 缺點:因為LFU需要記錄數據的訪問頻率,因此需要額外的空間;當訪問模式改變的時候,算法命中率會急劇下降,這也是他最大弊端。
下面描述了LFU的簡單工作過程,首先是訪問元素增加元素的訪問次數,從而提高元素在隊列中的位置,降低淘汰優先級,后面是插入新元素的時候,因為隊列已經滿了,所以優先淘汰在一定時間間隔內訪問頻率最低的元素
4 W-TinyLFU
W-TinyLFU(Window Tiny Least Frequently Used)是對LFU的的優化和加強。
- 算法:當一個數據進來的時候,會進行篩選比較,進入W-LRU窗口隊列,以此應對流量突增,經過淘汰后進入過濾器,通過訪問訪問頻率判決是否進入緩存。如果一個數據最近被訪問的次數很低,那么被認為在未來被訪問的概率也是最低的,當規定空間用盡的時候,會優先淘汰最近訪問次數很低的數據;
- 優點:使用Count-Min Sketch算法存儲訪問頻率,極大的節省空間;定期衰減操作,應對訪問模式變化;並且使用window-lru機制能夠盡可能避免緩存污染的發生,在過濾器內部會進行篩選處理,避免低頻數據置換高頻數據。
- 缺點:是由谷歌工程師發明的一種算法,目前已知應用於Caffeine Cache組件里,應用不是很多。
關於Count-Min Sketch算法,可以看作是布隆過濾器的同源的算法,假如我們用一個hashmap來存儲每個元素的訪問次數,那這個量級是比較大的,並且hash沖突的時候需要做一定處理,否則數據會產生很大的誤差,Count-Min Sketch算法將一個hash操作,擴增為多個hash,這樣原來hash沖突的概率就降低了幾個等級,且當多個hash取得數據的時候,取最低值,也就是Count Min的含義所在。
下圖展示了Count-Min Sketch算法簡單的工作原理:
- 假設有四個hash函數,每當元素被訪問時,將進行次數加1;
- 此時會按照約定好的四個hash函數進行hash計算找到對應的位置,相應的位置進行+1操作;
- 當獲取元素的頻率時,同樣根據hash計算找到4個索引位置;
- 取得四個位置的頻率信息,然后根據Count Min取得最低值作為本次元素的頻率值返回,即Min(Count);
入門級使用
在 pom.xml 中添加 caffeine 依賴:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.5.5</version>
</dependency>
創建一個 Caffeine 緩存(類似一個map):
Cache<String, Object> manualCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
常見用法:
public static void main(String... args) throws Exception {
Cache<String, String> cache = Caffeine.newBuilder()
//5秒沒有讀寫自動刪除
.expireAfterAccess(5, TimeUnit.SECONDS)
//最大容量1024個,超過會自動清理空間
.maximumSize(1024)
.removalListener(((key, value, cause) -> {
//清理通知 key,value ==> 鍵值對 cause ==> 清理原因
}))
.build();
//添加值
cache.put("張三", "浙江");
//獲取值
cache.getIfPresent("張三");
//remove
cache.invalidate("張三");
}
填充策略(Population)
填充策略是指如何在key不存在的情況下,如何創建一個對象進行返回,主要分為下面四種
1 手動(Manual)
public static void main(String... args) throws Exception {
Cache<String, Integer> cache = Caffeine.newBuilder().build();
Integer age1 = cache.getIfPresent("張三");
System.out.println(age1);
//當key不存在時,會立即創建出對象來返回,age2不會為空
Integer age2 = cache.get("張三", k -> {
System.out.println("k:" + k);
return 18;
});
System.out.println(age2);
}
null
k:張三
18
Cache接口允許顯式的去控制緩存的檢索,更新和刪除。
我們可以通過cache.getIfPresent(key) 方法來獲取一個key的值,通過cache.put(key, value)方法顯示的將數控放入緩存,但是這樣子會覆蓋緩原來key的數據。更加建議使用cache.get(key,k - > value) 的方式,get 方法將一個參數為 key 的 Function (createExpensiveGraph) 作為參數傳入。如果緩存中不存在該鍵,則調用這個 Function 函數,並將返回值作為該緩存的值插入緩存中。get 方法是以阻塞方式執行調用,即使多個線程同時請求該值也只會調用一次Function方法。這樣可以避免與其他線程的寫入競爭,這也是為什么使用 get 優於 getIfPresent 的原因。
注意:如果調用該方法返回NULL(如上面的 createExpensiveGraph 方法),則cache.get返回null,如果調用該方法拋出異常,則get方法也會拋出異常。
可以使用Cache.asMap() 方法獲取ConcurrentMap進而對緩存進行一些更改。
2 自動(Loading)
public static void main(String... args) throws Exception {
//此時的類型是 LoadingCache 不是 Cache
LoadingCache<String, Integer> cache = Caffeine.newBuilder().build(key -> {
System.out.println("自動填充:" + key);
return 18;
});
Integer age1 = cache.getIfPresent("張三");
System.out.println(age1);
// key 不存在時 會根據給定的CacheLoader自動裝載進去
Integer age2 = cache.get("張三");
System.out.println(age2);
}
null
自動填充:張三
18
3 異步手動(Asynchronous Manual)
public static void main(String... args) throws Exception {
AsyncCache<String, Integer> cache = Caffeine.newBuilder().buildAsync();
//會返回一個 future對象, 調用future對象的get方法會一直卡住直到得到返回,和多線程的submit一樣
CompletableFuture<Integer> ageFuture = cache.get("張三", name -> {
System.out.println("name:" + name);
return 18;
});
Integer age = ageFuture.get();
System.out.println("age:" + age);
}
name:張三
age:18
4 異步自動(Asynchronously Loading)
public static void main(String... args) throws Exception {
//和1.4基本差不多
AsyncLoadingCache<String, Integer> cache = Caffeine.newBuilder().buildAsync(name -> {
System.out.println("name:" + name);
return 18;
});
CompletableFuture<Integer> ageFuture = cache.get("張三");
Integer age = ageFuture.get();
System.out.println("age:" + age);
}
驅逐策略(eviction)
Caffeine提供三類驅逐策略:基於大小(size-based),基於時間(time-based)和基於引用(reference-based)。
基於大小(size-based)
基於大小驅逐,有兩種方式:一種是基於緩存大小,一種是基於權重。
// Evict based on the number of entries in the cache
// 根據緩存的計數進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// Evict based on the number of vertices in the cache
// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐)
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) 函數來指定緩存最大權重值。
maximumWeight與maximumSize不可以同時使用。
基於時間(Time-based)
// Evict based on a fixed expiration policy
// 基於固定的到期策略進行退出
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));
// Evict based on a varying expiration policy
// 基於不同的到期策略進行退出
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));
Caffeine提供了三種定時驅逐策略:
- expireAfterAccess(long, TimeUnit):在最后一次訪問或者寫入后開始計時,在指定的時間后過期。假如一直有請求訪問該key,那么這個緩存將一直不會過期。
- expireAfterWrite(long, TimeUnit): 在最后一次寫入緩存后開始計時,在指定的時間后過期。
- expireAfter(Expiry): 自定義策略,過期時間由Expiry實現獨自計算。
緩存的刪除策略使用的是惰性刪除和定時刪除。這兩個刪除策略的時間復雜度都是O(1)。
測試定時驅逐不需要等到時間結束。我們可以使用Ticker接口和Caffeine.ticker(Ticker)方法在緩存生成器中指定時間源,而不必等待系統時鍾。如:
FakeTicker ticker = new FakeTicker(); // Guava's testlib
Cache<Key, Graph> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.executor(Runnable::run)
.ticker(ticker::read)
.maximumSize(10)
.build();
cache.put(key, graph);
ticker.advance(30, TimeUnit.MINUTES)
assertThat(cache.getIfPresent(key), is(nullValue());
基於引用(reference-based)
強引用,軟引用,弱引用概念說明請點擊連接,這里說一下各各引用的區別:
Java4種引用的級別由高到低依次為:強引用 > 軟引用 > 弱引用 > 虛引用
引用類型 | 被垃圾回收時間 | 用途 | 生存時間 |
---|---|---|---|
強引用 | 從來不會 | 對象的一般狀態 | JVM停止運行時終止 |
軟引用 | 在內存不足時 | 對象緩存 | 內存不足時終止 |
弱引用 | 在垃圾回收時 | 對象緩存 | gc運行后終止 |
虛引用 | Unknown | Unknown | Unknown |
// Evict when neither the key nor value are strongly reachable
// 當key和value都沒有引用時驅逐緩存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key -> createExpensiveGraph(key));
// Evict when the garbage collector needs to free memory
// 當垃圾收集器需要釋放內存時驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.softValues()
.build(key -> createExpensiveGraph(key));
我們可以將緩存的驅逐配置成基於垃圾回收器。為此,我們可以將key 和 value 配置為弱引用或只將值配置成軟引用。
注意:AsyncLoadingCache不支持弱引用和軟引用。
Caffeine.weakKeys() 使用弱引用存儲key。如果沒有其他地方對該key有強引用,那么該緩存就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個緩存使用身份 (==) 相等來比較 key,而不是使用 equals()。
Caffeine.weakValues() 使用弱引用存儲value。如果沒有其他地方對該value有強引用,那么該緩存就會被垃圾回收器回收。由於垃圾回收器只依賴於身份(identity)相等,因此這會導致整個緩存使用身份 (==) 相等來比較 key,而不是使用 equals()。
Caffeine.softValues() 使用軟引用存儲value。當內存滿了過后,軟引用的對象以將使用最近最少使用(least-recently-used ) 的方式進行垃圾回收。由於使用軟引用是需要等到內存滿了才進行回收,所以我們通常建議給緩存配置一個使用內存的最大值。 softValues() 將使用身份相等(identity) (==) 而不是equals() 來比較值。
注意:Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。
移除監聽器(Removal)
概念:
- 驅逐(eviction):由於滿足了某種驅逐策略,后台自動進行的刪除操作
- 無效(invalidation):表示由調用方手動刪除緩存
- 移除(removal):監聽驅逐或無效操作的監聽器
手動刪除緩存:
在任何時候,您都可能明確地使緩存無效,而不用等待緩存被驅逐。
// individual key
cache.invalidate(key)
// bulk keys
cache.invalidateAll(keys)
// all keys
cache.invalidateAll()
Removal 監聽器:
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.removalListener((Key key, Graph graph, RemovalCause cause) ->
System.out.printf("Key %s was removed (%s)%n", key, cause))
.build();
您可以通過Caffeine.removalListener(RemovalListener) 為緩存指定一個刪除偵聽器,以便在刪除數據時執行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。
刪除偵聽器的里面的操作是使用Executor來異步執行的。默認執行程序是ForkJoinPool.commonPool(),可以通過Caffeine.executor(Executor)覆蓋。當操作必須與刪除同步執行時,請改為使用CacheWrite,CacheWrite將在下面說明。
注意:由RemovalListener拋出的任何異常都會被記錄(使用Logger)並不會拋出。
刷新(Refresh)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
// 指定在創建緩存或者最近一次更新緩存后經過固定的時間間隔,刷新緩存
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
刷新和驅逐是不一樣的。刷新的是通過LoadingCache.refresh(key)方法來指定,並通過調用CacheLoader.reload方法來執行,刷新key會異步地為這個key加載新的value,並返回舊的值(如果有的話)。驅逐會阻塞查詢操作直到驅逐作完成才會進行其他操作。
與expireAfterWrite不同的是,refreshAfterWrite將在查詢數據的時候判斷該數據是不是符合查詢條件,如果符合條件該緩存就會去執行刷新操作。例如,您可以在同一個緩存中同時指定refreshAfterWrite和expireAfterWrite,只有當數據具備刷新條件的時候才會去刷新數據,不會盲目去執行刷新操作。如果數據在刷新后就一直沒有被再次查詢,那么該數據也會過期。
刷新操作是使用Executor異步執行的。默認執行程序是ForkJoinPool.commonPool(),可以通過Caffeine.executor(Executor)覆蓋。
如果刷新時引發異常,則使用log記錄日志,並不會拋出。
Writer
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.writer(new CacheWriter<Key, Graph>() {
@Override public void write(Key key, Graph graph) {
// write to storage or secondary cache
}
@Override public void delete(Key key, Graph graph, RemovalCause cause) {
// delete from storage or secondary cache
}
})
.build(key -> createExpensiveGraph(key));
CacheWriter允許緩存充當一個底層資源的代理,當與CacheLoader結合使用時,所有對緩存的讀寫操作都可以通過Writer進行傳遞。Writer可以把操作緩存和操作外部資源擴展成一個同步的原子性操作。並且在緩存寫入完成之前,它將會阻塞后續的更新緩存操作,但是讀取(get)將直接返回原有的值。如果寫入程序失敗,那么原有的key和value的映射將保持不變,如果出現異常將直接拋給調用者。
CacheWriter可以同步的監聽到緩存的創建、變更和刪除操作。加載(例如,LoadingCache.get)、重新加載(例如,LoadingCache.refresh)和計算(例如Map.computeIfPresent)的操作不被CacheWriter監聽到。
注意:CacheWriter不能與weakKeys或AsyncLoadingCache結合使用。
可能的用例(Possible Use-Cases)
CacheWriter是復雜工作流的擴展點,需要外部資源來觀察給定Key的變化順序。這些用法Caffeine是支持的,但不是本地內置。
寫模式(Write Modes)
CacheWriter可以用來實現一個直接寫(write-through )或回寫(write-back )緩存的操作。
write-through式緩存中,寫操作是一個同步的過程,只有寫成功了才會去更新緩存。這避免了同時去更新資源和緩存的條件競爭。
write-back式緩存中,對外部資源的操作是在緩存更新后異步執行的。這樣可以提高寫入的吞吐量,避免數據不一致的風險,比如如果寫入失敗,則在緩存中保留無效的狀態。這種方法可能有助於延遲寫操作,直到指定的時間,限制寫速率或批寫操作。
通過對write-back進行擴展,我們可以實現以下特性:
- 批處理和合並操作
- 延遲操作並到一個特定的時間執行
- 如果超過閾值大小,則在定期刷新之前執行批處理
- 如果操作尚未刷新,則從寫入后緩沖器(write-behind)加載
- 根據外部資源的特點,處理重審,速率限制和並發
可以參考一個簡單的例子,使用RxJava實現。
分層(Layering)
CacheWriter可能用來集成多個緩存進而實現多級緩存。
多級緩存的加載和寫入可以使用系統外部高速緩存。這允許緩存使用一個小並且快速的緩存去調用一個大的並且速度相對慢一點的緩存。典型的off-heap、file-based和remote 緩存。
受害者緩存(Victim Cache)是一個多級緩存的變體,其中被刪除的數據被寫入二級緩存。這個delete(K, V, RemovalCause) 方法允許檢查為什么該數據被刪除,並作出相應的操作。
同步監聽器(Synchronous Listeners)
同步監聽器會接收一個key在緩存中的進行了那些操作的通知。監聽器可以阻止緩存操作,也可以將事件排隊以異步的方式執行。這種類型的監聽器最常用於復制或構建分布式緩存。
統計(Statistics)
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();
使用Caffeine.recordStats(),您可以打開統計信息收集。Cache.stats() 方法返回提供統計信息的CacheStats,如:
- hitRate():返回命中與請求的比率
- hitCount(): 返回命中緩存的總數
- evictionCount():緩存逐出的數量
- averageLoadPenalty():加載新值所花費的平均時間
Cleanup
緩存的刪除策略使用的是惰性刪除和定時刪除,但是我也可以自己調用cache.cleanUp()方法手動觸發一次回收操作。cache.cleanUp()是一個同步方法。
策略(Policy)
在創建緩存的時候,緩存的策略就指定好了。但是我們可以在運行時可以獲得和修改該策略。這些策略可以通過一些選項來獲得,以此來確定緩存是否支持該功能。
Size-based
cache.policy().eviction().ifPresent(eviction -> {
eviction.setMaximum(2 * eviction.getMaximum());
});
如果緩存配置的時基於權重來驅逐,那么我們可以使用weightedSize() 來獲取當前權重。這與獲取緩存中的記錄數的Cache.estimatedSize() 方法有所不同。
緩存的最大值(maximum)或最大權重(weight)可以通過getMaximum()方法來讀取,並使用setMaximum(long)進行調整。當緩存量達到新的閥值的時候緩存才會去驅逐緩存。
如果有需用我們可以通過hottest(int) 和 coldest(int)方法來獲取最有可能命中的數據和最有可能驅逐的數據快照。
Time-based
cache.policy().expireAfterAccess().ifPresent(expiration -> ...);
cache.policy().expireAfterWrite().ifPresent(expiration -> ...);
cache.policy().expireVariably().ifPresent(expiration -> ...);
cache.policy().refreshAfterWrite().ifPresent(expiration -> ...);
ageOf(key,TimeUnit) 提供了從expireAfterAccess,expireAfterWrite或refreshAfterWrite策略的角度來看條目已經空閑的時間。最大持續時間可以從getExpiresAfter(TimeUnit)讀取,並使用setExpiresAfter(long,TimeUnit)進行調整。
如果有需用我們可以通過hottest(int) 和 coldest(int)方法來獲取最有可能命中的數據和最有可能驅逐的數據快照。
測試(Testing)
FakeTicker ticker = new FakeTicker(); // Guava's testlib
Cache<Key, Graph> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.executor(Runnable::run)
.ticker(ticker::read)
.maximumSize(10)
.build();
cache.put(key, graph);
ticker.advance(30, TimeUnit.MINUTES)
assertThat(cache.getIfPresent(key), is(nullValue());
測試的時候我們可以使用Caffeine..ticker(ticker)來指定一個時間源,並不需要等到key過期。
FakeTicker這個是guawa test包里面的Ticker,主要用於測試。依賴:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava-testlib</artifactId>
<version>23.5-jre</version>
</dependency>
常見問題(Faq)
固定數據(Pinning Entries)
固定數據是不能通過驅逐策略去將數據刪除的。當數據是一個有狀態的資源時(如鎖),那么這條數據是非常有用的,你有在客端使用完這個條數據的時候才能刪除該數據。在這種情況下如果驅逐策略將這個條數據刪掉的話,將導致資源泄露。
通過使用權重將該數據的權重設置成0,並且這個條數據不計入maximum size里面。 當緩存達到maximum size 了以后,驅逐策略也會跳過該條數據,不會進行刪除操作。我們還必須自定義一個標准來判斷這個數據是否屬於固定數據。
通過使用Long.MAX_VALUE(大約300年)的值作為key的有效時間,這樣可以將一條數據從過期中排除。自定義到期必須定義,這可以評估條目是否固定。
將數據寫入緩存時我們要指定該數據的權重和到期時間。這可以通過使用cache.asMap()獲取緩存列表后,再來實現引腳和解除綁定。
遞歸調用(Recursive Computations)
在原子操作內執行的加載,計算或回調可能不會寫入緩存。 ConcurrentHashMap不允許這些遞歸寫操作,因為這將有可能導致活鎖(Java 8)或IllegalStateException(Java 9)。
解決方法是異步執行這些操作,例如使用AsyncLoadingCache。在異步這種情況下映射已經建立,value是一個CompletableFuture,並且這些操作是在緩存的原子范圍之外執行的。但是,如果發生無序的依賴鏈,這也有可能導致死鎖。
示例代碼:
package com.xiaolyuh.controller;
import com.alibaba.fastjson.JSON;
import com.github.benmanes.caffeine.cache.*;
import com.google.common.testing.FakeTicker;
import com.xiaolyuh.entity.Person;
import com.xiaolyuh.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@RestController
public class CaffeineCacheController {
@Autowired
PersonService personService;
Cache<String, Object> manualCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.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));
private CompletableFuture<Object> createExpensiveGraphAsync(String key, Executor executor) {
CompletableFuture<Object> objectCompletableFuture = new CompletableFuture<>();
return objectCompletableFuture;
}
private Object createExpensiveGraph(String key) {
System.out.println("緩存不存在或過期,調用了createExpensiveGraph方法獲取緩存key的值");
if (key.equals("name")) {
throw new RuntimeException("調用了該方法獲取緩存key的值的時候出現異常");
}
return personService.findOne1();
}
@RequestMapping("/testManual")
public Object testManual(Person person) {
String key = "name1";
Object graph = null;
// 根據key查詢一個緩存,如果沒有返回NULL
graph = manualCache.getIfPresent(key);
// 根據Key查詢一個緩存,如果沒有調用createExpensiveGraph方法,並將返回值保存到緩存。
// 如果該方法返回Null則manualCache.get返回null,如果該方法拋出異常則manualCache.get拋出異常
graph = manualCache.get(key, k -> createExpensiveGraph(k));
// 將一個值放入緩存,如果以前有值就覆蓋以前的值
manualCache.put(key, graph);
// 刪除一個緩存
manualCache.invalidate(key);
ConcurrentMap<String, Object> map = manualCache.asMap();
System.out.println(map.toString());
return graph;
}
@RequestMapping("/testLoading")
public Object testLoading(Person person) {
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);
return graph;
}
@RequestMapping("/testAsyncLoading")
public Object testAsyncLoading(Person person) {
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();
return graph;
}
@RequestMapping("/testSizeBased")
public Object testSizeBased(Person person) {
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1)
.build(k -> createExpensiveGraph(k));
cache.get("A");
System.out.println(cache.estimatedSize());
cache.get("B");
// 因為執行回收的方法是異步的,所以需要調用該方法,手動觸發一次回收操作。
cache.cleanUp();
System.out.println(cache.estimatedSize());
return "";
}
@RequestMapping("/testTimeBased")
public Object testTimeBased(Person person) {
String key = "name1";
// 用戶測試,一個時間源,返回一個時間值,表示從某個固定但任意時間點開始經過的納秒數。
FakeTicker ticker = new FakeTicker();
// 基於固定的到期策略進行退出
// expireAfterAccess
LoadingCache<String, Object> cache1 = Caffeine.newBuilder()
.ticker(ticker::read)
.expireAfterAccess(5, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
System.out.println("expireAfterAccess:第一次獲取緩存");
cache1.get(key);
System.out.println("expireAfterAccess:等待4.9S后,第二次次獲取緩存");
// 直接指定時鍾
ticker.advance(4900, TimeUnit.MILLISECONDS);
cache1.get(key);
System.out.println("expireAfterAccess:等待0.101S后,第三次次獲取緩存");
ticker.advance(101, TimeUnit.MILLISECONDS);
cache1.get(key);
// expireAfterWrite
LoadingCache<String, Object> cache2 = Caffeine.newBuilder()
.ticker(ticker::read)
.expireAfterWrite(5, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
System.out.println("expireAfterWrite:第一次獲取緩存");
cache2.get(key);
System.out.println("expireAfterWrite:等待4.9S后,第二次次獲取緩存");
ticker.advance(4900, TimeUnit.MILLISECONDS);
cache2.get(key);
System.out.println("expireAfterWrite:等待0.101S后,第三次次獲取緩存");
ticker.advance(101, TimeUnit.MILLISECONDS);
cache2.get(key);
// Evict based on a varying expiration policy
// 基於不同的到期策略進行退出
LoadingCache<String, Object> cache3 = Caffeine.newBuilder()
.ticker(ticker::read)
.expireAfter(new Expiry<String, Object>() {
@Override
public long expireAfterCreate(String key, Object value, long currentTime) {
// Use wall clock time, rather than nanotime, if from an external resource
return TimeUnit.SECONDS.toNanos(5);
}
@Override
public long expireAfterUpdate(String key, Object graph,
long currentTime, long currentDuration) {
System.out.println("調用了 expireAfterUpdate:" + TimeUnit.NANOSECONDS.toMillis(currentDuration));
return currentDuration;
}
@Override
public long expireAfterRead(String key, Object graph,
long currentTime, long currentDuration) {
System.out.println("調用了 expireAfterRead:" + TimeUnit.NANOSECONDS.toMillis(currentDuration));
return currentDuration;
}
})
.build(k -> createExpensiveGraph(k));
System.out.println("expireAfter:第一次獲取緩存");
cache3.get(key);
System.out.println("expireAfter:等待4.9S后,第二次次獲取緩存");
ticker.advance(4900, TimeUnit.MILLISECONDS);
cache3.get(key);
System.out.println("expireAfter:等待0.101S后,第三次次獲取緩存");
ticker.advance(101, TimeUnit.MILLISECONDS);
Object object = cache3.get(key);
return object;
}
@RequestMapping("/testRemoval")
public Object testRemoval(Person person) {
String key = "name1";
// 用戶測試,一個時間源,返回一個時間值,表示從某個固定但任意時間點開始經過的納秒數。
FakeTicker ticker = new FakeTicker();
// 基於固定的到期策略進行退出
// expireAfterAccess
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.removalListener((String k, Object graph, RemovalCause cause) ->
System.out.printf("Key %s was removed (%s)%n", k, cause))
.ticker(ticker::read)
.expireAfterAccess(5, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
System.out.println("第一次獲取緩存");
Object object = cache.get(key);
System.out.println("等待6S后,第二次次獲取緩存");
// 直接指定時鍾
ticker.advance(6000, TimeUnit.MILLISECONDS);
cache.get(key);
System.out.println("手動刪除緩存");
cache.invalidate(key);
return object;
}
@RequestMapping("/testRefresh")
public Object testRefresh(Person person) {
String key = "name1";
// 用戶測試,一個時間源,返回一個時間值,表示從某個固定但任意時間點開始經過的納秒數。
FakeTicker ticker = new FakeTicker();
// 基於固定的到期策略進行退出
// expireAfterAccess
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.removalListener((String k, Object graph, RemovalCause cause) ->
System.out.printf("執行移除監聽器- Key %s was removed (%s)%n", k, cause))
.ticker(ticker::read)
.expireAfterWrite(5, TimeUnit.SECONDS)
// 指定在創建緩存或者最近一次更新緩存后經過固定的時間間隔,刷新緩存
.refreshAfterWrite(4, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
System.out.println("第一次獲取緩存");
Object object = cache.get(key);
System.out.println("等待4.1S后,第二次次獲取緩存");
// 直接指定時鍾
ticker.advance(4100, TimeUnit.MILLISECONDS);
cache.get(key);
System.out.println("等待5.1S后,第三次次獲取緩存");
// 直接指定時鍾
ticker.advance(5100, TimeUnit.MILLISECONDS);
cache.get(key);
return object;
}
@RequestMapping("/testWriter")
public Object testWriter(Person person) {
String key = "name1";
// 用戶測試,一個時間源,返回一個時間值,表示從某個固定但任意時間點開始經過的納秒數。
FakeTicker ticker = new FakeTicker();
// 基於固定的到期策略進行退出
// expireAfterAccess
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.removalListener((String k, Object graph, RemovalCause cause) ->
System.out.printf("執行移除監聽器- Key %s was removed (%s)%n", k, cause))
.ticker(ticker::read)
.expireAfterWrite(5, TimeUnit.SECONDS)
.writer(new CacheWriter<String, Object>() {
@Override
public void write(String key, Object graph) {
// write to storage or secondary cache
// 寫入存儲或者二級緩存
System.out.printf("testWriter:write - Key %s was write (%s)%n", key, graph);
createExpensiveGraph(key);
}
@Override
public void delete(String key, Object graph, RemovalCause cause) {
// delete from storage or secondary cache
// 刪除存儲或者二級緩存
System.out.printf("testWriter:delete - Key %s was delete (%s)%n", key, graph);
}
})
// 指定在創建緩存或者最近一次更新緩存后經過固定的時間間隔,刷新緩存
.refreshAfterWrite(4, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
cache.put(key, personService.findOne1());
cache.invalidate(key);
System.out.println("第一次獲取緩存");
Object object = cache.get(key);
System.out.println("等待4.1S后,第二次次獲取緩存");
// 直接指定時鍾
ticker.advance(4100, TimeUnit.MILLISECONDS);
cache.get(key);
System.out.println("等待5.1S后,第三次次獲取緩存");
// 直接指定時鍾
ticker.advance(5100, TimeUnit.MILLISECONDS);
cache.get(key);
return object;
}
@RequestMapping("/testStatistics")
public Object testStatistics(Person person) {
String key = "name1";
// 用戶測試,一個時間源,返回一個時間值,表示從某個固定但任意時間點開始經過的納秒數。
FakeTicker ticker = new FakeTicker();
// 基於固定的到期策略進行退出
// expireAfterAccess
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.removalListener((String k, Object graph, RemovalCause cause) ->
System.out.printf("執行移除監聽器- Key %s was removed (%s)%n", k, cause))
.ticker(ticker::read)
.expireAfterWrite(5, TimeUnit.SECONDS)
// 開啟統計
.recordStats()
// 指定在創建緩存或者最近一次更新緩存后經過固定的時間間隔,刷新緩存
.refreshAfterWrite(4, TimeUnit.SECONDS)
.build(k -> createExpensiveGraph(k));
for (int i = 0; i < 10; i++) {
cache.get(key);
cache.get(key + i);
}
// 驅逐是異步操作,所以這里要手動觸發一次回收操作
ticker.advance(5100, TimeUnit.MILLISECONDS);
// 手動觸發一次回收操作
cache.cleanUp();
System.out.println("緩存命數量:" + cache.stats().hitCount());
System.out.println("緩存命中率:" + cache.stats().hitRate());
System.out.println("緩存逐出的數量:" + cache.stats().evictionCount());
System.out.println("加載新值所花費的平均時間:" + cache.stats().averageLoadPenalty());
return cache.get(key);
}
@RequestMapping("/testPolicy")
public Object testPolicy(Person person) {
FakeTicker ticker = new FakeTicker();
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.ticker(ticker::read)
.expireAfterAccess(5, TimeUnit.SECONDS)
.maximumSize(1)
.build(k -> createExpensiveGraph(k));
// 在代碼里面動態的指定最大Size
cache.policy().eviction().ifPresent(eviction -> {
eviction.setMaximum(4 * eviction.getMaximum());
});
cache.get("E");
cache.get("B");
cache.get("C");
cache.cleanUp();
System.out.println(cache.estimatedSize() + ":" + JSON.toJSON(cache.asMap()).toString());
cache.get("A");
ticker.advance(100, TimeUnit.MILLISECONDS);
cache.get("D");
ticker.advance(100, TimeUnit.MILLISECONDS);
cache.get("A");
ticker.advance(100, TimeUnit.MILLISECONDS);
cache.get("B");
ticker.advance(100, TimeUnit.MILLISECONDS);
cache.policy().eviction().ifPresent(eviction -> {
// 獲取熱點數據Map
Map<String, Object> hottestMap = eviction.hottest(10);
// 獲取冷數據Map
Map<String, Object> coldestMap = eviction.coldest(10);
System.out.println("熱點數據:" + JSON.toJSON(hottestMap).toString());
System.out.println("冷數據:" + JSON.toJSON(coldestMap).toString());
});
ticker.advance(3000, TimeUnit.MILLISECONDS);
// ageOf通過這個方法來查看key的空閑時間
cache.policy().expireAfterAccess().ifPresent(expiration -> {
System.out.println(JSON.toJSON(expiration.ageOf("A", TimeUnit.MILLISECONDS)));
});
return cache.get("name1");
}
}
參考: