作者 | LLLSQ
責編 | 郭芮
網際網路軟體神速發展,用戶的體驗度是判斷一個軟體好壞的重要原因,所以緩存就是必不可少的一個神器。在多線程高並發場景中往往是離不開cache的,需要根據不同的應用場景來需要選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進程內)緩存如ehcache、GuavaCache、Caffeine。
說起Guava Cache,很多人都不會陌生,它是Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。由於Guava的大量使用,Guava Cache也得到了大量的應用。但是,Guava Cache的性能一定是最好的嗎?也許,曾經,它的性能是非常不錯的。但所謂長江後浪推前浪,總會有更加優秀的技術出現。今天,我就來介紹一個比Guava Cache性能更高的緩存框架:Caffeine。
官方性能比較
Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。
EhCache 是一個純Java的進程內緩存框架,具有快速、精幹等特點,是Hibernate中默認的CacheProvider。
Caffeine是使用Java8對Guava緩存的重寫版本,在Spring Boot 2.0中將取代,基於LRU算法實現,支持多種緩存過期策略。
場景1:8個線程讀,100%的讀操作。
場景二:6個線程讀,2個線程寫,也就是75%的讀操作,25%的寫操作。
場景三:8個線程寫,100%的寫操作。
可以清楚地看到Caffeine效率明顯高於其他緩存。
如何使用?
1 public staticvoid main(String[] args) { 2 LoadingCache<String, String> build = CacheBuilder.newBuilder().initialCapacity(1).maximumSize(100).expireAfterWrite(1, TimeUnit.DAYS) 3 .build(new CacheLoader<String, String>() { 4//默認的數據加載實現,當調用get取值的時候,如果key沒有對應的值,就調用這個方法進行加載 5@Override 6 public String load(String key) { 7return""; 8 } 9 });10 }11 }參數方法:
initialCapacity(1) 初始緩存長度為1;maximumSize(100) 最大長度為100;expireAfterWrite(1, TimeUnit.DAYS) 設置緩存策略在1天未寫入過期緩存(後面講緩存策略)。
過期策略
在Caffeine中分為兩種緩存,一個是有界緩存,一個是無界緩存,無界緩存不需要過期並且沒有界限。
在有界緩存中提供了三個過期API:
expireAfterWrite:代表著寫了之後多久過期。(上面列子就是這種方式)expireAfterAccess:代表著最後一次訪問了之後多久過期。expireAfter:在expireAfter中需要自己實現Expiry接口,這個接口支持create、update、以及access了之後多久過期。注意這個API和前面兩個API是互斥的。這裡和前面兩個API不同的是,需要你告訴緩存框架,它應該在具體的某個時間過期,也就是通過前面的重寫create、update、以及access的方法,獲取具體的過期時間。
更新策略
何為更新策略?就是在設定多長時間後會自動刷新緩存。
Caffeine提供了refreshAfterWrite()方法來讓我們進行寫後多久更新策略:
1 LoadingCache<String, String> build = CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)2 .build(new CacheLoader<String, String>() {3@Override4 public String load(String key) {5return"";6 }7 });8 }上面的代碼我們需要建立一個CacheLodaer來進行刷新,這裡是同步進行的,可以通過buildAsync方法進行異步構建。在實際業務中這裡可以把我們代碼中的mapper傳入進去,進行數據源的刷新。
但是實際使用中,你設置了一天刷新,但是一天後你發現緩存並沒有刷新。這是因為只有在1天後這個緩存再次訪問後才能刷新,如果沒人訪問,那麼永遠也不會刷新。
我們來看看自動刷新是怎麼做的呢?自動刷新只存在讀操作之後,也就是我們的afterRead()這個方法,其中有個方法叫refreshIfNeeded,它會根據你是同步還是異步然後進行刷新處理。
填充策略(Population)
Caffeine 為我們提供了三種填充策略:手動、同步和異步。
手動加載(Manual)
1Cache<String, Object> manualCache = Caffeine.newBuilder() 2 .expireAfterWrite(10, TimeUnit.MINUTES) 3 .maximumSize(10_000) 4 .build(); 5 6String key = "name1"; 7// 根據key查詢一個緩存,如果沒有返回NULL 8graph = manualCache.getIfPresent(key); 9// 根據Key查詢一個緩存,如果沒有調用createExpensiveGraph方法,並將返回值保存到緩存。10// 如果該方法返回Null則manualCache.get返回null,如果該方法拋出異常則manualCache.get拋出異常11graph = manualCache.get(key, k -> createExpensiveGraph(k));12// 將一個值放入緩存,如果以前有值就覆蓋以前的值13manualCache.put(key, graph);14// 刪除一個緩存15manualCache.invalidate(key);1617ConcurrentMap<String, Object> map = manualCache.asMap();18cache.invalidate(key);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進而對緩存進行一些更改。
同步加載(Loading)
1LoadingCache<String, Object> loadingCache = Caffeine.newBuilder() 2 .maximumSize(10_000) 3 .expireAfterWrite(10, TimeUnit.MINUTES) 4 .build(key -> createExpensiveGraph(key)); 5 6String key = "name1"; 7// 採用同步方式去獲取一個緩存和上面的手動方式是一個原理。在build Cache的時候會提供一個createExpensiveGraph函數。 8// 查詢並在缺失的情況下使用同步的方式來構建一個緩存 9Object graph = loadingCache.get(key);1011// 獲取組key的值返回一個Map12List<String> keys = new ArrayList<>();13keys.add(key);14Map<String, Object> graphs = loadingCache.getAll(keys);LoadingCache是使用CacheLoader來構建的緩存的值。批量查找可以使用getAll方法。默認情況下,getAll將會對緩存中沒有值的key分別調用CacheLoader.load方法來構建緩存的值。我們可以重寫CacheLoader.loadAll方法來提高getAll的效率。
注意:可以編寫一個CacheLoader.loadAll來實現為特別請求的key加載值。例如,如果計算某個組中的任何鍵的值將為該組中的所有鍵提供值,則loadAll可能會同時加載該組的其餘部分。
異步加載(Asynchronously Loading)
1AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder() 2 .maximumSize(10_000) 3 .expireAfterWrite(10, TimeUnit.MINUTES) 4// Either: Build with a synchronous computation that is wrapped as asynchronous 5 .buildAsync(key -> createExpensiveGraph(key)); 6// Or: Build with a asynchronous computation that returns a future 7// .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor)); 8 9String key = "name1";1011// 查詢並在缺失的情況下使用異步的方式來構建緩存12CompletableFuture<Object> graph = asyncLoadingCache.get(key);13// 查詢一組緩存並在缺失的情況下使用異步的方式來構建緩存14List<String> keys = new ArrayList<>();15keys.add(key);16CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);17// 異步轉同步18loadingCache = asyncLoadingCache.synchronous();AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法並返回一個CompletableFuture。異步加載緩存使用了響應式編程模型。
如果要以同步方式調用時,應提供CacheLoader。要以異步表示時,應該提供一個AsyncCacheLoader,並返回一個CompletableFuture。
synchronous()這個方法返回了一個LoadingCacheView視圖,LoadingCacheView也繼承自LoadingCache。調用該方法後就相當於你將一個異步加載的緩存AsyncLoadingCache轉換成了一個同步加載的緩存LoadingCache。
默認使用ForkJoinPool.commonPool()來執行異步線程,但是我們可以通過Caffeine.executor(Executor) 方法來替換線程池。
驅逐策略(eviction)
Caffeine提供三類驅逐策略:基於大小(size-based),基於時間(time-based)和基於引用(reference-based)。
基於大小(size-based)
基於大小驅逐,有兩種方式:一種是基於緩存大小,一種是基於權重。
1// Evict based on the number of entries in the cache 2// 根據緩存的計數進行驅逐 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .maximumSize(10_000) 5 .build(key -> createExpensiveGraph(key)); 6 7// Evict based on the number of vertices in the cache 8// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐) 9LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()10 .maximumWeight(10_000)11 .weigher((Keykey, Graph graph) -> graph.vertices().size())12 .build(key -> createExpensiveGraph(key));我們可以使用Caffeine.maximumSize(long)方法來指定緩存的最大容量。當緩存超出這個容量的時候,會使用Window TinyLfu策略來刪除緩存。我們也可以使用權重的策略來進行驅逐,可以使用Caffeine.weigher(Weigher) 函數來指定權重,使用Caffeine.maximumWeight(long) 函數來指定緩存最大權重值。
注意:maximumWeight與maximumSize不可以同時使用。
基於時間(Time-based)
1// Evict based on a fixed expiration policy 2// 基於固定的到期策略進行退出 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .expireAfterAccess(5, TimeUnit.MINUTES) 5 .build(key -> createExpensiveGraph(key)); 6LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 7 .expireAfterWrite(10, TimeUnit.MINUTES) 8 .build(key -> createExpensiveGraph(key)); 910// Evict based on a varying expiration policy11// 基於不同的到期策略進行退出12LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()13 .expireAfter(new Expiry<Key, Graph>() {14@Override15publiclongexpireAfterCreate(Key key, Graph graph, long currentTime){16// Use wall clock time, rather than nanotime, if from an external resource17long seconds = graph.creationDate().plusHours(5)18 .minus(System.currentTimeMillis(), MILLIS)19 .toEpochSecond();20return TimeUnit.SECONDS.toNanos(seconds);21 }2223@Override24publiclongexpireAfterUpdate(Key key, Graph graph, 25long currentTime, long currentDuration){26return currentDuration;27 }2829@Override30publiclongexpireAfterRead(Key key, Graph graph,31long currentTime, long currentDuration){32return currentDuration;33 }34 })35 .build(key -> createExpensiveGraph(key));基於引用(reference-based)
Java 4種引用的級別由高到低依次為:強引用 > 軟引用 > 弱引用 > 虛引用。
1// Evict when neither the key nor value are strongly reachable 2// 當key和value都沒有引用時驅逐緩存 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .weakKeys() 5 .weakValues() 6 .build(key -> createExpensiveGraph(key)); 7 8// Evict when the garbage collector needs to free memory 9// 當垃圾收集器需要釋放內存時驅逐10LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()11 .softValues()12 .build(key -> createExpensiveGraph(key));我們可以將緩存的驅逐配置成基於垃圾回收器。為此,我們可以將key和 value配置為弱引用或只將值配置成軟引用。
注意:AsyncLoadingCache不支持弱引用和軟引用。
移除監聽器(Removal)
驅逐(eviction):由於滿足了某種驅逐策略,後臺自動進行的刪除操作;無效(invalidation):表示由調用方手動刪除緩存;移除(removal):監聽驅逐或無效操作的監聽器。手動刪除緩存:
在任何時候,都可能明確地使緩存無效,而不用等待緩存被驅逐。
1// individual key2cache.invalidate(key)3// bulkkeys4cache.invalidateAll(keys)5// all keys6cache.invalidateAll()Removal 監聽器:
可以通過Caffeine.removalListener(RemovalListener) 為緩存指定一個刪除偵聽器,以便在刪除數據時執行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。
刪除偵聽器的裡面的操作是使用Executor來異步執行的。默認執行程序是ForkJoinPool.commonPool(),可以通過Caffeine.executor(Executor)覆蓋。當操作必須與刪除同步執行時,請改為使用CacheWrite,CacheWrite將在下面說明。
注意:由RemovalListener拋出的任何異常都會被記錄(使用Logger)並不會拋出。
統計(Statistics)
Cache<Key, Graph> graphs = Caffeine.newBuilder()2 .maximumSize(10_000)3 .recordStats()4 .build();使用Caffeine.recordStats(),可以打開統計信息收集。Cache.stats() 方法返回提供統計信息的CacheStats,如:
hitRate():返回命中與請求的比率;hitCount(): 返回命中緩存的總數;evictionCount():緩存逐出的數量;averageLoadPenalty():加載新值所花費的平均時間。
總結
Caffeine的調整不只有算法上面的調整,內存方面的優化也有很大進步,Caffeine的API的操作功能和Guava是基本保持一致的,並且Caffeine為了兼容之前是Guava的用戶,所以使用或者重寫緩存到Caffeine應該沒什麼問題,但是也要看項目情況,不要盲目使用。
作者:LLLSQ,一隻有著悲慘故事的北漂程式設計師,為讀者提供熱點技術文章和IT實時熱點新聞、架構、面試信息等最新訊息。聲明:本文為作者投稿,版權歸對方所有。