還有一篇文章是講解redis 如何刪除過期數據的,參考:Redis的內存回收策略和內存上限(阿里)
划重點:在GuavaCache中,並不存在任何線程!它實現機制是在寫操作時順帶做少量的維護工作(如清除),偶爾在讀操作時做(如果寫操作實在太少的話),也就是說在使用的是調用線程
總結
請一定要記住GuavaCache的實現代碼中沒有啟動任何線程!!Cache中的所有維護操作,包括清除緩存、寫入緩存等,都是通過調用線程來操作的。這在需要低延遲服務場景中使用時尤其需要關注,可能會在某個調用的響應時間突然變大。
GuavaCache畢竟是一款面向本地緩存的,輕量級的Cache,適合緩存少量數據。如果你想緩存上千萬數據,可以為每個key設置不同的存活時間,並且高性能,那並不適合使用GuavaCache。
前言
在多線程高並發場景中往往是離不開cache的,需要根據不同的應用場景來需要選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進程內)緩存如ehcache、GuavaCache。之前用spring cache的時候集成的是ehcache,但接觸到GuavaCache之后,被它的簡單、強大、及輕量級所吸引。它不需要配置文件,使用起來和ConcurrentHashMap一樣簡單,而且能覆蓋絕大多數使用cache的場景需求!
GuavaCache是google開源java類庫Guava的其中一個模塊,在maven工程下使用可在pom文件加入如下依賴:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
Cache接口及其實現
先說說一般的cache都會實現的基礎功能包括:
提供一個存儲緩存的容器,該容器實現了存放(Put)和讀取(Get)緩存的接口供外部調用。 緩存通常以<key,value>的形式存在,通過key來從緩存中獲取value。當然容器的大小往往是有限的(受限於內存大小),需要為它設置清除緩存的策略。
在GuavaCache中緩存的容器被定義為接口Cache<K, V>的實現類,這些實現類都是線程安全的,因此通常定義為一個單例。並且接口Cache是泛型,很好的支持了不同類型的key和value。作為示例,我們構建一個key為Integer、value為String的Cache實例:
final static Cache<Integer, String> cache = CacheBuilder.newBuilder() //設置cache的初始大小為10,要合理設置該值 .initialCapacity(10) //設置並發數為5,即同一時間最多只能有5個線程往cache執行寫入操作 .concurrencyLevel(5) //設置cache中的數據在寫入之后的存活時間為10秒 .expireAfterWrite(10, TimeUnit.SECONDS) //構建cache實例 .build();
據說GuavaCache的實現是基於ConcurrentHashMap的,因此上面的構造過程所調用的方法,通過查看其官方文檔也能看到一些類似的原理。比如通過initialCapacity(5)定義初始值大小,要是定義太大就好浪費內存空間,要是太小,需要擴容的時候就會像map一樣需要resize,這個過程會產生大量需要gc的對象,還有比如通過concurrencyLevel(5)來限制寫入操作的並發數,這和ConcurrentHashMap的鎖機制也是類似的(ConcurrentHashMap讀不需要加鎖,寫入需要加鎖,每個segment都有一個鎖)。
接下來看看Cache提供哪些方法(只列了部分常用的):
/** * 該接口的實現被認為是線程安全的,即可在多線程中調用 * 通過被定義單例使用 */ public interface Cache<K, V> { /** * 通過key獲取緩存中的value,若不存在直接返回null */ V getIfPresent(Object key); /** * 通過key獲取緩存中的value,若不存在就通過valueLoader來加載該value * 整個過程為 "if cached, return; otherwise create, cache and return" * 注意valueLoader要么返回非null值,要么拋出異常,絕對不能返回null */ V get(K key, Callable<? extends V> valueLoader) throws ExecutionException; /** * 添加緩存,若key存在,就覆蓋舊值 */ void put(K key, V value); /** * 刪除該key關聯的緩存 */ void invalidate(Object key); /** * 刪除所有緩存 */ void invalidateAll(); /** * 執行一些維護操作,包括清理緩存 */ void cleanUp(); }
使用過程還是要認真查看官方的文檔,以下Demo簡單的展示了Cache的寫入,讀取,和過期清除策略是否生效:
public static void main(String[] args) throws Exception { cache.put(1, "Hi"); for(int i=0 ;i<100 ;i++) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); System.out.println(sdf.format(new Date()) + " key:1 ,value:"+cache.getIfPresent(1)); Thread.sleep(1000); } }
清除緩存的策略
任何Cache的容量都是有限的,而緩存清除策略就是決定數據在什么時候應該被清理掉。GuavaCache提了以下幾種清除策略:
1、基於存活時間的清除(Timed Eviction)
這應該是最常用的清除策略,在構建Cache實例的時候,CacheBuilder提供兩種基於存活時間的構建方法:
(1)expireAfterAccess(long, TimeUnit):緩存項在創建后,在給定時間內沒有被讀/寫訪問,則清除。
(2)expireAfterWrite(long, TimeUnit):緩存項在創建后,在給定時間內沒有被寫訪問(創建或覆蓋),則清除。
expireAfterWrite()方法有些類似於redis中的expire命令,但顯然它只能設置所有緩存都具有相同的存活時間。若遇到一些緩存數據的存活時間為1分鍾,一些為5分鍾,那只能構建兩個Cache實例了。
2、基於容量的清除(size-based eviction)
在構建Cache實例的時候,通過CacheBuilder.maximumSize(long)方法可以設置Cache的最大容量數,當緩存數量達到或接近該最大值時,Cache將清除掉那些最近最少使用的緩存。
以上是這種方式是以緩存的“數量”作為容量的計算方式,還有另外一種基於“權重”的計算方式。比如每一項緩存所占據的內存空間大小都不一樣,可以看作它們有不同的“權重”(weights)。你可以使用CacheBuilder.weigher(Weigher)指定一個權重函數,並且用CacheBuilder.maximumWeight(long)指定最大總重。
3、顯式清除
任何時候,你都可以顯式地清除緩存項,而不是等到它被回收,Cache接口提供了如下API:
(1)個別清除:Cache.invalidate(key)
(2)批量清除:Cache.invalidateAll(keys)
(3)清除所有緩存項:Cache.invalidateAll()
4、基於引用的清除(Reference-based Eviction)
在構建Cache實例過程中,通過設置使用弱引用的鍵、或弱引用的值、或軟引用的值,從而使JVM在GC時順帶實現緩存的清除,不過一般不輕易使用這個特性。
(1)CacheBuilder.weakKeys():使用弱引用存儲鍵
(2)CacheBuilder.weakValues():使用弱引用存儲值
(3)CacheBuilder.softValues():使用軟引用存儲值
清除什么時候發生?
也許這個問題有點奇怪,如果設置的存活時間為一分鍾,難道不是一分鍾后這個key就會立即清除掉嗎?我們來分析一下如果要實現這個功能,那Cache中就必須存在線程來進行周期性地檢查、清除等工作,很多cache如redis、ehcache都是這樣實現的。注意 redis使用了維護線程清理過期的key但是 guava沒有使用 ,redis參考:Redis的內存回收策略和內存上限(阿里)
但在GuavaCache中,並不存在任何線程!它實現機制是在寫操作時順帶做少量的維護工作(如清除),偶爾在讀操作時做(如果寫操作實在太少的話),也就是說在使用的是調用線程,參考如下示例:
public class CacheService { static Cache<Integer, String> cache = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build(); public static void main(String[] args) throws Exception { new Thread() { //monitor public void run() { while(true) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); System.out.println(sdf.format(new Date()) +" size: "+cache.size()); try { Thread.sleep(2000); } catch (InterruptedException e) { } } }; }.start(); SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); cache.put(1, "Hi"); System.out.println("write key:1 ,value:"+cache.getIfPresent(1)); Thread.sleep(10000); // when write ,key:1 clear cache.put(2, "bbb"); System.out.println("write key:2 ,value:"+cache.getIfPresent(2)); Thread.sleep(10000); // when read other key ,key:2 do not clear System.out.println(sdf.format(new Date()) +" after write, key:1 ,value:"+cache.getIfPresent(1)); Thread.sleep(2000); // when read same key ,key:2 clear System.out.println(sdf.format(new Date()) +" final, key:2 ,value:"+cache.getIfPresent(2)); } }
控制台輸出:
00:34:17 size: 0 write key:1 ,value:Hi 00:34:19 size: 1 00:34:21 size: 1 00:34:23 size: 1 00:34:25 size: 1 write key:2 ,value:bbb 00:34:27 size: 1 00:34:29 size: 1 00:34:31 size: 1 00:34:33 size: 1 00:34:35 size: 1 00:34:37 after write, key:1 ,value:null 00:34:37 size: 1 00:34:39 final, key:2 ,value:null 00:34:39 size: 0
通過分析發現:
(1)緩存項<1,"Hi">的存活時間是5秒,但經過5秒后並沒有被清除,因為還是size=1
(2)發生寫操作cache.put(2, "bbb")后,緩存項<1,"Hi">被清除,因為size=1,而不是size=2
(3)發生讀操作cache.getIfPresent(1)后,緩存項<2,"bbb">沒有被清除,因為還是size=1,看來讀操作確實不一定會發生清除
(4)發生讀操作cache.getIfPresent(2)后,緩存項<2,"bbb">被清除,因為讀的key就是2
這在GuavaCache被稱為“延遲刪除”,即刪除總是發生得比較“晚”,這也是GuavaCache不同於其他Cache的地方!這種實現方式的問題:緩存會可能會存活比較長的時間,一直占用着內存。如果使用了復雜的清除策略如基於容量的清除,還可能會占用着線程而導致響應時間變長。但優點也是顯而易見的,沒有啟動線程,不管是實現,還是使用起來都讓人覺得簡單(輕量)。
如果你還是希望盡可能的降低延遲,可以創建自己的維護線程,以固定的時間間隔調用Cache.cleanUp(),ScheduledExecutorService可以幫助你很好地實現這樣的定時調度。不過這種方式依然沒辦法百分百的確定一定是自己的維護線程“命中”了維護的工作。