1. 前言
互聯(lián)網(wǎng)軟件神速發(fā)展诡右,用戶的體驗(yàn)度是判斷一個(gè)軟件好壞的重要原因,所以緩存就是必不可少的一個(gè)神器诺祸。在多線程高并發(fā)場(chǎng)景中往往是離不開(kāi)cache的揣钦,需要根據(jù)不同的應(yīng)用場(chǎng)景來(lái)需要選擇不同的cache获询,比如分布式緩存如redis涨岁、memcached,還有本地(進(jìn)程內(nèi))緩存如ehcache吉嚣、GuavaCache梢薪、Caffeine。
說(shuō)起Guava Cache尝哆,很多人都不會(huì)陌生秉撇,它是Google Guava工具包中的一個(gè)非常方便易用的本地化緩存實(shí)現(xiàn),基于LRU算法實(shí)現(xiàn),支持多種緩存過(guò)期策略琐馆。由于Guava的大量使用规阀,Guava Cache也得到了大量的應(yīng)用。但是瘦麸,Guava Cache的性能一定是最好的嗎谁撼?也許,曾經(jīng)滋饲,它的性能是非常不錯(cuò)的厉碟。但所謂長(zhǎng)江后浪推前浪,總會(huì)有更加優(yōu)秀的技術(shù)出現(xiàn)屠缭。今天箍鼓,我就來(lái)介紹一個(gè)比Guava Cache性能更高的緩存框架:Caffeine。
2. 比較
Google Guava工具包中的一個(gè)非常方便易用的本地化緩存實(shí)現(xiàn)呵曹,基于LRU算法實(shí)現(xiàn)款咖,支持多種緩存過(guò)期策略。
EhCache 是一個(gè)純Java的進(jìn)程內(nèi)緩存框架逢并,具有快速之剧、精干等特點(diǎn),是Hibernate中默認(rèn)的CacheProvider砍聊。
Caffeine是使用Java8對(duì)Guava緩存的重寫(xiě)版本背稼,在Spring Boot 2.0中將取代,基于LRU算法實(shí)現(xiàn)玻蝌,支持多種緩存過(guò)期策略蟹肘。
2.1 官方性能比較
場(chǎng)景1:8個(gè)線程讀,100%的讀操作
場(chǎng)景二:6個(gè)線程讀俯树,2個(gè)線程寫(xiě)帘腹,也就是75%的讀操作,25%的寫(xiě)操作
場(chǎng)景三:8個(gè)線程寫(xiě)许饿,100%的寫(xiě)操作
可以清楚的看到Caffeine效率明顯的高于其他緩存阳欲。
3. 如何使用
public static void main(String[] args) {
LoadingCache<String, String> build = CacheBuilder.newBuilder().initialCapacity(1).maximumSize(100).expireAfterWrite(1, TimeUnit.DAYS)
.build(new CacheLoader<String, String>() {
//默認(rèn)的數(shù)據(jù)加載實(shí)現(xiàn),當(dāng)調(diào)用get取值的時(shí)候陋率,如果key沒(méi)有對(duì)應(yīng)的值球化,就調(diào)用這個(gè)方法進(jìn)行加載
@Override
public String load(String key) {
return "";
}
});
}
參數(shù)方法
- initialCapacity(1) 初始緩存長(zhǎng)度為1
- maximumSize(100) 最大長(zhǎng)度為100
- expireAfterWrite(1, TimeUnit.DAYS) 設(shè)置緩存策略在1天未寫(xiě)入過(guò)期緩存(后面講緩存策略)
4. 過(guò)期策略
在Caffeine中分為兩種緩存,一個(gè)是有界緩存瓦糟,一個(gè)是無(wú)界緩存筒愚,無(wú)界緩存不需要過(guò)期并且沒(méi)有界限。在有界緩存中提供了三個(gè)過(guò)期API:
- expireAfterWrite:代表著寫(xiě)了之后多久過(guò)期菩浙。(上面列子就是這種方式)
- expireAfterAccess: 代表著最后一次訪問(wèn)了之后多久過(guò)期巢掺。
- expireAfter:在expireAfter中需要自己實(shí)現(xiàn)Expiry接口句伶,這個(gè)接口支持create,update,以及access了之后多久過(guò)期。注意這個(gè)API和前面兩個(gè)API是互斥的陆淀。這里和前面兩個(gè)API不同的是考余,需要你告訴緩存框架,他應(yīng)該在具體的某個(gè)時(shí)間過(guò)期倔约,也就是通過(guò)前面的重寫(xiě)create,update,以及access的方法秃殉,獲取具體的過(guò)期時(shí)間。
4. 更新策略
何為更新策略浸剩?就是在設(shè)定多長(zhǎng)時(shí)間后會(huì)自動(dòng)刷新緩存钾军。
Caffeine提供了refreshAfterWrite()方法來(lái)讓我們進(jìn)行寫(xiě)后多久更新策略:
LoadingCache<String, String> build = CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "";
}
});
}
上面的代碼我們需要建立一個(gè)CacheLodaer來(lái)進(jìn)行刷新,這里是同步進(jìn)行的,可以通過(guò)buildAsync方法進(jìn)行異步構(gòu)建绢要。在實(shí)際業(yè)務(wù)中這里可以把我們代碼中的mapper傳入進(jìn)去吏恭,進(jìn)行數(shù)據(jù)源的刷新。
但是實(shí)際使用中重罪,你設(shè)置了一天刷新樱哼,但是一天后你發(fā)現(xiàn)緩存并沒(méi)有刷新。這是因?yàn)楸赜性?天后這個(gè)緩存再次訪問(wèn)才能刷新剿配,如果沒(méi)人訪問(wèn)搅幅,那么永遠(yuǎn)也不會(huì)刷新。你明白了嗎呼胚?
我們來(lái)看看自動(dòng)刷新他是怎么做的呢茄唐?自動(dòng)刷新只存在讀操作之后,也就是我們afterRead()這個(gè)方法蝇更,其中有個(gè)方法叫refreshIfNeeded沪编,他會(huì)根據(jù)你是同步還是異步然后進(jìn)行刷新處理。
5. 填充策略(Population)
Caffeine 為我們提供了三種填充策略:手動(dòng)年扩、同步和異步
5.1 手動(dòng)加載(Manual)
// 初始化緩存
Cache<String, Object> manualCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
String key = "name1";
// 根據(jù)key查詢一個(gè)緩存蚁廓,如果沒(méi)有返回NULL
graph = manualCache.getIfPresent(key);
// 如果緩存中不存在該鍵,createExpensiveGraph函數(shù)將用于提供回退值厨幻,該值在計(jì)算后插入緩存中
graph = manualCache.get(key, k -> createExpensiveGraph(k));
// 使用 put 方法手動(dòng)填充緩存相嵌,如果以前有值就覆蓋以前的值
manualCache.put(key, graph);
// 刪除一個(gè)緩存
manualCache.invalidate(key);
ConcurrentMap<String, Object> map = manualCache.asMap();
cache.invalidate(key);
Cache接口允許顯式的去控制緩存的檢索,更新和刪除况脆。
我們可以通過(guò)cache.getIfPresent(key) 方法來(lái)獲取一個(gè)key的值平绩,通過(guò)cache.put(key, value)方法顯示的將數(shù)控放入緩存,但是這樣子會(huì)覆蓋緩原來(lái)key的數(shù)據(jù)漠另。更加建議使用cache.get(key,k - > value) 的方式跃赚,get 方法將一個(gè)參數(shù)為 key 的 Function (createExpensiveGraph) 作為參數(shù)傳入笆搓。如果緩存中不存在該鍵性湿,則調(diào)用這個(gè) Function 函數(shù),并將返回值作為該緩存的值插入緩存中满败。get 方法是以阻塞方式執(zhí)行調(diào)用肤频,即使多個(gè)線程同時(shí)請(qǐng)求該值也只會(huì)調(diào)用一次Function方法。這樣可以避免與其他線程的寫(xiě)入競(jìng)爭(zhēng)算墨,這也是為什么使用 get 優(yōu)于 getIfPresent 的原因宵荒。
注意:如果調(diào)用該方法返回NULL(如上面的 createExpensiveGraph 方法),則cache.get返回null净嘀,如果調(diào)用該方法拋出異常报咳,則get方法也會(huì)拋出異常⊥诓兀可以使用Cache.asMap() 方法獲取ConcurrentMap進(jìn)而對(duì)緩存進(jìn)行一些更改暑刃。
5.2 同步加載(Loading)
// 初始化緩存
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
String key = "name1";
// 采用同步方式去獲取一個(gè)緩存和上面的手動(dòng)方式是一個(gè)原理。在build Cache的時(shí)候會(huì)提供一個(gè)createExpensiveGraph函數(shù)膜眠。
// 查詢并在缺失的情況下使用同步的方式來(lái)構(gòu)建一個(gè)緩存
Object graph = loadingCache.get(key);
// 獲取組key的值返回一個(gè)Map
List<String> keys = new ArrayList<>();
keys.add(key);
Map<String, Object> graphs = loadingCache.getAll(keys);
LoadingCache是使用CacheLoader來(lái)構(gòu)建的緩存的值岩臣。批量查找可以使用getAll方法,默認(rèn)情況下宵膨,getAll將會(huì)對(duì)緩存中沒(méi)有值的key分別調(diào)用CacheLoader.load方法來(lái)構(gòu)建緩存的值架谎。我們可以重寫(xiě)CacheLoader.loadAll方法來(lái)提高getAll的效率。
注意:您可以編寫(xiě)一個(gè)CacheLoader.loadAll來(lái)實(shí)現(xiàn)為特別請(qǐng)求的key加載值辟躏。例如谷扣,如果計(jì)算某個(gè)組中的任何鍵的值將為該組中的所有鍵提供值,則loadAll可能會(huì)同時(shí)加載該組的其余部分鸿脓。
5.3 異步加載(Asynchronously Loading)
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));
String key = "name1";
// 查詢并在缺失的情況下使用異步的方式來(lái)構(gòu)建緩存
CompletableFuture<Object> graph = asyncLoadingCache.get(key);
// 查詢一組緩存并在缺失的情況下使用異步的方式來(lái)構(gòu)建緩存
List<String> keys = new ArrayList<>();
keys.add(key);
CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);
// 異步轉(zhuǎn)同步
loadingCache = asyncLoadingCache.synchronous();
AsyncLoadingCache是繼承自LoadingCache類的抑钟,異步加載使用Executor去調(diào)用方法并返回一個(gè)CompletableFuture。異步加載緩存使用了響應(yīng)式編程模型野哭。
如果要以同步方式調(diào)用時(shí)在塔,應(yīng)提供CacheLoader。要以異步表示時(shí)拨黔,應(yīng)該提供一個(gè)AsyncCacheLoader蛔溃,并返回一個(gè)CompletableFuture。
synchronous()這個(gè)方法返回了一個(gè)LoadingCacheView視圖篱蝇,LoadingCacheView也繼承自LoadingCache贺待。調(diào)用該方法后就相當(dāng)于你將一個(gè)異步加載的緩存AsyncLoadingCache轉(zhuǎn)換成了一個(gè)同步加載的緩存LoadingCache。
默認(rèn)使用ForkJoinPool.commonPool()來(lái)執(zhí)行異步線程零截,但是我們可以通過(guò)Caffeine.executor(Executor) 方法來(lái)替換線程池麸塞。
6. 驅(qū)逐策略(eviction)
緩存的驅(qū)逐策略是為了預(yù)測(cè)哪些數(shù)據(jù)在短期內(nèi)最可能被再次用到,從而提升緩存的命中率涧衙。LRU(Least Recently Used)策略或許是最流行的驅(qū)逐策略哪工。但LRU通過(guò)歷史數(shù)據(jù)來(lái)預(yù)測(cè)未來(lái)是局限的奥此,它會(huì)認(rèn)為最后到來(lái)的數(shù)據(jù)是最可能被再次訪問(wèn)的,從而給與它最高的優(yōu)先級(jí)雁比。
Caffeine提供三類驅(qū)逐策略:基于大兄苫ⅰ(size-based),基于時(shí)間(time-based)和基于引用(reference-based)偎捎。
6.1 基于大写乐铡(size-based)
基于大小驅(qū)逐,有兩種方式:一種是基于緩存大小茴她,一種是基于權(quán)重寻拂。
// 根據(jù)緩存的計(jì)數(shù)進(jìn)行驅(qū)逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// 根據(jù)緩存的權(quán)重來(lái)進(jìn)行驅(qū)逐(權(quán)重只是用于確定緩存大小,不會(huì)用于決定該緩存是否被驅(qū)逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((Key key, Graph graph) -> graph.vertices().size())
.build(key -> createExpensiveGraph(key));
我們可以使用Caffeine.maximumSize(long)方法來(lái)指定緩存的最大容量败京。當(dāng)緩存超出這個(gè)容量的時(shí)候兜喻,會(huì)使用Window TinyLfu策略來(lái)刪除緩存。我們也可以使用權(quán)重的策略來(lái)進(jìn)行驅(qū)逐赡麦,可以使用Caffeine.weigher(Weigher) 函數(shù)來(lái)指定權(quán)重朴皆,使用Caffeine.maximumWeight(long) 函數(shù)來(lái)指定緩存最大權(quán)重值。
讓我們看看如何計(jì)算緩存中的對(duì)象泛粹。當(dāng)緩存初始化時(shí)遂铡,其大小等于零:
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.maximumSize(1)
.build(k -> DataObject.get("Data for " + k));
assertEquals(0, cache.estimatedSize());
當(dāng)我們添加一個(gè)值時(shí),大小明顯增加:
cache.get("A");
assertEquals(1, cache.estimatedSize());
我們可以將第二個(gè)值添加到緩存中晶姊,這導(dǎo)致第一個(gè)值被刪除:
cache.get("B");
assertEquals(1, cache.estimatedSize());
注意:maximumWeight與maximumSize不可以同時(shí)使用扒接。
6.2 基于時(shí)間(Time-based)
// 基于固定的到期策略進(jìn)行退出
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));
// 要初始化自定義策略,我們需要實(shí)現(xiàn) Expiry 接口
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));
6.3 基于引用(reference-based)
強(qiáng)引用们衙,軟引用钾怔,弱引用概念說(shuō)明請(qǐng)點(diǎn)擊連接,這里說(shuō)一下各各引用的區(qū)別:
// 當(dāng)key和value都沒(méi)有引用時(shí)驅(qū)逐緩存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key -> createExpensiveGraph(key));
// 當(dāng)垃圾收集器需要釋放內(nèi)存時(shí)驅(qū)逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.softValues()
.build(key -> createExpensiveGraph(key));
我們可以將緩存的驅(qū)逐配置成基于垃圾回收器蒙挑。當(dāng)沒(méi)有任何對(duì)對(duì)象的強(qiáng)引用時(shí)宗侦,使用 WeakRefence 可以啟用對(duì)象的垃圾收回收。SoftReference 允許對(duì)象根據(jù) JVM 的全局最近最少使用(Least-Recently-Used)的策略進(jìn)行垃圾回收忆蚀。
注意:AsyncLoadingCache不支持弱引用和軟引用矾利。
7. 移除監(jiān)聽(tīng)器(Removal)
如果我們需要在緩存被移除的時(shí)候,得到通知產(chǎn)生回調(diào)馋袜,并做一些額外處理工作男旗。這個(gè)時(shí)候RemovalListener就派上用場(chǎng)了。
7.1 概念
驅(qū)逐(eviction):由于滿足了某種驅(qū)逐策略欣鳖,后臺(tái)自動(dòng)進(jìn)行的刪除操作
無(wú)效(invalidation):表示由調(diào)用方手動(dòng)刪除緩存
移除(removal):監(jiān)聽(tīng)驅(qū)逐或無(wú)效操作的監(jiān)聽(tīng)器
手動(dòng)刪除緩存:在任何時(shí)候察皇,您都可能明確地使緩存無(wú)效,而不用等待緩存被驅(qū)逐泽台。
// individual key
cache.invalidate(key)
// bulk keys
cache.invalidateAll(keys)
// all keys
cache.invalidateAll()
7.2 Removal 監(jiān)聽(tīng)器
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();
您可以通過(guò)Caffeine.removalListener(RemovalListener) 為緩存指定一個(gè)刪除偵聽(tīng)器什荣,以便在刪除數(shù)據(jù)時(shí)執(zhí)行某些操作呀忧。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)溃睹。
刪除偵聽(tīng)器的里面的操作是使用Executor來(lái)異步執(zhí)行的。默認(rèn)執(zhí)行程序是ForkJoinPool.commonPool()胰坟,可以通過(guò)Caffeine.executor(Executor)覆蓋因篇。當(dāng)操作必須與刪除同步執(zhí)行時(shí),請(qǐng)改為使用CacheWrite笔横,CacheWrite將在下面說(shuō)明竞滓。
注意:由RemovalListener拋出的任何異常都會(huì)被記錄(使用Logger)并不會(huì)拋出。
7.3 移除監(jiān)聽(tīng)器應(yīng)用
public class Main {
// 創(chuàng)建一個(gè)監(jiān)聽(tīng)器
private static class MyRemovalListener implements RemovalListener<Integer, Integer> {
@Override
public void onRemoval(RemovalNotification<Integer, Integer> notification) {
String tips = String.format("key=%s,value=%s,reason=%s", notification.getKey(), notification.getValue(), notification.getCause());
System.out.println(tips);
}
}
public static void main(String[] args) {
// 創(chuàng)建一個(gè)帶有RemovalListener監(jiān)聽(tīng)的緩存
Cache<Integer, Integer> cache = CacheBuilder.newBuilder().removalListener(new MyRemovalListener()).build();
cache.put(1, 1);
// 手動(dòng)清除
cache.invalidate(1);
System.out.println(cache.getIfPresent(1)); // null
}
}
使用invalidate()清除緩存數(shù)據(jù)之后吹缔,注冊(cè)的回調(diào)被觸發(fā)了
8. 統(tǒng)計(jì)(Statistics)
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();
使用Caffeine.recordStats()商佑,您可以打開(kāi)統(tǒng)計(jì)信息收集。Cache.stats() 方法返回提供統(tǒng)計(jì)信息的CacheStats厢塘,如:
- hitRate():返回命中與請(qǐng)求的比率
- hitCount(): 返回命中緩存的總數(shù)
- evictionCount():緩存逐出的數(shù)量
- averageLoadPenalty():加載新值所花費(fèi)的平均時(shí)間