這是why哥的第 83 篇原創文章
![](https://i.iter01.com/images/5b583419dc8bf6081defc5f76a50c6298cd57f9b125d5a5207942f999358aa65.png)
讓人摳腦殼的 LFU
前幾天在某APP看到了這樣的一個討論:
![](https://i.iter01.com/images/bff537f6b5e9e4c8ab175cb2662d487662a0d6c5948dfaab699fbae2eae2d151.png)
看到一個有點意思的評論:
![](https://i.iter01.com/images/0015e8fc17bd0c022ac60e35e15cad0c41f639fc52f70ed958c030ba16b48024.jpg)
LFU 是真的難,腦殼都給我摳疼了。
如果說 LRU 是 Easy 模式的話,那麼把中間的字母從 R(Recently) 變成 F(Frequently),即 LFU ,那就是 hard 模式了。
你不認識 Frequently 沒關係,畢竟這是一個英語專八的詞彙,我這個英語八級半的選手教你:
![](https://i.iter01.com/images/aa1df117bad4bb2bc4dc611b71b220867d35cb912882cc18faea1d3e89456de4.png)
所以 LFU 的全稱是Least Frequently Used,最不經常使用策略。
很明顯,強調的是使用頻率。
而 LRU 演算法的全稱是Least Recently Used。最近最少使用演算法。
強調的是時間。
當統計的維度從時間變成了頻率之後,在演算法實現上發生了什麼變化呢?
這個問題先按下不表,我先和之前寫過的 LRU 演算法進行一個對比。
LRU vs LFU
LRU 演算法的思想是如果一個資料在最近一段時間沒有被訪問到,那麼在將來它被訪問的可能性也很小。所以,當指定的空間已存滿資料時,應當把最久沒有被訪問到的資料淘汰。
也就是淘汰資料的時候,只看資料在快取裡面待的時間長短這個維度。
而 LFU 在快取滿了,需要淘汰資料的時候,看的是資料的訪問次數,被訪問次數越多的,就越不容易被淘汰。
但是呢,有的資料的訪問次數可能是相同的。
怎麼處理呢?
如果訪問次數相同,那麼再考慮資料在快取裡面待的時間長短這個維度。
也就是說 LFU 演算法,先看訪問次數,如果次數一致,再看快取時間。
給大家舉個具體的例子。
假設我們的快取容量為 3,按照下列資料順序進行訪問:
![](https://i.iter01.com/images/d0dd45933ffb38bfce26bb99f66cc8cd066c677d9225c0609d6d37d799423b0f.png)
如果按照 LRU 演算法進行資料淘汰,那麼十次訪問的結果如下:
![](https://i.iter01.com/images/0b248475a7ca3e58933703d5255119b60086ca2bd15dac9200ede18b9813dd6b.png)
十次訪問結束後,快取中剩下的是 b,c,d 這三個元素。
你有沒有覺得有一絲絲不對勁?
![](https://i.iter01.com/images/d7966aaad3f2acd6af45b4371ba752d3c1893963ba7ba79708113260b5de7f9c.png)
十次訪問中元素 a 被訪問了 5 次,結果最後元素 a 被淘汰了?
如果按照 LFU 演算法,最後留在快取中的三個元素應該是 b,c,a。
這樣看來,LFU 比 LRU 更加合理,更加巴適。
假設,要我們實現一個 LFUCache:
class LFUCache {
public LFUCache(int capacity) {
}
public int get(int key) {
}
public void put(int key, int value) {
}
}
那麼思路應該是怎樣的呢?
帶你瞅一眼。
LFU 方案一 - 一個雙向連結串列
如果在完全沒有接觸過 LFU 演算法之前,讓我硬想,我能想到的方案也只能是下面這樣的:
因為既需要有頻次,又需要有時間順序。
我們就可以搞個連結串列,先按照頻次排序,頻次一樣的,再按照時間排序。
因為這個過程中我們需要刪除節點,所以為了效率,我們使用雙向連結串列。
還是假設我們的快取容量為 3,還是用剛剛那組資料進行演示。
我們把頻次定義為 freq,那麼前三次訪問結束後,即這三個請求訪問結束後:
![](https://i.iter01.com/images/aeca92fa9545a3b9896787ce1278688e17e12bad9527ebedbf2f377e51cf2a2d.png)
連結串列裡面應該是這樣的:
![](https://i.iter01.com/images/797e71bd011114144f9c11c54ae2f1afbaa793f97947732f3f64a10c446047d8.png)
三個元素的訪問頻次都是 1。
對於前三個元素來說,value=a 是頻次相同的情況下,最久沒有被訪問到的元素,所以它就是 head 節點的下一個元素,隨時等著被淘汰。
接著過來了 1 個 value=a 的請求:
![](https://i.iter01.com/images/363b8c2f975ba4e2446f425a700827f5ca6d4af40e9de4ec26ce9b1fd7f9210b.png)
當這個請求過來的時候,連結串列中的 value=a 的節點的頻率(freq)就變成了2。
此時,它的頻率最高,最不應該被淘汰。
因此,連結串列變成了下面這個樣子:
![](https://i.iter01.com/images/f1d42973e43ea5835aeafd50f89b648a0b562430cfd5eb592fdd7e4ed688190a.png)
接著連續來了 3 個 value=a 的請求:
![](https://i.iter01.com/images/098f21b01cde9d5d10165b615deebe1a5078411de8819a111a07d47443b17a39.png)
此時的連結串列變化就集中在 value=a 這個節點的頻率(freq)上:
![](https://i.iter01.com/images/e8f884b32f9d8964b1b8a44e725f22bdfb1887d79d860c33ca817e924b67bfd1.png)
接著,這個 b 請求過來了:
![](https://i.iter01.com/images/a8911702247acc45135570c08e6658c4401f7b9a7d65e3029255621c83e53df2.png)
b 節點的 freq 從 1 變成了 2,節點的位置也發生了變化:
![](https://i.iter01.com/images/354e02e44c614b263d6a21660b14c387ca8ce23ad5bb31d0e23bfd47aabbf2ea.png)
然後,c 請求過來:
![](https://i.iter01.com/images/1918f769dcd43db167da16821abe3fc107964ecedcb3f4744f39d5494fe330f7.png)
你說這個時候會發生什麼事情?
連結串列中的 c 當前的訪問頻率是 1,當這個 c 請求過來後,那麼連結串列中的 c 的頻率就會變成 2。
你說巧不巧,此時,value=b 節點的頻率也是 2。
撞車了,那麼你說,這個時候怎麼辦?
前面說了:頻率一樣的時候,看時間。
value=c 的節點是正在被訪問的,所以要淘汰也應該淘汰之前被訪問的 value=b 的節點。
此時的連結串列,就應該是這樣的:
![](https://i.iter01.com/images/2564b0f2ebafcc0131317f38a3ab4ddf6df3ba33338826b060df7f86484a3d2f.png)
然後,最後一個請求過來了:
![](https://i.iter01.com/images/b593c6443721ec3199deb7835b00fc3240187de1e56eed1da0fbf63911fda76e.png)
d 元素,之前沒有在連結串列裡面出現過,而此時連結串列的容量也滿了。
該進行淘汰了。
於是把 head 的下一個節點(value=b)淘汰,並把 value=d 的節點插入:
![](https://i.iter01.com/images/dc07bed8ea591ac98070a53fa3e351076fb3413db99789704c0493364fa3d26c.png)
最終,所有請求完畢。
留在快取中的是 d,c,a 這三個元素。
整體的流程就是這樣的:
![](https://i.iter01.com/images/23d2a74e7f3072f364bae49ce08c9ced2f492c85517b1cbac47b11000cea22ba.png)
當然,這裡只是展示了連結串列的變化。
其實我們放的是 key-value 鍵值對。
所以應該還有一個 HashMap 來儲存 key 和連結串列節點的對映關係。
這個簡單,用腳趾頭都能想到,我也就不展開來說了。
按照上面這個思路,你慢慢的寫程式碼,應該是能寫出來的。
上面這個雙連結串列的方案,就是扣著腦殼硬想,大部分人能直接想到的方案。
面試官要的肯定是時間複雜度為 O(1) 的解決方案。
現在的這個解決方案時間複雜度為 O(N)。
![](https://i.iter01.com/images/47fb2773eeddb63b05acc25c674bb19a4f8e334f196fc76ec0eb96f51ed81a4c.webp)
O(1) 解法
如果我們要拿出時間複雜度為 O(1) 的解法,我們就得細細的分析了,不能扣著腦殼硬想了。
先分析一下需求。
第一點:我們需要根據 key 查詢其對應的 value。
用腳趾頭都能想到,用 HashMap 儲存 key-value 鍵值對。
查詢時間複雜度為 O(1),滿足。
第二點:每當我們操作一個 key 的時候,不論是查詢還是新增,都需要維護這個 key 的頻次,記作 freq。
因為我們需要頻繁的操作 key 對應的 freq,也就是得在時間複雜度為 O(1) 的情況下,獲取到指定 key 的 freq。
來,請你大聲的告訴我,用什麼資料結構?
是不是還得再來一個 HashMap 儲存 key 和 freq 的對應關係?
第三點:如果快取裡面放不下了,需要淘汰資料的時候,把 freq 最小的 key 刪除掉。
注意啊,上面這句話:[把 freq 最小的 key 刪除掉]。
freq 最小?
我們怎麼知道哪個 key 的 freq 最小呢?
前面說了,有一個 HashMap 儲存 key 和 freq 的對應關係。
當然我們可以遍歷這個 HashMap,來獲取到 freq 最小的 key。
但是啊,朋友們,遍歷出現了,那麼時間複雜度還會是 O(1) 嗎?
那怎麼辦呢?
注意啊,高潮來了,一學就廢,一點就破。
我們可以搞個變數來記錄這個最小的 freq 啊,記為 minFreq,不就行了?
![](https://i.iter01.com/images/9a1275375061383da8d1b625520f0a96f5c765ac20e41540c7159aae55e3148a.png)
現在我們有最小頻次(minFreq)了,需要獲取到這個最小頻次對應的 key,時間複雜度得為 O(1)。
來,朋友,請你大聲的告訴我,你又想起了什麼資料結構?
是不是又想到了 HashMap?
好了,我們現在有三個 HashMap 了,給大家介紹一下:
一個儲存 key 和 value 的 HashMap,即HashMap<key,value>。 一個儲存 key 和 freq 的 HashMap,即HashMap<key,freq>。 一個儲存 freq 和 key 的 HashMap,即HashMap<freq,key>。
它們每個都是各司其職,目的都是為了時間複雜度為 O(1)。
但是我們可以把前兩個 HashMap 合併一下。
我們弄一個物件,物件裡面包含兩個屬性分別是value、freq。
假設這個物件叫做 Node,它就是這樣的,頻次預設為 1:
class Node {
int value;
int freq = 1;
//建構函式省略
}
那麼現在我們就可以把前面兩個 HashMap ,替換為一個了,即 HashMap<key,Node>。
同理,我們可以在 Node 裡面再加入一個 key 屬性:
class Node {
int key;
int value;
int freq = 1;
//建構函式省略
}
因為 Node 裡面包含了 key,所以可以把第三個 HashMap<freq,key> 替換為 HashMap<freq,Node>。
到這一步,我們還差了一個非常關鍵的資訊沒有補全,就是下面這一個點。
第四點:可能有多個 key 具有相同的最小的 freq,此時移除這一批資料在快取中待的時間最長的那個元素。
這個需求,我們需要通過 freq 查詢 Node,那麼操作的就是 HashMap<freq,Node> 這個雜湊表。
上面說[多個 key 具有相同的最小的 freq],也就是說通過 minFreq ,是可以查詢到多個 Node 的。
所以HashMap<freq,Node> 這個雜湊表,應該是這樣的:
HashMap<freq,集合
此時的問題就變成了:我們應該用什麼集合來裝這個 Node 物件呢?
不慌,我們先理一下這個集合需要滿足什麼條件。
首先,需要刪除 Node 的時候。
因為這個集合裡面裝的是訪問頻次一樣的資料,那麼希望這批資料能有時序,這樣可以快速的刪除待的時間最久的 Node。
有時序,能快速查詢刪除待的時間最久的 key,你能想到什麼資料結構?
這不就是雙向連結串列嗎?
然後,需要訪問 Node 的時候。
一個 Node 被訪問,那麼它的頻次必然就會加一。
比如下面這個例子:
![](https://i.iter01.com/images/c917b0b21c340f25ae33a0c3e6213bc69e47c5adfa1a12a6e1aac648d2bd1963.png)
假設最小訪問頻次就是 5,而 5 對應了 3 個 Node 物件。
此時,我要訪問 value=b 的物件,那麼該物件就會從 key=5 的 value 中移走。
然後頻次加一,即 5+1=6。
加入到 key=6 的 value 集合中,變成下面這個樣子:
![](https://i.iter01.com/images/b39e0b5bb1df77471712f02aceb77bee78a5e67c60925d9ae1a070959e531982.png)
也就是說我們得支援任意 node 的快速刪除。
我們可以針對上面的需求,自定義一個雙向連結串列。
但是在 Java 集合類中,有一個滿足上面說的有序且支援快速刪除的條件的集合。
那就是 LinkedHashSet。
所以,HashMap<freq,集合
總結一下。
我們需要兩個 HashMap,分別是 HashMap<key,Node> 和 HashMap<freq,LinkedHashSet
然後還需要維護一個最小訪問頻次,minFreq。
哦,對了,還得來一個引數記錄快取支援的最大容量,capacity。
沒了。
有的小夥伴肯定要問了:你倒是給我一份程式碼啊?
![](https://i.iter01.com/images/296afc937ee09dcb47b19d40c72d5a244735c751775911c8d3e4e7f86ec16d0e.png)
這些分析出來了,程式碼自己慢慢就擼出來了。
思路清晰後再去寫程式碼,就算面試的時候沒有寫出 bug free 的程式碼,也基本上八九不離十了。
Dubbo 中的 LFU 演算法
Dubbo 在 2.7.7 版本之後支援了 LFU 演算法:
![](https://i.iter01.com/images/b183aca0dd853ddf8ff210a0b7915de3d366de9a5b3dd209740e687b821ae971.png)
其原始碼的位置是:org.apache.dubbo.common.utils.LFUCache
![](https://i.iter01.com/images/1bdcd2db2fe4d0f69afbad0b58e13b7c8822651ec8b2bbe0efe444b2afbf1a13.png)
程式碼不長,總共就 200 多行,和我們上面說的 LFU 實現起來還有點不一樣。
你可以看到它甚至沒有維護 minFreq。
但是這些都不重要,打個斷點除錯一下很快就能分析出來作者的程式碼思路。
重要的是,我在看 Dubbo 的 LFU 演算法的時候發現了一個 bug。
不是指這個 LFU 演算法實現上的 bug,演算法實現我看了是沒有問題的。
bug 是 Dubbo 雖然加入了 LFU 快取演算法的實現,但是作為使用者,卻不能使用。
問題出在哪裡呢?
我帶你瞅一眼。
原始碼裡面告訴我這樣配置一下就可以使用 lfu 的快取策略:
![](https://i.iter01.com/images/4ae95ad814d0ab9feb84056e8aa9c7d3061da69adc504e76bd5fe4c8f1b7da1d.png)
但是,當我這樣配置,發起呼叫之後,是這樣的:
![](https://i.iter01.com/images/ef3f740daa692a9157364250017da281b2119fb624470aeb1374da26001b7744.png)
可以看到當前請求的快取策略確實是 lfu。
但是會丟擲一個錯誤:
![](https://i.iter01.com/images/e0fdfbc1f63b7832dc8a5740e30c1f4357013de8055354ad1722d0895ce2b90a.png)
No such extension org.apache.dubbo.cache.CacheFactory by name lfu
沒有 lfu 這個策略。
這不是玩我嗎?
![](https://i.iter01.com/images/9099a53cd4d0850adc82ae2a819af0fca5741b5019a0f823da81607eaa010cff.png)
再看一下具體的原因。
在org.apache.dubbo.common.extension.ExtensionLoader#getExtensionClasses
處只獲取到了 4 個快取策略,並沒有我們想要的 LFU:
![](https://i.iter01.com/images/c4317f5b381e9b4ad365d463d4b7cb1f84cccbefca6e65ee02b77f9ab3239d66.png)
所以,在這裡丟擲了異常:
![](https://i.iter01.com/images/065f93fefcdc85b459670ba66efe315797d777f414d964d3343692938987a361.png)
為什麼沒有找到我們想要的 LFU 呢?
那就的看你熟不熟悉 SPI 了。
在 SPI 檔案中,確實沒有 lfu 的配置:
![](https://i.iter01.com/images/2a37fdef21c44e564c91483fc745d1a8f7e4f36cd4e431e650607801e195cc67.png)
這就是 bug。
所以怎麼解決呢?
非常簡單,加上就完事了。
![](https://i.iter01.com/images/f6a3de4d2a4a0af95fdd7bfcdbe98ae45748933155912aed38d981969aeb83b3.png)
害,一不小心又給 Dubbo 貢獻了一行原始碼。
![](https://i.iter01.com/images/ea82f103d61acef17af9645abf669e8854f476393213d314e26a4a2e973e01d4.png)
最後說一句(求關注)
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在後臺提出來,我對其加以修改。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是 why,一個主要寫程式碼,經常寫文章,偶爾拍視訊的程式猿。
![](https://i.iter01.com/images/c44dcca4750c5ed744cfb333baf3bd0ff44fe74de5faa8129f84b27c422c95f0.png)