高并发之--Guava Cache
最近需要用到緩存來存放臨時(shí)數(shù)據(jù),又不想采用Redis,Java自帶的Map功能太少,發(fā)現(xiàn)google的Guava提供的Cache模塊功能很強(qiáng)大,于是選擇使用它。
本地緩存
本地緩存作用就是提高系統(tǒng)的運(yùn)行速度,是一種空間換時(shí)間的取舍。它實(shí)質(zhì)上是一個(gè)做key-value查詢的字典,但是相對于我們常用的HashMap它又有以下特點(diǎn):
1.并發(fā)性: 由于目前的應(yīng)用大都是多線程的,所以緩存需要支持并發(fā)的寫入。
2.過期策略:在某些場景中,我們可能會(huì)希望緩存的數(shù)據(jù)有一定"保質(zhì)期",過期策略可以固定時(shí)間,例如緩存寫入10分鐘后過期。也可以是相對時(shí)間,例如10分鐘內(nèi)未訪問則緩存過期(類似于servlet中的session)。在java中甚至可以使用軟引用,弱引用的過期策略。
3.淘汰策略:由于本地緩存是存放在內(nèi)存中,我們往往需要設(shè)置一個(gè)容量上限和淘汰策略來防止出現(xiàn)內(nèi)存溢出的情況。
緩存應(yīng)當(dāng)具備的屬性為:
1.能夠配置緩存的大小,保持可控的Memory。
2.適應(yīng)多種場景的數(shù)據(jù)expire策略。
3.在高并發(fā)的情況下、能夠正常緩存的更新以及返回。
Guava Cache適用于:
你愿意消耗一些內(nèi)存空間來提升速度。
你預(yù)料到某些鍵會(huì)被查詢一次以上。
緩存中存放的數(shù)據(jù)總量不會(huì)超出內(nèi)存容量
緩存的最大容量與淘汰策略
由于本地緩存是將計(jì)算結(jié)果緩存到內(nèi)存中,所以我們往往需要設(shè)置一個(gè)最大容量來防止出現(xiàn)內(nèi)存溢出的情況。這個(gè)容量可以是緩存對象的數(shù)量,也可以是一個(gè)具體的內(nèi)存大小。在Guva中僅支持設(shè)置緩存對象的數(shù)量。
當(dāng)緩存數(shù)量逼近或大于我們所設(shè)置的最大容量時(shí),為了將緩存數(shù)量控制在我們所設(shè)定的閾值內(nèi),就需要丟棄掉一些數(shù)據(jù)。由于緩存的最大容量恒定,為了提高緩存的命中率,我們需要盡量丟棄那些我們之后不再經(jīng)常訪問的數(shù)據(jù),保留那些即將被訪問的數(shù)據(jù)。為了達(dá)到以上目的,我們往往會(huì)制定一些緩存淘汰策略,常用的緩存淘汰策略有以下幾種:
1.FIFO:First In First Out,先進(jìn)先出。
一般采用隊(duì)列的方式實(shí)現(xiàn)。這種淘汰策略僅僅是保證了緩存數(shù)量不超過我們所設(shè)置的閾值,而完全沒有考慮緩存的命中率。所以在這種策略極少被使用。
2.LRU:Least Recently Used,最近最少使用;
該算法其核心思想是“如果數(shù)據(jù)最近被訪問過,那么將來被訪問的幾率也更高”。
所以該算法是淘汰最后一次使用時(shí)間離當(dāng)前最久的緩存數(shù)據(jù),保留最近訪問的數(shù)據(jù)。所以該種算法非常適合緩存“熱點(diǎn)數(shù)據(jù)”。
但是該算法在緩存周期性數(shù)據(jù)時(shí),就會(huì)出現(xiàn)緩存污染,也就是淘汰了即將訪問的數(shù)據(jù),反而把不常用的數(shù)據(jù)讀取到緩存中。
為了解決這個(gè)問題,后續(xù)也出現(xiàn)了如LRU-K,Two queues,Multi Queue等進(jìn)階算法。
3.LFU:Least Frequently Used,最不經(jīng)常使用。
該算法的核心思想是“如果數(shù)據(jù)在以前被訪問的次數(shù)最多,那么將來被訪問的幾率就會(huì)更高”。所以該算法淘汰的是歷史訪問次數(shù)最少的數(shù)據(jù)。
一般情況下,LFU效率要優(yōu)于LRU,且能夠避免周期性或者偶發(fā)性的操作導(dǎo)致緩存命中率下降的問題。但LFU需要記錄數(shù)據(jù)的歷史訪問記錄,一旦數(shù)據(jù)訪問模式改變,LFU需要更長時(shí)間來適用新的訪問模式,即:LFU存在歷史數(shù)據(jù)影響將來數(shù)據(jù)的“緩存污染”效用。
后續(xù)出現(xiàn)LFU*,LFU-Aging,Window-LFU等改進(jìn)算法。
合理的使用淘汰算法能夠很明顯的提升緩存命中率,但是也不應(yīng)該一味的追求命中率,而是應(yīng)在命中率和資源消耗中找到一個(gè)平衡。
在guava中默認(rèn)使用LRU淘汰算法,而且在不修改源碼的情況下也不支持自定義淘汰算法。
使用Guava構(gòu)建緩存
// 通過CacheBuilder構(gòu)建一個(gè)緩存實(shí)例 Cache<String, String> cache = CacheBuilder.newBuilder().maximumSize(100) // 設(shè)置緩存的最大容量.expireAfterWrite(1, TimeUnit.MINUTES) // 設(shè)置緩存在寫入一分鐘后失效.concurrencyLevel(10) // 設(shè)置并發(fā)級別為10.recordStats() // 開啟緩存統(tǒng)計(jì).build();// 放入緩存 cache.put("key", "value"); // 獲取緩存 String value = cache.getIfPresent("key");Cache和LoadingCache
使用CacheBuilder我們能構(gòu)建出兩種類型的cache,他們分別是Cache與LoadingCache。
Cache
Cache是通過CacheBuilder的build()方法構(gòu)建,它是Guava提供的最基本的緩存接口,并且它提供了一些常用的緩存api:
//放入/覆蓋一個(gè)緩存 cache.put("k1", "v1"); // 獲取一個(gè)緩存,如果該緩存不存在則返回一個(gè)null值 Object value = cache.getIfPresent("k1"); // 獲取緩存,當(dāng)緩存不存在時(shí),則通過Callable進(jìn)行加載并返回,該操作是原子 Object getValue = cache.get("k1", new Callable<Object>() {@Overridepublic Object call() throws Exception {return null;} });java8也可以采用lambda表達(dá)式來代替匿名內(nèi)部類
Object getValue = cache.get("k1", () -> {return null; });LoadingCache
LoadingCache繼承自Cache,在構(gòu)建LoadingCache時(shí),需要通過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;
}
});
// 獲取緩存,當(dāng)緩存不存在時(shí),會(huì)通過CacheLoader自動(dòng)加載,該方法會(huì)拋出ExecutionException異常
loadingCache.get("k1");
// 以不安全的方式獲取緩存,當(dāng)緩存不存在時(shí),會(huì)通過CacheLoader自動(dòng)加載,該方法不會(huì)拋出異常
loadingCache.getUnchecked("k1");
緩存的并發(fā)級別
Guava提供了設(shè)置并發(fā)級別的api,使得緩存支持并發(fā)的寫入和讀取。同ConcurrentHashMap類似Guava cache的并發(fā)也是通過分離鎖實(shí)現(xiàn)。在一般情況下,將并發(fā)級別設(shè)置為服務(wù)器cpu核心數(shù)是一個(gè)比較不錯(cuò)的選擇。
CacheBuilder.newBuilder()
// 設(shè)置并發(fā)級別為cpu核心數(shù)
.concurrencyLevel(Runtime.getRuntime().availableProcessors())
.build();
緩存的初始容量
我們在構(gòu)建緩存時(shí)可以為緩存設(shè)置一個(gè)合理大小初始容量,由于Guava的緩存使用了分離鎖的機(jī)制,擴(kuò)容的代價(jià)非常昂貴。所以合理的初始容量能夠減少緩存容器的擴(kuò)容次數(shù)。
CacheBuilder.newBuilder()
// 設(shè)置初始容量為100
.initialCapacity(100)
.build();
緩存的回收
在前文提到過,在構(gòu)建本地緩存時(shí),我們應(yīng)該指定一個(gè)最大容量來防止出現(xiàn)內(nèi)存溢出的情況。在guava中除了提供基于數(shù)量,和基于內(nèi)存容量兩種回收策略外,還提供了基于引用的回收。
基于數(shù)量/容量的回收
基于最大數(shù)量的回收策略非常簡單,我們只需指定緩存的最大數(shù)量maximumSize即可,maximumSize 設(shè)定了該緩存的最大存儲單位(key)個(gè)數(shù):
CacheBuilder.newBuilder()
.maximumSize(100) // 緩存數(shù)量上限為100
.build();
使用基于最大容量的的回收策略時(shí),我們需要設(shè)置2個(gè)必要參數(shù):
maximumWeigh;用于指定最大容量,maximumWeight 是根據(jù)設(shè)定緩存數(shù)據(jù)的最大值。
Weigher;在加載緩存時(shí)用于計(jì)算緩存容量大小。
這里我們例舉一個(gè)key和value都是String類型緩存:
CacheBuilder.newBuilder()
.maximumWeight(1024 * 1024 * 1024) // 設(shè)置最大容量為 1M
// 設(shè)置用來計(jì)算緩存容量的Weigher
.weigher(new Weigher<String, String>() {
@Override
public int weigh(String key, String value) {
return key.getBytes().length + value.getBytes().length;
}
}).build();
當(dāng)緩存的最大數(shù)量/容量逼近或超過我們所設(shè)置的最大值時(shí),Guava就會(huì)使用LRU算法對之前的緩存進(jìn)行回收。
基于軟/弱引用的回收
基于引用的回收策略,是java中獨(dú)有的。在java中有對象自動(dòng)回收機(jī)制,依據(jù)程序員創(chuàng)建對象的方式不同,將對象由強(qiáng)到弱分為強(qiáng)引用、軟引用、弱引用、虛引用。對于這幾種引用他們有以下區(qū)別:
強(qiáng)引用
強(qiáng)引用是使用最普遍的引用。如果一個(gè)對象具有強(qiáng)引用,那垃圾回收器絕不會(huì)回收它。
Object o=new Object();當(dāng)內(nèi)存空間不足,垃圾回收器不會(huì)自動(dòng)回收一個(gè)被引用的強(qiáng)引用對象,而是會(huì)直接拋出OutOfMemoryError錯(cuò)誤,使程序異常終止。
軟引用
相對于強(qiáng)引用,軟引用是一種不穩(wěn)定的引用方式,如果一個(gè)對象具有軟引用,當(dāng)內(nèi)存充足時(shí),GC不會(huì)主動(dòng)回收軟引用對象,而當(dāng)內(nèi)存不足時(shí)軟引用對象就會(huì)被回收。
SoftReference<Object> softRef=new SoftReference<Object>(new Object()); // 軟引用
Object object = softRef.get(); // 獲取軟引用
使用軟引用能防止內(nèi)存泄露,增強(qiáng)程序的健壯性。但是一定要做好null檢測。
弱引用
弱引用是一種比軟引用更不穩(wěn)定的引用方式,因?yàn)闊o論內(nèi)存是否充足,弱引用對象都有可能被回收。
WeakReference<Object> weakRef = new WeakReference<Object>(new Object()); // 弱引用
Object obj = weakRef.get(); // 獲取弱引用
虛引用
而虛引用這種引用方式就是形同虛設(shè),因?yàn)槿绻粋€(gè)對象僅持有虛引用,那么它就和沒有任何引用一樣。在實(shí)踐中也幾乎沒有使用。
在Guava cache中支持,軟/弱引用的緩存回收方式。使用這種方式能夠極大的提高內(nèi)存的利用率,并且不會(huì)出現(xiàn)內(nèi)存溢出的異常。
CacheBuilder.newBuilder()
.weakKeys() // 使用弱引用存儲鍵。當(dāng)鍵沒有其它(強(qiáng)或軟)引用時(shí),該緩存可能會(huì)被回收。
.weakValues() // 使用弱引用存儲值。當(dāng)值沒有其它(強(qiáng)或軟)引用時(shí),該緩存可能會(huì)被回收。
.softValues() // 使用軟引用存儲值。當(dāng)內(nèi)存不足并且該值其它強(qiáng)引用引用時(shí),該緩存就會(huì)被回收
.build();
通過軟/弱引用的回收方式,相當(dāng)于將緩存回收任務(wù)交給了GC,使得緩存的命中率變得十分的不穩(wěn)定,在非必要的情況下,還是推薦基于數(shù)量和容量的回收。
顯式回收
在緩存構(gòu)建完畢后,我們可以通過Cache提供的接口,顯式的對緩存進(jìn)行回收,例如:
- 個(gè)別清除:Cache.invalidate(key)
- 批量清除:Cache.invalidateAll(keys)
- 清除所有緩存項(xiàng):Cache.invalidateAll()
// 構(gòu)建一個(gè)緩存
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),你可以聲明一個(gè)監(jiān)聽器,以便緩存項(xiàng)被移除時(shí)做一些額外操作。緩存項(xiàng)被移除時(shí),RemovalListener<會(huì)獲取移除通知[RemovalNotification],其中包含移除原因[RemovalCause]、鍵和值。
請注意,RemovalListener拋出的任何異常都會(huì)在記錄到日志后被丟棄[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);
警告:默認(rèn)情況下,監(jiān)聽器方法是在移除緩存時(shí)同步調(diào)用的。因?yàn)榫彺娴木S護(hù)和請求響應(yīng)通常是同時(shí)進(jìn)行的,代價(jià)高昂的監(jiān)聽器方法在同步模式下會(huì)拖慢正常的緩存請求。在這種情況下,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)把監(jiān)聽器裝飾為異步操作。
緩存的過期策略與刷新
Guava也提供了緩存的過期策略和刷新策略。
緩存過期策略
緩存的過期策略分為固定時(shí)間和相對時(shí)間。
固定時(shí)間一般是指寫入后多長時(shí)間過期,例如我們構(gòu)建一個(gè)寫入10分鐘后過期的緩存:
CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 寫入10分鐘后過期
.build();
// java8后可以使用Duration設(shè)置
CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build();
相對時(shí)間一般是相對于訪問時(shí)間,也就是每次訪問后,會(huì)重新刷新該緩存的過期時(shí)間,這有點(diǎn)類似于servlet中的session過期時(shí)間,例如構(gòu)建一個(gè)在10分鐘內(nèi)未訪問則過期的緩存:
CacheBuilder.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES) //在10分鐘內(nèi)未訪問則過期
.build();
// java8后可以使用Duration設(shè)置
CacheBuilder.newBuilder()
.expireAfterAccess(Duration.ofMinutes(10))
.build();
緩存刷新
在Guava cache中支持定時(shí)刷新和顯式刷新兩種方式,其中只有LoadingCache能夠進(jìn)行定時(shí)刷新。
定時(shí)刷新
在進(jìn)行緩存定時(shí)刷新時(shí),我們需要指定緩存的刷新間隔,和一個(gè)用來加載緩存的CacheLoader,當(dāng)達(dá)到刷新時(shí)間間隔后,下一次獲取緩存時(shí),會(huì)調(diào)用CacheLoader的load方法刷新緩存。例如構(gòu)建個(gè)刷新頻率為10分鐘的緩存:
CacheBuilder.newBuilder()
// 設(shè)置緩存在寫入10分鐘后,通過CacheLoader的load方法進(jìn)行刷新
.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提供的一些借口方法,顯式的對緩存進(jìn)行刷新覆蓋,例如:
// 構(gòu)建一個(gè)緩存
Cache<String, String> cache = CacheBuilder.newBuilder().build();
// 使用put進(jìn)行覆蓋刷新
cache.put("k1", "v1");
// 使用Map的put方法進(jìn)行覆蓋刷新
cache.asMap().put("k1", "v1");
// 使用Map的putAll方法進(jìn)行批量覆蓋刷新
Map<String,String> needRefreshs = new HashMap<>();
needRefreshs.put("k1", "v1");
cache.asMap().putAll(needRefreshs);
// 使用ConcurrentMap的replace方法進(jìn)行覆蓋刷新
cache.asMap().replace("k1", "v1");
對于LoadingCache,由于它能夠自動(dòng)的加載緩存,所以在進(jìn)行刷新時(shí),不需要顯式的傳入緩存的值
LoadingCache<String, String> loadingCache = CacheBuilder
.newBuilder()
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 緩存加載邏輯
return null;
}
});
// loadingCache 在進(jìn)行刷新時(shí)無需顯式的傳入 value
loadingCache.refresh("k1");
統(tǒng)計(jì)
CacheBuilder.recordStats()用來開啟Guava Cache的統(tǒng)計(jì)功能。統(tǒng)計(jì)打開后,Cache.stats()方法會(huì)返回CacheStats對象以提供如下統(tǒng)計(jì)信息:
-
hitRate():緩存命中率;
-
averageLoadPenalty():加載新值的平均時(shí)間,單位為納秒;
-
evictionCount():緩存項(xiàng)被回收的總數(shù),不包括顯式清除。
此外,還有其他很多統(tǒng)計(jì)信息。這些統(tǒng)計(jì)信息對于調(diào)整緩存設(shè)置是至關(guān)重要的,在性能要求高的應(yīng)用中我們建議密切關(guān)注這些數(shù)據(jù)。
Guava 提供了recordStats()方法,相當(dāng)于啟動(dòng)了記錄模式,通過Cache.stats()方法可以獲取CacheStats對象,里面存儲著緩存的使用情況,通過觀察它就可以知道緩存的命中率,加載耗時(shí)等信息,有了這些數(shù)據(jù)的反饋就可以調(diào)整的緩存的大小以及其他的優(yōu)化工作了。
asMap視圖
asMap視圖提供了緩存的ConcurrentMap形式,但asMap視圖與緩存的交互需要注意:
- cache.asMap()包含當(dāng)前所有加載到緩存的項(xiàng)。因此相應(yīng)地,cache.asMap().keySet()包含當(dāng)前所有已加載鍵;
- asMap().get(key)實(shí)質(zhì)上等同于cache.getIfPresent(key),而且不會(huì)引起緩存項(xiàng)的加載。這和Map的語義約定一致。
- 所有讀寫操作都會(huì)重置相關(guān)緩存項(xiàng)的訪問時(shí)間,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合視圖上的操作。比如,遍歷Cache.asMap().entrySet()不會(huì)重置緩存項(xiàng)的讀取時(shí)間。
常見問題
緩存使用的最常見的問題,上文中,提到緩存數(shù)據(jù)拉取出來后,需要添加一些關(guān)于每一個(gè)訪問用戶的額外信息,例如拉取出上課列表后,每一個(gè)用戶針對課程的狀態(tài)是不一樣的(報(bào)名狀態(tài)),通常會(huì)犯的一個(gè)錯(cuò)誤就是直接在緩存數(shù)據(jù)基礎(chǔ)上進(jìn)行修改,通常我們緩存的對象會(huì)是一個(gè)Map,或者List,對其引用的修改其實(shí)已經(jīng)修改了對應(yīng)值本身,這樣會(huì)造成數(shù)據(jù)的混亂。因此記得在修改之前將緩存數(shù)據(jù)先深拷貝。
總結(jié)
以上是生活随笔為你收集整理的高并发之--Guava Cache的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 记一次iis+aspx环境下利用http
- 下一篇: js获取显卡型号