原文
使用Guava cache構(gòu)建本地緩存 - sameLuo的個人空間 - OSCHINA
Guava Cache -- Java 應用緩存神器 - 云+社區(qū) - 騰訊云
最近需要用到緩存來存放臨時數(shù)據(jù),又不想采用Redis芦鳍,Java自帶的Map功能太少档插,發(fā)現(xiàn)Google的Guava提供的Cache模塊功能很強大算灸,于是選擇使用它。
本地緩存
本地緩存作用就是提高系統(tǒng)的運行速度巧婶,是一種空間換時間的取舍缸血。它實質(zhì)上是一個做key-value查詢的字典物蝙,但是相對于我們常用HashMap它又有以下特點:
1.并發(fā)性:由于目前的應用大都是多線程的澜汤,所以緩存需要支持并發(fā)的寫入蚜迅。
2.過期策略:在某些場景中,我們可能會希望緩存的數(shù)據(jù)有一定“保質(zhì)期”俊抵,過期策略可以固定時間谁不,例如緩存寫入10分鐘后過期。也可以是相對時間徽诲,例如10分鐘內(nèi)未訪問則使緩存過期(類似于servlet中的session)拍谐。在java中甚至可以使用軟引用,弱引用的過期策略馏段。
3.淘汰策略:由于本地緩存是存放在內(nèi)存中,我們往往需要設置一個容量上限和淘汰策略來防止出現(xiàn)內(nèi)存溢出的情況践瓷。
緩存應當具備的屬性為:
1院喜、能夠配置緩存的大小,保持可控的Memory晕翠。
2处硬、適應多種場景的數(shù)據(jù)expire策略齐婴。
3、在高并發(fā)情況下、能夠正常緩存的更新以及返回默穴。
Guava Cache適用于:
你愿意消耗一些內(nèi)存空間來提升速度。
你預料到某些鍵會被查詢一次以上趴生。
緩存中存放的數(shù)據(jù)總量不會超出內(nèi)存容量
緩存的最大容量與淘汰策略
由于本地緩存是將計算結(jié)果緩存到內(nèi)存中短蜕,所以我們往往需要設置一個最大容量來防止出現(xiàn)內(nèi)存溢出的情況。這個容量可以是緩存對象的數(shù)量碌尔,也可以是一個具體的內(nèi)存大小浇辜。在Guva中僅支持設置緩存對象的數(shù)量。
當緩存數(shù)量逼近或大于我們所設置的最大容量時唾戚,為了將緩存數(shù)量控制在我們所設定的閾值內(nèi)柳洋,就需要丟棄掉一些數(shù)據(jù)。由于緩存的最大容量恒定叹坦,為了提高緩存的命中率熊镣,我們需要盡量丟棄那些我們之后不再經(jīng)常訪問的數(shù)據(jù),保留那些即將被訪問的數(shù)據(jù)。為了達到以上目的绪囱,我們往往會制定一些緩存淘汰策略测蹲,常用的緩存淘汰策略有以下幾種:
1.FIFO:First In First Out,先進先出毕箍。
一般采用隊列的方式實現(xiàn)弛房。這種淘汰策略僅僅是保證了緩存數(shù)量不超過我們所設置的閾值,而完全沒有考慮緩存的命中率而柑。所以在這種策略極少被使用文捶。
2.LRU:Least Recently Used,最近最少使用媒咳;
該算法其核心思想是“如果數(shù)據(jù)最近被訪問過粹排,那么將來被訪問的幾率也更高”。
所以該算法是淘汰最后一次使用時間離當前最久的緩存數(shù)據(jù)涩澡,保留最近訪問的數(shù)據(jù)顽耳。所以該種算法非常適合緩存“熱點數(shù)據(jù)”。
但是該算法在緩存周期性數(shù)據(jù)時妙同,就會出現(xiàn)緩存污染射富,也就是淘汰了即將訪問的數(shù)據(jù),反而把不常用的數(shù)據(jù)讀取到緩存中粥帚。
為了解決這個問題胰耗,后續(xù)也出現(xiàn)了如LRU-K,Two queues芒涡,Multi Queue等進階算法柴灯。
3.LFU:Least Frequently Used,最不經(jīng)常使用费尽。
該算法的核心思想是“如果數(shù)據(jù)在以前被訪問的次數(shù)最多赠群,那么將來被訪問的幾率就會更高”。所以該算法淘汰的是歷史訪問次數(shù)最少的數(shù)據(jù)旱幼。
一般情況下查描,LFU效率要優(yōu)于LRU,且能夠避免周期性或者偶發(fā)性的操作導致緩存命中率下降的問題柏卤。但LFU需要記錄數(shù)據(jù)的歷史訪問記錄叹誉,一旦數(shù)據(jù)訪問模式改變,LFU需要更長時間來適用新的訪問模式闷旧,即:LFU存在歷史數(shù)據(jù)影響將來數(shù)據(jù)的“緩存污染”效用长豁。
后續(xù)出現(xiàn)LFU*,LFU-Aging忙灼,Window-LFU等改進算法匠襟。
合理的使用淘汰算法能夠很明顯的提升緩存命中率钝侠,但是也不應該一味的追求命中率,而是應在命中率和資源消耗中找到一個平衡酸舍。
在guava中默認使用LRU淘汰算法帅韧,而且在不修改源碼的情況下也不支持自定義淘汰算法。
使用Guava構(gòu)建緩存
// 通過CacheBuilder構(gòu)建一個緩存實例
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(100) // 設置緩存的最大容量
.expireAfterWrite(1, TimeUnit.MINUTES) // 設置緩存在寫入一分鐘后失效
.concurrencyLevel(10) // 設置并發(fā)級別為10
.recordStats() // 開啟緩存統(tǒng)計
.build();
// 放入緩存
cache.put("key", "value");
// 獲取緩存
String value = cache.getIfPresent("key");
Guava的緩存有許多配置選項啃勉,所以為了簡化緩存的創(chuàng)建過程忽舟,使用了Builder設計模式
上面的代碼演示了使用Guava創(chuàng)建了一個基于內(nèi)存的本地緩存,并指定了一些緩存的參數(shù)淮阐,如緩存容量叮阅、緩存過期時間、并發(fā)級別等泣特,隨后通過put方法放入一個緩存并使用getIfPresent來獲取它浩姥。
Cache與LoadingCache
使用CacheBuilder我們能構(gòu)建出兩種類型的cache,他們分別是Cache與LoadingCache状您。
Cache
Cache是通過CacheBuilder的build()方法構(gòu)建勒叠,它是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表達式來代替匿名內(nèi)部類
Object getValue = cache.get("k1", () -> {
return null;
});
LoadingCache
LoadingCache繼承自Cache弊决,在構(gòu)建LoadingCache時,需要通過CacheBuilder的build(CacheLoader<? super K1, V1> loader)方法構(gòu)建:
CacheBuilder.newBuilder()
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 緩存加載邏輯
...
}
});
LoadingCache幕垦,顧名思義,它能夠通過CacheLoader自發(fā)的加載緩存:
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");
緩存的并發(fā)級別
Guava提供了設置并發(fā)級別的api比驻,使得緩存支持并發(fā)的寫入和讀取该溯。同ConcurrentHashMap類似Guava cache的并發(fā)也是通過分離鎖實現(xiàn)。在一般情況下别惦,將并發(fā)級別設置為服務器cpu核心數(shù)是一個比較不錯的選擇狈茉。
CacheBuilder.newBuilder()
// 設置并發(fā)級別為cpu核心數(shù)
.concurrencyLevel(Runtime.getRuntime().availableProcessors())
.build();
緩存的初始容量
我們在構(gòu)建緩存時可以為緩存設置一個合理大小初始容量,由于Guava的緩存使用了分離鎖的機制掸掸,擴容的代價非常昂貴氯庆。所以合理的初始容量能夠減少緩存容器的擴容次數(shù)蹭秋。
CacheBuilder.newBuilder()
// 設置初始容量為100
.initialCapacity(100)
.build();
緩存的回收
在前文提到過,在構(gòu)建本地緩存時堤撵,我們應該指定一個最大容量來防止出現(xiàn)內(nèi)存溢出的情況仁讨。在guava中除了提供基于數(shù)量,和基于內(nèi)存容量兩種回收策略外实昨,還提供了基于引用的回收洞豁。
基于數(shù)量/容量的回收
基于最大數(shù)量的回收策略非常簡單,我們只需指定緩存的最大數(shù)量maximumSize即可荒给,maximumSize 設定了該緩存的最大存儲單位(key)個數(shù):
CacheBuilder.newBuilder()
.maximumSize(100) // 緩存數(shù)量上限為100
.build();
使用基于最大容量的的回收策略時丈挟,我們需要設置2個必要參數(shù):
maximumWeigh;用于指定最大容量锐墙,maximumWeight 是根據(jù)設定緩存數(shù)據(jù)的最大值礁哄。
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();
當緩存的最大數(shù)量/容量逼近或超過我們所設置的最大值時桐绒,Guava就會使用LRU算法對之前的緩存進行回收。
基于軟/弱引用的回收
基于引用的回收策略之拨,是java中獨有的茉继。在java中有對象自動回收機制,依據(jù)程序員創(chuàng)建對象的方式不同蚀乔,將對象由強到弱分為強引用烁竭、軟引用、弱引用吉挣、虛引用派撕。對于這幾種引用他們有以下區(qū)別:
強引用
強引用是使用最普遍的引用。如果一個對象具有強引用睬魂,那垃圾回收器絕不會回收它终吼。
Object o=new Object();
當內(nèi)存空間不足,垃圾回收器不會自動回收一個被引用的強引用對象氯哮,而是會直接拋出OutOfMemoryError錯誤际跪,使程序異常終止。
軟引用
相對于強引用喉钢,軟引用是一種不穩(wěn)定的引用方式姆打,如果一個對象具有軟引用,當內(nèi)存充足時肠虽,GC不會主動回收軟引用對象幔戏,而當內(nèi)存不足時軟引用對象就會被回收。
SoftReference<Object> softRef=new SoftReference<Object>(new Object()); // 軟引用
Object object = softRef.get(); // 獲取軟引用
使用軟引用能防止內(nèi)存泄露税课,增強程序的健壯性评抚。但是一定要做好null檢測豹缀。
弱引用
弱引用是一種比軟引用更不穩(wěn)定的引用方式,因為無論內(nèi)存是否充足慨代,弱引用對象都有可能被回收邢笙。
WeakReference<Object> weakRef = new WeakReference<Object>(new Object()); // 弱引用
Object obj = weakRef.get(); // 獲取弱引用
虛引用
而虛引用這種引用方式就是形同虛設,因為如果一個對象僅持有虛引用侍匙,那么它就和沒有任何引用一樣氮惯。在實踐中也幾乎沒有使用。
在Guava cache中支持想暗,軟/弱引用的緩存回收方式妇汗。使用這種方式能夠極大的提高內(nèi)存的利用率,并且不會出現(xiàn)內(nèi)存溢出的異常说莫。
CacheBuilder.newBuilder()
.weakKeys() // 使用弱引用存儲鍵杨箭。當鍵沒有其它(強或軟)引用時,該緩存可能會被回收储狭。
.weakValues() // 使用弱引用存儲值互婿。當值沒有其它(強或軟)引用時,該緩存可能會被回收辽狈。
.softValues() // 使用軟引用存儲值慈参。當內(nèi)存不足并且該值其它強引用引用時,該緩存就會被回收
.build();
通過軟/弱引用的回收方式刮萌,相當于將緩存回收任務交給了GC驮配,使得緩存的命中率變得十分的不穩(wěn)定,在非必要的情況下着茸,還是推薦基于數(shù)量和容量的回收壮锻。
顯式回收
在緩存構(gòu)建完畢后,我們可以通過Cache提供的接口涮阔,顯式的對緩存進行回收猜绣,例如:
- 個別清除:Cache.invalidate(key)
- 批量清除:Cache.invalidateAll(keys)
- 清除所有緩存項:Cache.invalidateAll()
// 構(gòu)建一個緩存
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();
移除監(jiān)聽器
通過CacheBuilder.removalListener(RemovalListener)澎语,你可以聲明一個監(jiān)聽器途事,以便緩存項被移除時做一些額外操作验懊。緩存項被移除時擅羞,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);
警告:默認情況下娃承,監(jiān)聽器方法是在移除緩存時同步調(diào)用的奏夫。因為緩存的維護和請求響應通常是同時進行的,代價高昂的監(jiān)聽器方法在同步模式下會拖慢正常的緩存請求历筝。在這種情況下酗昼,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)把監(jiān)聽器裝飾為異步操作。
緩存的過期策略與刷新
Guava也提供了緩存的過期策略和刷新策略梳猪。
緩存過期策略
緩存的過期策略分為固定時間和相對時間麻削。
固定時間一般是指寫入后多長時間過期,例如我們構(gòu)建一個寫入10分鐘后過期的緩存:
CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 寫入10分鐘后過期
.build();
// java8后可以使用Duration設置
CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build();
相對時間一般是相對于訪問時間春弥,也就是每次訪問后呛哟,會重新刷新該緩存的過期時間,這有點類似于servlet中的session過期時間匿沛,例如構(gòu)建一個在10分鐘內(nèi)未訪問則過期的緩存:
CacheBuilder.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES) //在10分鐘內(nèi)未訪問則過期
.build();
// java8后可以使用Duration設置
CacheBuilder.newBuilder()
.expireAfterAccess(Duration.ofMinutes(10))
.build();
緩存刷新
在Guava cache中支持定時刷新和顯式刷新兩種方式扫责,其中只有LoadingCache能夠進行定時刷新。
定時刷新
在進行緩存定時刷新時逃呼,我們需要指定緩存的刷新間隔鳖孤,和一個用來加載緩存的CacheLoader,當達到刷新時間間隔后蜘渣,下一次獲取緩存時淌铐,會調(diào)用CacheLoader的load方法刷新緩存。例如構(gòu)建個刷新頻率為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 {
// 緩存加載邏輯
...
}
});
顯式刷新
在緩存構(gòu)建完畢后腿准,我們可以通過Cache提供的一些借口方法,顯式的對緩存進行刷新覆蓋拾碌,例如:
// 構(gòu)建一個緩存
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");
統(tǒng)計
CacheBuilder.recordStats()用來開啟Guava Cache的統(tǒng)計功能弟跑。統(tǒng)計打開后,Cache.stats()方法會返回CacheStats對象以提供如下統(tǒng)計信息:
hitRate():緩存命中率防症;
averageLoadPenalty():加載新值的平均時間孟辑,單位為納秒;
evictionCount():緩存項被回收的總數(shù)蔫敲,不包括顯式清除饲嗽。
此外,還有其他很多統(tǒng)計信息奈嘿。這些統(tǒng)計信息對于調(diào)整緩存設置是至關(guān)重要的貌虾,在性能要求高的應用中我們建議密切關(guān)注這些數(shù)據(jù)。
Guava 提供了recordStats()方法裙犹,相當于啟動了記錄模式尽狠,通過Cache.stats()方法可以獲取CacheStats對象衔憨,里面存儲著緩存的使用情況,通過觀察它就可以知道緩存的命中率袄膏,加載耗時等信息践图,有了這些數(shù)據(jù)的反饋就可以調(diào)整的緩存的大小以及其他的優(yōu)化工作了。
asMap視圖
asMap視圖提供了緩存的ConcurrentMap形式沉馆,但asMap視圖與緩存的交互需要注意:
- cache.asMap()包含當前所有加載到緩存的項平项。因此相應地,cache.asMap().keySet()包含當前所有已加載鍵;
- asMap().get(key)實質(zhì)上等同于cache.getIfPresent(key)悍及,而且不會引起緩存項的加載闽瓢。這和Map的語義約定一致。
- 所有讀寫操作都會重置相關(guān)緩存項的訪問時間心赶,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法扣讼,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合視圖上的操作缨叫。比如椭符,遍歷Cache.asMap().entrySet()不會重置緩存項的讀取時間。
常見問題
緩存使用的最常見的問題耻姥,上文中销钝,提到緩存數(shù)據(jù)拉取出來后,需要添加一些關(guān)于每一個訪問用戶的額外信息琐簇,例如拉取出上課列表后蒸健,每一個用戶針對課程的狀態(tài)是不一樣的(報名狀態(tài)),通常會犯的一個錯誤就是直接在緩存數(shù)據(jù)基礎(chǔ)上進行修改婉商,通常我們緩存的對象會是一個Map似忧,或者List,對其引用的修改其實已經(jīng)修改了對應值本身丈秩,這樣會造成數(shù)據(jù)的混亂盯捌。因此記得在修改之前將緩存數(shù)據(jù)先深拷貝。