讀這篇文章以前但願你能好好的閱讀: 你應該知道的緩存進化史 和 如何優雅的設計和使用緩存? 。這兩篇文章主要從一些實戰上面去介紹如何去使用緩存。在這兩篇文章中我都比較推薦Caffeine這款本地緩存去代替你的Guava Cache。本篇文章我將介紹Caffeine緩存的具體有哪些功能,以及內部的實現原理,讓你們知其然,也要知其因此然。有人會問:我不使用Caffeine這篇文章應該對我沒啥用了,彆着急,在Caffeine中的知識必定會對你在其餘代碼設計方面有很大的幫助。固然在介紹以前仍是要貼一下他和其餘緩存的一些比較圖:web
能夠看見Caffeine基本從各個維度都是相比於其餘緩存都高,廢話很少說,首先仍是先看看如何使用吧。
Caffeine使用比較簡單,API和Guava Cache一致:算法
public static void main(String[] args) {
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.expireAfterAccess(1,TimeUnit.SECONDS)
.maximumSize(10)
.build();
cache.put("hello","hello"); } 複製代碼
傳統的LFU受時間週期的影響比較大。因此各類LFU的變種出現了,基於時間週期進行衰減,或者在最近某個時間段內的頻率。一樣的LFU也會使用額外空間記錄每個數據訪問的頻率,即便數據沒有在緩存中也須要記錄,因此須要維護的額外空間很大。api
能夠試想咱們對這個維護空間創建一個hashMap,每一個數據項都會存在這個hashMap中,當數據量特別大的時候,這個hashMap也會特別大。數組
再回到LRU,咱們的LRU也不是那麼一無可取,LRU能夠很好的應對突發流量的狀況,由於他不須要累計數據頻率。緩存
因此W-TinyLFU結合了LRU和LFU,以及其餘的算法的一些特色。bash
首先要說到的就是頻率記錄的問題,咱們要實現的目標是利用有限的空間能夠記錄隨時間變化的訪問頻率。在W-TinyLFU中使用Count-Min Sketch記錄咱們的訪問頻率,而這個也是布隆過濾器的一種變種。以下圖所示:數據結構
若是須要記錄一個值,那咱們須要經過多種Hash算法對其進行處理hash,而後在對應的hash算法的記錄中+1,爲何須要多種hash算法呢?因爲這是一個壓縮算法一定會出現衝突,好比咱們創建一個Long的數組,經過計算出每一個數據的hash的位置。好比張三和李四,他們兩有可能hash值都是相同,好比都是1那Long[1]這個位置就會增長相應的頻率,張三訪問1萬次,李四訪問1次那Long[1]這個位置就是1萬零1,若是取李四的訪問評率的時候就會取出是1萬零1,可是李四命名只訪問了1次啊,爲了解決這個問題,因此用了多個hash算法能夠理解爲long[][]二維數組的一個概念,好比在第一個算法張三和李四衝突了,可是在第二個,第三個中很大的機率不衝突,好比一個算法大概有1%的機率衝突,那四個算法一塊兒衝突的機率是1%的四次方。經過這個模式咱們取李四的訪問率的時候取全部算法中,李四訪問最低頻率的次數。因此他的名字叫Count-Min Sketch。
這裏和之前的作個對比,簡單的舉個例子:若是一個hashMap來記錄這個頻率,若是我有100個數據,那這個HashMap就得存儲100個這個數據的訪問頻率。哪怕我這個緩存的容量是1,由於Lfu的規則我必須所有記錄這個100個數據的訪問頻率。若是有更多的數據我就有記錄更多的。app
在Count-Min Sketch中,我這裏直接說caffeine中的實現吧(在FrequencySketch這個類中),若是你的緩存大小是100,他會生成一個long數組大小是和100最接近的2的冪的數,也就是128。而這個數組將會記錄咱們的訪問頻率。在caffeine中規定頻率最大爲15,15的二進制位1111,總共是4位,而Long型是64位。因此每一個Long型能夠放16種算法,可是caffeine並無這麼作,只用了四種hash算法,每一個Long型被分爲四段,每段裏面保存的是四個算法的頻率。這樣作的好處是能夠進一步減小Hash衝突,原先128大小的hash,就變成了128X4。框架
一個Long的結構以下:異步
咱們的4個段分爲A,B,C,D,在後面我也會這麼叫它們。而每一個段裏面的四個算法我叫他s1,s2,s3,s4。下面舉個例子若是要添加一個訪問50的數字頻率應該怎麼作?咱們這裏用size=100來舉例。
這個時候有人會質疑頻率最大爲15的這個是否過小?不要緊在這個算法中,好比size等於100,若是他全局提高了size*10也就是1000次就會全局除以2衰減,衰減以後也能夠繼續增長,這個算法再W-TinyLFU的論文中證實了其能夠較好的適應時間段的訪問頻率。
在guava cache中咱們說過其讀寫操做中夾雜着過時時間的處理,也就是你在一次Put操做中有可能還會作淘汰操做,因此其讀寫性能會受到必定影響,能夠看上面的圖中,caffeine的確在讀寫操做上面完爆guava cache。主要是由於在caffeine,對這些事件的操做是經過異步操做,他將事件提交至隊列,這裏的隊列的數據結構是RingBuffer,不清楚的能夠看看這篇文章,你應該知道的高性能無鎖隊列Disruptor。而後會經過默認的ForkJoinPool.commonPool(),或者本身配置線程池,進行取隊列操做,而後在進行後續的淘汰,過時操做。
固然讀寫也是有不一樣的隊列,在caffeine中認爲緩存讀比寫多不少,因此對於寫操做是全部線程共享一個Ringbuffer。
對於讀操做比寫操做更加頻繁,進一步減小競爭,其爲每一個線程配備了一個RingBuffer:
在caffeine全部的數據都在ConcurrentHashMap中,這個和guava cache不一樣,guava cache是本身實現了個相似ConcurrentHashMap的結構。在caffeine中有三個記錄引用的LRU隊列:
Eden隊列:在caffeine中規定只能爲緩存容量的%1,若是size=100,那這個隊列的有效大小就等於1。這個隊列中記錄的是新到的數據,防止突發流量因爲以前沒有訪問頻率,而致使被淘汰。好比有一部新劇上線,在最開始實際上是沒有訪問頻率的,防止上線以後被其餘緩存淘汰出去,而加入這個區域。伊甸區,最舒服最安逸的區域,在這裏很難被其餘數據淘汰。
Probation隊列:叫作緩刑隊列,在這個隊列就表明你的數據相對比較冷,立刻就要被淘汰了。這個有效大小爲size減去eden減去protected。
Protected隊列:在這個隊列中,能夠稍微放心一下了,你暫時不會被淘汰,可是別急,若是Probation隊列沒有數據了或者Protected數據滿了,你也將會被面臨淘汰的尷尬局面。固然想要變成這個隊列,須要把Probation訪問一次以後,就會提高爲Protected隊列。這個有效大小爲(size減去eden) X 80% 若是size =100,就會是79。
這三個隊列關係以下:
對於發生數據淘汰的時候,會從Probation中進行淘汰。會把這個隊列中的數據隊頭稱爲受害者,這個隊頭確定是最先進入的,按照LRU隊列的算法的話那他其實他就應該被淘汰,可是在這裏只能叫他受害者,這個隊列是緩刑隊列,表明立刻要給他行刑了。這裏會取出隊尾叫候選者,也叫攻擊者。這裏受害者會和攻擊者皇城PK決出咱們應該被淘汰的。
經過咱們的Count-Min Sketch中的記錄的頻率數據有如下幾個判斷:
在Caffeine中功能比較多,下面來剖析一下,這些API究竟是如何生效的呢?
在Caffeine中有個LocalCacheFactory類,他會根據你的配置進行具體Cache的建立。
能夠看見他會根據你是否配置了過時時間,remove監聽器等參數,來進行字符串的拼裝,最後會根據字符串來生成具體的Cache,這裏的Cache太多了,做者的源碼並無直接寫這部分代碼,而是經過Java Poet進行代碼的生成:
在Caffeine中分爲兩種緩存,一個是有界緩存,一個是無界緩存,無界緩存不須要過時而且沒有界限。在有界緩存中提供了三個過時API:
在Caffeine中有個scheduleDrainBuffers方法,用來進行咱們的過時任務的調度,在咱們讀寫以後都會對其進行調用:
首先他會進行加鎖,若是鎖失敗說明有人已經在執行調度了。他會使用默認的線程池ForkJoinPool或者自定義線程池,這裏的drainBuffersTask實際上是Caffeine中PerformCleanupTask。
在performCleanUp方法中再次進行加鎖,防止其餘線程進行清理操做。而後咱們進入到maintenance方法中:
能夠看見裏面有挺多方法的,其餘方法稍後再討論,這裏咱們重點關注expireEntries(),也就是用來過時的方法:
這裏根據咱們的配置evicts()方法爲true,因此會從三個隊列都進行過時淘汰,上面已經說過了這三個隊列都是LRU隊列,因此咱們的expireAfterAccessEntries方法,只須要把各個隊列的頭結點進行判斷是否訪問過時而後進行剔除便可。
能夠看見這裏依賴了一個隊列writeQrderDeque,這個隊列的數據是何時填充的呢?固然也是使用異步,具體方法在咱們上面的draninWriteBuffer中,他會將咱們以前放進RingBuffer的Task拿出來執行,其中也包括添加writeQrderDeque。過時的策略很簡單,直接循環彈出第一個判斷其是否過時便可。
在上面的方法中咱們能夠看見,是利用時間輪,來進行過時處理的,時間輪是什麼呢?想必熟悉一些定時任務系統對其並不陌生,他是一個高效的處理定時任務的結構,能夠簡單的將其看作是一個多維數組。在Caffeine中是一個二層時間輪,也就是二維數組,其一維的數據表示較大的時間維度好比,秒,分,時,天等,其二維的數據表示該時間維度較小的時間維度,好比秒內的某個區間段。當定位到一個TimeWhile[i][j]以後,其數據結構實際上是一個鏈表,記錄着咱們的Node。在Caffeine利用時間輪記錄咱們在某個時間過時的數據,而後去處理。
在Caffeine中的時間輪如上面所示。在咱們插入數據的時候,根據咱們重寫的方法計算出他應該過時的時間,好比他應該在1536046571142時間過時,上一次處理過時時間是1536046571100,對其相減則獲得42ms,而後將其放入時間輪,因爲其小於1.07s,因此直接放入1.07s的位置,以及第二層的某個位置(須要通過必定的算法算出),使用尾插法插入鏈表。
處理過時時間的時候會算出上一次處理的時間和當前處理的時間的差值,須要將其這個時間範圍以內的全部時間輪的時間都進行處理,若是某個Node其實沒有過時,那麼就須要將其從新插入進時間輪。
Caffeine提供了refreshAfterWrite()方法來讓咱們進行寫後多久更新策略:
上面的代碼咱們須要創建一個CacheLodaer來進行刷新,這裏是同步進行的,能夠經過buildAsync方法進行異步構建。在實際業務中這裏能夠把咱們代碼中的mapper傳入進去,進行數據源的刷新。
注意這裏的刷新並非到期就刷新,而是對這個數據再次訪問以後,纔會刷新。舉個例子:有個key:'咖啡',value:'拿鐵' 的數據,咱們設置1s刷新,咱們在添加數據以後,等待1分鐘,按理說下次訪問時他會刷新,獲取新的值,惋惜並無,訪問的時候仍是返回'拿鐵'。可是繼續訪問的話就會發現,他已經進行了刷新了。
咱們來看看自動刷新他是怎麼作的呢?自動刷新只存在讀操做以後,也就是咱們afterRead()這個方法,其中有個方法叫refreshIfNeeded,他會根據你是同步仍是異步而後進行刷新處理。
在Java中有四種引用類型:強引用(StrongReference)、軟引用(SoftReference)、弱引用(WeakReference)、虛引用(PhantomReference)。
在Caffeine中支持弱引用的淘汰策略,其中有兩個api: weakKeys()和weakValues(),用來設置key是弱引用仍是value是弱引用。具體原理是在put的時候將key和value用虛引用進行包裝並綁定至引用隊列:
。
具體回收的時候,在咱們前面介紹的maintenance方法中,有兩個方法:
//處理key引用的
drainKeyReferences();
//處理value引用
drainValueReferences();
複製代碼
具體的處理的代碼有:
由於咱們的key已經被回收了,而後他會進入引用隊列,經過這個引用隊列,一直彈出到他爲空爲止。咱們能根據這個隊列中的運用獲取到Node,而後對其進行驅逐。
注意:不少同窗覺得在緩存中內部是存儲的Key-Value的形式,其實存儲的是KeyReference - Node(Node中包含Value)的形式。
在Caffeine中還支持軟引用的淘汰策略,其api是softValues(),軟引用只支持Value不支持Key。咱們能夠看見在Value的回收策略中有:
和key引用回收類似,可是要說明的是這裏的引用隊列,有多是軟引用隊列,也有多是弱引用隊列。
在Caffeine中提供了一些的打點監控策略,經過recordStats()Api進行開啓,默認是使用Caffeine自帶的,也能夠本身進行實現。 在StatsCounter接口中,定義了須要打點的方法目前來講有以下幾個:
經過上面的監聽,咱們能夠實時監控緩存當前的狀態,以評估緩存的健康程度以及緩存命中率等,方便後續調整參數。
有不少時候咱們須要知道Caffeine中的緩存爲何被淘汰了呢,從而進行一些優化?這個時候咱們就須要一個監聽器,代碼以下所示:
Cache<String, String> cache = Caffeine.newBuilder()
.removalListener(((key, value, cause) -> {
System.out.println(cause);
}))
.build();
複製代碼
在Caffeine中被淘汰的緣由有不少種:
當咱們進行淘汰的時候就會進行回調,咱們能夠打印出日誌,對數據淘汰進行實時監控。