緩存
本次主要討論緩存。緩存在日常開發中舉足輕重,如果你的應用對某類數據有着較高的讀取頻次,並且改動較小時那就非常適合利用緩存來提高性能。
緩存之所以可以提高性能是因為它的讀取效率很高,就像是 CPU 的 L1、L2、L3
緩存一樣,級別越高相應的讀取速度也會越快。
但也不是什么好處都占,讀取速度快了但是它的內存更小資源更寶貴,所以我們應當緩存真正需要的數據。其實也就是典型的空間換時間。下面談談 Java 中所用到的緩存。
JVM 緩存
首先是 JVM 緩存,也可以認為是堆緩存。
其實就是創建一些全局變量,如 Map、List
之類的容器用於存放數據。
這樣的優勢是使用簡單但是也有以下問題:
- 只能顯式的寫入,清除數據。
- 不能按照一定的規則淘汰數據,如
LRU,LFU,FIFO
等。 - 清除數據時的回調通知。
- 其他一些定制功能等。
Ehcache、Guava Cache
所以出現了一些專門用作 JVM 緩存的開源工具出現了,如本文提到的 Guava Cache。
它具有上文 JVM 緩存不具有的功能,如自動清除數據、多種清除算法、清除回調等。
但也正因為有了這些功能,這樣的緩存必然會多出許多東西需要額外維護,自然也就增加了系統的消耗。
今天說的 Guava Cache 是google guava中的一個內存緩存模塊,用於將數據緩存到JVM內存中。他很好的解決了上面提到的幾個問題:
- 很好的封裝了get、put操作,能夠集成數據源 ;
- 線程安全的緩存,與ConcurrentMap相似,但前者增加了更多的元素失效策略,后者只能顯示的移除元素;
- Guava Cache提供了三種基本的緩存回收方式:基於容量回收、定時回收和基於引用回收。定時回收有兩種:按照寫入時間,最早寫入的最先回收;按照訪問時間,最早訪問的最早回收;
- 監控緩存加載/命中情況
Guava Cache的架構設計靈感ConcurrentHashMap,在簡單場景中可以通過HashMap實現簡單數據緩存,但如果要實現緩存隨時間改變、存儲的數據空間可控則緩存工具還是很有必要的。Cache存儲的是鍵值對的集合,不同時是還需要處理緩存過期、動態加載等算法邏輯,需要額外信息實現這些操作,對此根據面向對象的思想,還需要做方法與數據的關聯性封裝,主要實現的緩存功能有:自動將節點加載至緩存結構中,當緩存的數據超過最大值時,使用LRU算法替換;它具備根據節點上一次被訪問或寫入時間計算緩存過期機制,緩存的key被封裝在WeakReference引用中,緩存的value被封裝在WeakReference或SoftReference引用中;還可以統計緩存使用過程中的命中率、異常率和命中率等統計數據。
分布式緩存
剛才提到的兩種緩存其實都是堆內緩存,只能在單個節點中使用,這樣在分布式場景下就招架不住了。
於是也有了一些緩存中間件,如 Redis、Memcached,在分布式環境下可以共享內存。
具體不在本次的討論范圍。
Guava Cache 示例
示例1:如果構建guava的cache
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Random; import java.util.concurrent.TimeUnit; public class GuavaCacheService { public void setCache() { LoadingCache<Integer, String> cache = CacheBuilder.newBuilder() //設置並發級別為8,並發級別是指可以同時寫緩存的線程數 .concurrencyLevel(8) //設置緩存容器的初始容量為10 .initialCapacity(10) //設置緩存最大容量為100,超過100之后就會按照LRU最近雖少使用算法來移除緩存項 .maximumSize(100) //是否需要統計緩存情況,該操作消耗一定的性能,生產環境應該去除 .recordStats() //設置寫緩存后n秒鍾過期 .expireAfterWrite(60, TimeUnit.SECONDS) //設置讀寫緩存后n秒鍾過期,實際很少用到,類似於expireAfterWrite //.expireAfterAccess(17, TimeUnit.SECONDS) //只阻塞當前數據加載線程,其他線程返回舊值 //.refreshAfterWrite(13, TimeUnit.SECONDS) //設置緩存的移除通知 .removalListener(notification -> { System.out.println(notification.getKey() + " " + notification.getValue() + " 被移除,原因:" + notification.getCause()); }) //build方法中可以指定CacheLoader,在緩存不存在時通過CacheLoader的實現自動加載緩存 .build(new DemoCacheLoader()); //模擬線程並發 new Thread(() -> { //非線程安全的時間格式化工具 SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss"); try { for (int i = 0; i < 10; i++) { String value = cache.get(1); System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value); TimeUnit.SECONDS.sleep(3); } } catch (Exception ignored) { } }).start(); new Thread(() -> { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss"); try { for (int i = 0; i < 10; i++) { String value = cache.get(1); System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value); TimeUnit.SECONDS.sleep(5); } } catch (Exception ignored) { } }).start(); //緩存狀態查看 System.out.println(cache.stats().toString()); } /** * 隨機緩存加載,實際使用時應實現業務的緩存加載邏輯,例如從數據庫獲取數據 */ public static class DemoCacheLoader extends CacheLoader<Integer, String> { @Override public String load(Integer key) throws Exception { System.out.println(Thread.currentThread().getName() + " 加載數據開始"); TimeUnit.SECONDS.sleep(8); Random random = new Random(); System.out.println(Thread.currentThread().getName() + " 加載數據結束"); return "value:" + random.nextInt(10000); } } }
上面一段代碼展示了如何使用Cache創建一個緩存對象並使用它。
LoadingCache是Cache的子接口,相比較於Cache,當從LoadingCache中讀取一個指定key的記錄時,如果該記錄不存在,則LoadingCache可以自動執行加載數據到緩存的操作。
在調用CacheBuilder的build方法時,必須傳遞一個CacheLoader類型的參數,CacheLoader的load方法需要我們提供實現。當調用LoadingCache的get方法時,如果緩存不存在對應key的記錄,則CacheLoader中的load方法會被自動調用從外存加載數據,load方法的返回值會作為key對應的value存儲到LoadingCache中,並從get方法返回。
當然如果你不想指定重建策略,那么你可以使用無參的build()方法,它將返回Cache類型的構建對象。
CacheBuilder 是Guava 提供的一個快速構建緩存對象的工具類。CacheBuilder類采用builder設計模式,它的每個方法都返回CacheBuilder本身,直到build方法被調用。 該類中提供了很多的參數設置選項,你可以設置cache的默認大小,並發數,存活時間,過期策略等等。
可選配置分析#
緩存的並發級別
Guava提供了設置並發級別的api,使得緩存支持並發的寫入和讀取。同 ConcurrentHashMap 類似Guava cache的並發也是通過分離鎖實現。在一般情況下,將並發級別設置為服務器cpu核心數是一個比較不錯的選擇。
CacheBuilder.newBuilder() // 設置並發級別為cpu核心數 .concurrencyLevel(Runtime.getRuntime().availableProcessors()) .build();
緩存的初始容量設置
我們在構建緩存時可以為緩存設置一個合理大小初始容量,由於Guava的緩存使用了分離鎖的機制,擴容的代價非常昂貴。所以合理的初始容量能夠減少緩存容器的擴容次數。
CacheBuilder.newBuilder() // 設置初始容量為100 .initialCapacity(100) .build();
設置最大存儲
Guava Cache可以在構建緩存對象時指定緩存所能夠存儲的最大記錄數量。當Cache中的記錄數量達到最大值后再調用put方法向其中添加對象,Guava會先從當前緩存的對象記錄中選擇一條刪除掉,騰出空間后再將新的對象存儲到Cache中。
- 基於容量的清除(size-based eviction): 通過CacheBuilder.maximumSize(long)方法可以設置Cache的最大容量數,當緩存數量達到或接近該最大值時,Cache將清除掉那些最近最少使用的緩存;
- **基於權重的清除: ** 使用CacheBuilder.weigher(Weigher)指定一個權重函數,並且用CacheBuilder.maximumWeight(long)指定最大總重。比如每一項緩存所占據的內存空間大小都不一樣,可以看作它們有不同的“權重”(weights)。
緩存清除策略
1. 基於存活時間的清除
- expireAfterWrite 寫緩存后多久過期
- expireAfterAccess 讀寫緩存后多久過期
- refreshAfterWrite 寫入數據后多久過期,只阻塞當前數據加載線程,其他線程返回舊值
這幾個策略時間可以單獨設置,也可以組合配置。
2. 上面提到的基於容量的清除
3. 顯式清除
任何時候,你都可以顯式地清除緩存項,而不是等到它被回收,Cache接口提供了如下API:
-
個別清除:Cache.invalidate(key)
-
批量清除:Cache.invalidateAll(keys)
-
清除所有緩存項:Cache.invalidateAll()
4. 基於引用的清除(Reference-based Eviction)
在構建Cache實例過程中,通過設置使用弱引用的鍵、或弱引用的值、或軟引用的值,從而使JVM在GC時順帶實現緩存的清除,不過一般不輕易使用這個特性。
- CacheBuilder.weakKeys():使用弱引用存儲鍵。當鍵沒有其它(強或軟)引用時,緩存項可以被垃圾回收。因為垃圾回收僅依賴恆等式,使用弱引用鍵的緩存用而不是equals比較鍵。
- CacheBuilder.weakValues():使用弱引用存儲值。當值沒有其它(強或軟)引用時,緩存項可以被垃圾回收。因為垃圾回收僅依賴恆等式,使用弱引用值的緩存用而不是equals比較值。
- CacheBuilder.softValues():使用軟引用存儲值。軟引用只有在響應內存需要時,才按照全局最近最少使用的順序回收。考慮到使用軟引用的性能影響,我們通常建議使用更有性能預測性的緩存大小限定(見上文,基於容量回收)。使用軟引用值的緩存同樣用==而不是equals比較值。
清理什么時候發生
也許這個問題有點奇怪,如果設置的存活時間為一分鍾,難道不是一分鍾后這個key就會立即清除掉嗎?我們來分析一下如果要實現這個功能,那Cache中就必須存在線程來進行周期性地檢查、清除等工作,很多cache如redis、ehcache都是這樣實現的。
使用CacheBuilder構建的緩存不會”自動”執行清理和回收工作,也不會在某個緩存項過期后馬上清理,也沒有諸如此類的清理機制。相反,它會在寫操作時順帶做少量的維護工作,或者偶爾在讀操作時做——如果寫操作實在太少的話。
這樣做的原因在於:如果要自動地持續清理緩存,就必須有一個線程,這個線程會和用戶操作競爭共享鎖。此外,某些環境下線程創建可能受限制,這樣CacheBuilder就不可用了。參考如下示例:
import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.TimeUnit; public class GuavaCacheService { static Cache<Integer, String> cache = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build(); public static void main(String[] args) throws Exception { new Thread(() -> { 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, "a"); System.out.println("寫入 key:1 ,value:" + cache.getIfPresent(1)); Thread.sleep(10000); cache.put(2, "b"); System.out.println("寫入 key:2 ,value:" + cache.getIfPresent(2)); Thread.sleep(10000); System.out.println(sdf.format(new Date()) + " sleep 10s , key:1 ,value:" + cache.getIfPresent(1)); System.out.println(sdf.format(new Date()) + " sleep 10s, key:2 ,value:" + cache.getIfPresent(2)); } }
結果:
部分輸出結果: 23:57:36 size: 0 寫入 key:1 ,value:a 23:57:38 size: 1 23:57:40 size: 1 23:57:42 size: 1 23:57:44 size: 1 23:57:46 size: 1 寫入 key:2 ,value:b 23:57:48 size: 1 23:57:50 size: 1 23:57:52 size: 1 23:57:54 size: 1 23:57:56 size: 1 23:57:56 sleep 10s , key:1 ,value:null 23:57:56 sleep 10s, key:2 ,value:null 23:57:58 size: 0 23:58:00 size: 0 23:58:02 size: 0 ...
上面程序設置了緩存過期時間為5S,每打印一次當前的size需要2S,打印了5次size之后寫入key 2,此時的size為1,說明在這個時候才把第一次應該過期的key 1給刪除。
給移除操作添加一個監聽器:
可以為Cache對象添加一個移除監聽器,這樣當有記錄被刪除時可以感知到這個事件。
RemovalListener<String, String> listener = notification -> System.out.println("[" + notification.getKey() + ":" + notification.getValue() + "] is removed!"); Cache<String,String> cache = CacheBuilder.newBuilder() .maximumSize(5) .removalListener(listener) .build();
但是要注意的是:
默認情況下,監聽器方法是在移除緩存時同步調用的。因為緩存的維護和請求響應通常是同時進行的,代價高昂的監聽器方法在同步模式下會拖慢正常的緩存請求。在這種情況下,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)
把監聽器裝飾為異步操作。
自動加載
上面我們說過使用get方法的時候如果key不存在你可以使用指定方法去加載這個key。在Cache構建的時候通過指定CacheLoder的方式。如果你沒有指定,你也可以在get的時候顯式的調用call方法來設置key不存在的補救策略。
Cache的get方法有兩個參數,第一個參數是要從Cache中獲取記錄的key,第二個記錄是一個Callable對象。
當緩存中已經存在key對應的記錄時,get方法直接返回key對應的記錄。如果緩存中不包含key對應的記錄,Guava會啟動一個線程執行Callable對象中的call方法,call方法的返回值會作為key對應的值被存儲到緩存中,並且被get方法返回。
import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; public class GuavaCacheService { private static Cache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(3) .build(); public static void main(String[] args) { new Thread(() -> { System.out.println("thread1"); try { String value = cache.get("key", new Callable<String>() { public String call() throws Exception { System.out.println("thread1"); //加載數據線程執行標志 Thread.sleep(1000); //模擬加載時間 return "thread1"; } }); System.out.println("thread1 " + value); } catch (ExecutionException e) { e.printStackTrace(); } }).start(); new Thread(() -> { System.out.println("thread2"); try { String value = cache.get("key", new Callable<String>() { public String call() throws Exception { System.out.println("thread2"); //加載數據線程執行標志 Thread.sleep(1000); //模擬加載時間 return "thread2"; } }); System.out.println("thread2 " + value); } catch (ExecutionException e) { e.printStackTrace(); } }).start(); } }
結果:
輸出結果為:
thread1
thread2
thread2
thread1 thread2
thread2 thread2
可以看到輸出結果:兩個線程都啟動,輸出thread1,thread2,接着又輸出了thread2,說明進入了thread2的call方法了,此時thread1正在阻塞,等待key被設置。然后thread1 得到了value是thread2,thread2的結果自然也是thread2。
這段代碼中有兩個線程共享同一個Cache對象,兩個線程同時調用get方法獲取同一個key對應的記錄。由於key對應的記錄不存在,所以兩個線程都在get方法處阻塞。此處在call方法中調用Thread.sleep(1000)模擬程序從外存加載數據的時間消耗。
從結果中可以看出,雖然是兩個線程同時調用get方法,但只有一個get方法中的Callable會被執行(沒有打印出load2)。Guava可以保證當有多個線程同時訪問Cache中的一個key時,如果key對應的記錄不存在,Guava只會啟動一個線程執行get方法中Callable參數對應的任務加載數據存到緩存。當加載完數據后,任何線程中的get方法都會獲取到key對應的值。
統計信息
可以對Cache的命中率、加載數據時間等信息進行統計。在構建Cache對象時,可以通過CacheBuilder的recordStats方法開啟統計信息的開關。開關開啟后Cache會自動對緩存的各種操作進行統計,調用Cache的stats方法可以查看統計后的信息。
package com.transsnet.palmpay.product.web; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; public class GuavaCacheService { public static void main(String[] args) { Cache<String, String> cache = CacheBuilder.newBuilder() .maximumSize(3) .recordStats() //開啟統計信息開關 .build(); cache.put("1", "v1"); cache.put("2", "v2"); cache.put("3", "v3"); cache.put("4", "v4"); cache.getIfPresent("1"); cache.getIfPresent("2"); cache.getIfPresent("3"); cache.getIfPresent("4"); cache.getIfPresent("5"); cache.getIfPresent("6"); System.out.println(cache.stats()); //獲取統計信息 } }
結果:
輸出:
CacheStats{hitCount=3, missCount=3, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=1}
示例2:利用cache的過期超時功能
之所以想到 Guava 的 Cache,也是最近在做一個需求,大體如下:
從 Kafka 實時讀取出應用系統的日志信息,該日志信息包含了應用的健康狀況。
如果在時間窗口 N 內發生了 X 次異常信息,相應的我就需要作出反饋(報警、記錄日志等)。
對此 Guava 的 Cache 就非常適合,我利用了它的 N 個時間內不寫入數據時緩存就清空的特點,在每次讀取數據時判斷異常信息是否大於 X 即可。
偽代碼如下:
@Value("${alert.in.time:2}") private int time ; @Bean public LoadingCache buildCache(){ return CacheBuilder.newBuilder() .expireAfterWrite(time, TimeUnit.MINUTES) .build(new CacheLoader<Long, AtomicLong>() { @Override public AtomicLong load(Long key) throws Exception { return new AtomicLong(0); } }); } /** * 判斷是否需要報警 */ public void checkAlert() { try { if (counter.get(KEY).incrementAndGet() >= limit) { LOGGER.info("***********報警***********"); //將緩存清空 counter.get(KEY).getAndSet(0L); } } catch (ExecutionException e) { LOGGER.error("Exception", e); } }
首先是構建了 LoadingCache 對象,在 N 分鍾內不寫入數據時就回收緩存(當通過 Key 獲取不到緩存時,默認返回 0)。
然后在每次消費時候調用 checkAlert()
方法進行校驗,這樣就可以達到上文的需求。
我們來設想下 Guava 它是如何實現過期自動清除數據,並且是可以按照 LRU 這樣的方式清除的。
大膽假設下:
內部通過一個隊列來維護緩存的順序,每次訪問過的數據移動到隊列頭部,並且額外開啟一個線程來判斷數據是否過期,過期就刪掉。有點類似於我之前寫過的 動手實現一個 LRU cache
胡適說過:大膽假設小心論證
下面來看看 Guava 到底是怎么實現。
原理分析
看原理最好不過是跟代碼一步步走了:
示例代碼在這里:
8.png
為了能看出 Guava 是怎么刪除過期數據的在獲取緩存之前休眠了 5 秒鍾,達到了超時條件。
2.png
最終會發現在 com.google.common.cache.LocalCache
類的 2187 行比較關鍵。
再跟進去之前第 2182 行會發現先要判斷 count 是否大於 0,這個 count 保存的是當前緩存的數量,並用 volatile 修飾保證了可見性。
更多關於 volatile 的相關信息可以查看 你應該知道的 volatile 關鍵字
接着往下跟到:
3.png
2761 行,根據方法名稱可以看出是判斷當前的 Entry 是否過期,該 entry 就是通過 key 查詢到的。
4.png
這里就很明顯的看出是根據根據構建時指定的過期方式來判斷當前 key 是否過期了。
5.png
如果過期就往下走,嘗試進行過期刪除(需要加鎖,后面會具體討論)。
6.png
到了這里也很清晰了:
- 獲取當前緩存的總數量
- 自減一(前面獲取了鎖,所以線程安全)
- 刪除並將更新的總數賦值到 count。
其實大體上就是這個流程,Guava 並沒有按照之前猜想的另起一個線程來維護過期數據。
應該是以下原因:
- 新起線程需要資源消耗。
- 維護過期數據還要獲取額外的鎖,增加了消耗。
而在查詢時候順帶做了這些事情,但是如果該緩存遲遲沒有訪問也會存在數據不能被回收的情況,不過這對於一個高吞吐的應用來說也不是問題。
總結
最后再來總結下 Guava 的 Cache。
其實在上文跟代碼時會發現通過一個 key 定位數據時有以下代碼:
7.png
如果有看過 ConcurrentHashMap 的原理 應該會想到這其實非常類似。
其實 Guava Cache 為了滿足並發場景的使用,核心的數據結構就是按照 ConcurrentHashMap 來的,這里也是一個 key 定位到一個具體位置的過程。
先找到 Segment,再找具體的位置,等於是做了兩次 Hash 定位。
上文有一個假設是對的,它內部會維護兩個隊列 accessQueue,writeQueue
用於記錄緩存順序,這樣才可以按照順序淘汰數據(類似於利用 LinkedHashMap 來做 LRU 緩存)。
同時從上文的構建方式來看,它也是構建者模式來創建對象的。
因為作為一個給開發者使用的工具,需要有很多的自定義屬性,利用構建則模式再合適不過了。
Guava 其實還有很多東西沒談到,比如它利用 GC 來回收內存,移除數據時的回調通知等。之后再接着討論。
轉:https://ifeve.com/guava-source-cache/