動手實現 LRU 演演算法,以及 Caffeine 和 Redis 中的快取淘汰策略
我是風箏,公眾號「古時的風箏」。 文章會收錄在 JavaNewBee 中,更有 Java 後端知識圖譜,從小白到大牛要走的路都在裡面。
那天我在 LeetCode 上刷到一道 LRU 快取機制的問題,第 146 題,難度為中等,題目如下。
運用你所掌握的資料結構,設計和實現一個 LRU (最近最少使用) 快取機制。它應該支援以下操作: 獲取資料 get 和 寫入資料 put 。
獲取資料 get(key) - 如果關鍵字 (key) 存在於快取中,則獲取關鍵字的值(總是正數),否則返回 -1。
寫入資料 put(key, value) - 如果關鍵字已經存在,則變更其資料值;如果關鍵字不存在,則插入該組「關鍵字/值」。當快取容量達到上限時,它應該在寫入新資料之前刪除最久未使用的資料值,從而為新的資料值留出空間。
LRU 全名 Least Recently Used,意為最近最少使用,注重最近使用的時間,是常用的快取淘汰策略。為了加快訪問速度,快取可以說無處不在,無論是計算機內部的快取,還是 Java 程式中的 JVM 快取,又或者是網站架構中的 Redis 快取。快取雖然好用,但快取內容可不能無限增加,要受儲存空間的約束,當空間不足的時候,只能選擇刪除一部分內容。那刪除哪些內容呢,這就涉及到淘汰策略了,而 LRU 應該是各種快取架構最常用的淘汰策略了。也就是當記憶體不足,新內容進來時,會將最近最少使用的元素刪掉。
我一看這題我熟啊,當初看 LinkedHashMap
原始碼的時候,原始碼中有註釋提到了它可以用來實現 LRU 快取。原文是這麼寫的。
A special {@link #LinkedHashMap(int,float,boolean) constructor} is provided to create a linked hash map whose order of iteration is the order in which its entries were last accessed, from least-recently accessed to most-recently (<i>access-order</i>). This kind of map is well-suited to building LRU caches.
翻譯過來大意如下:
通過一個特殊的建構函式,三個引數的這種,最後一個布林值引數表示是否要維護最近訪問順序,如果是 true 的話會維護最近訪問的順序,如果是 false 的話,只會維護插入順序。保證維護最近最少使用的順序。LinkedHashMap
這種結構非常適合構造 LRU 快取。
當我看到這段註釋的時候,特意去查了一下用 LinkedHashMap
實現 LRU 的方法。
public class LRUCache {
private int cacheSize;
private LinkedHashMap<Integer,Integer> linkedHashMap;
public LRUCache(int capacity) {
this.cacheSize = capacity;
linkedHashMap = new LinkedHashMap<Integer,Integer>(capacity,0.75F,true){
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size()>cacheSize;
}
};
}
public int get(int key) {
return this.linkedHashMap.getOrDefault(key,-1);
}
public void put(int key, int value) {
this.linkedHashMap.put(key,value);
}
}
這是根據這道題的寫法,如果不限定這個題目的話,可以讓 LRUCache
繼承 LinkedHashMap
,然後再重寫 removeEldestEntry
方法即可。
看到沒,就是這麼簡單,LinkedHashMap
已經完美實現了 LRU,這個方法是在插入鍵值對的時候呼叫的,如果返回 true,就刪除最近最少使用的元素,所以只要判斷 size()
是否大於 cacheSize
即可,cacheSize
就是快取的最大容量。
提交,順利通過,完美!
LRU 簡單實現
你以為這麼簡單就完了嗎,並沒有。當我檢視官方題解的時候,發現裡面是這麼說的。
在
Java
語言中,同樣有類似的資料結構LinkedHashMap
。這些做法都不會符合面試官的要求。
什麼,這麼完美還不符合面試官要求,面試官是什麼要求呢?面試官的要求是考考你 LRU 的原理,讓你自己實現一個。
那咱們就由LinkedHashMap
介紹一下最基礎的 LRU 實現。簡單概括 LinkedHashMap
的實現原理就是 HashMap
+雙向連結串列的結合。
雙向連結串列用來維護元素訪問順序,將最近被訪問(也就是調動 get 方法)的元素放到連結串列尾部,一旦超過快取容量的時候,就從連結串列頭部刪除元素,用雙向連結串列能保證元素移動速度最快,假設訪問了連結串列中的某個元素,只要把這個元素移動連結串列尾部,然後修改這個元素的 prev 和 next 節點的指向即可。
雙向連結串列節點的型別的基本屬性如下:
static class Node {
/**
* 快取 key
*/
private int key;
/**
* 快取值
*/
private int value;
/**
* 當前節點的前驅節點
*/
private Node prev;
/**
* 當前節點的後驅節點
*/
private Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
HashMap
用來儲存 key 值對應的節點,為的是快速定位 key 值在連結串列中的位置,我們都知道,這是因為HashMap
的 get 方法的時間複雜度為 O(1)。而如果不借助 HashMap
,那這個過程可就慢了。如果要想找一個 key,要從連結串列頭或連結串列尾遍歷才行。
按上圖的展示, head 是連結串列頭,也是最長時間未被訪問的節點,tail 是最近被訪問的元素,假設快取最大容量是 4 。
插入元素
當有新元素被插入,先判斷快取容量是否超過最大值了,如果超過,就將頭節點刪除,然後將頭結點的 next 節點設定為 head,同時刪除 HashMap
中對應的 key。然後將插入的元素放到連結串列尾部,設定此元素為尾節,並在 HashMap
中儲存下來。
如果沒超過最大容量,直接插入到尾部。
訪問元素
當訪問其中的某個 key 時,先從 HashMap
中快速找到這個節點。如果這個 key 不是尾節點,那麼就將此節的前驅節點的 next 指向此節點的後驅節點,此節點的後驅節點的 prev 指向此節點的前驅節點。 同時,將這個節點移動到尾部,並將它設定為尾結點。
下面這個動圖,演示了 get key2 時的移動情況。
刪除元素
如果是刪除頭節點,則將此節點的後驅節點的 prev 設定為 null,並將它設定為 head,同時,刪除 HashMap
中此節點的 key。
如果是刪除尾節點,則將此節點的前驅節點的 next 設定為 null,並將它設定為 tail,同時,刪除HashMap
中此節點的 key。
如果是中間節點,則將此節的前驅節點的 next 指向此節點的後驅節點,此節點的後驅節點的 prev 指向此節點的前驅節點,同時,刪除HashMap
中此節點的 key。
動手實現
思路就是這麼一個思路,有了這個思路我擼起袖子開始寫程式碼,由於自身演演算法比較渣,而且又好長時間不刷演演算法,所以我的慘痛經歷如下。
先是執行出錯,後來又解答錯誤,頓時開始懷疑人生,懷疑智商。最後發現,確實是智商問題。
總歸就是這麼一個意思,你也去寫一遍試試吧,看看效果如何。原題地址:https://leetcode-cn.com/problems/lru-cache/
除了 LRU 還有 LFU
還有一種常用的淘汰策略叫做 LFU(Least Frequently Used),最不經常使用。想比於LFU 更加註重訪問頻次。在 LRU 的基礎上增加了訪問頻次。
看下圖,舉個例子來說,假設現在 put 進來一個鍵值對,並且超過了最大的容量,那就要刪除一個鍵值對。假設 key2 是在 5 分鐘之前訪問過一次,而 key1 是在 10 分鐘之前訪問過,以 LRU 的策略來說,就會刪除頭節點,也就是圖中的 key1。但是如果是 LFU 的話,會記錄每個 key 的訪問頻次,雖然 key2 是最近一次訪問晚於 key1,但是它的頻次比 key1 少,那要淘汰一個 key 的話,還是要淘汰 key2 的。只是舉個例子,真正的 LFU 資料結構比 LRU 要複雜。
看 LeetCode 上的難度等級就知道了,LFU 也有一道對應的題目,地址:https://leetcode-cn.com/problems/lfu-cache/,它的難度是困難,而 LRU 的難度是中等。
還有一種 FIFO ,先進先出策略,先進入快取的會先被淘汰,比起上面兩種,它的命中率比較低。
優缺點分析
LRU的優點:LRU相比於 LFU 而言效能更好一些,因為它演演算法相對比較簡單,不需要記錄訪問頻次,可以更好的應對突發流量。
LRU的缺點:雖然效能好一些,但是它通過歷史資料來預測未來是侷限的,它會認為最後到來的資料是最可能被再次訪問的,從而給與它最高的優先順序。有些非熱點資料被訪問過後,佔據了高優先順序,它會在快取中佔據相當長的時間,從而造成空間浪費。
LFU的優點:LFU根據訪問頻次訪問,在大部分情況下,熱點資料的頻次肯定高於非熱點資料,所以它的命中率非常高。
LFU的缺點:LFU 演演算法相對比較複雜,效能比 LRU 差。有問題的是下面這種情況,比如前一段時間微博有個熱點話題熱度非常高,就比如那種可以讓微博短時間停止服務的,於是趕緊快取起來,LFU 演演算法記錄了其中熱點詞的訪問頻率,可能高達十幾億,而過後很長一段時間,這個話題已經不是熱點了,新的熱點也來了,但是,新熱點話題的熱度沒辦法到達十幾億,也就是說訪問頻次沒有之前的話題高,那之前的熱點就會一直佔據著快取空間,長時間無法被剔除。
針對以上這些問題,現有的快取框架都會做一系列改進。比如 JVM 本地快取 Caffeine,或者分散式快取 Redis。
Caffeine 中的快取淘汰策略
Caffeine 是一款高效能的 JVM 快取框架,是目前 Spring 5.x 中的預設快取框架,之前版本是用的 Guava Cache。
為了改進上述 LRU 和 LFU 存在的問題,前Google工程師在 TinyLfu的基礎上發明瞭 W-TinyLFU 快取演演算法。Caffine 就是基於此演演算法開發的。
Caffeine 因使用 Window TinyLfu 回收策略,提供了一個近乎最佳的命中率。
TinyLFU維護了近期訪問記錄的頻率資訊,作為一個過濾器,當新記錄來時,只有滿足TinyLFU要求的記錄才可以被插入快取。
TinyLFU藉助了資料流Sketching技術,它可以用小得多的空間存放頻次資訊。TinyLFU採用了一種基於滑動視窗的時間衰減設計機制,藉助於一種簡易的 reset 操作:每次新增一條記錄到Sketch的時候,都會給一個計數器上加 1,當計數器達到一個尺寸 W 的時候,把所有記錄的 Sketch 數值都除以 2,該 reset 操作可以起到衰減的作用 。
W-TinyLFU主要用來解決一些稀疏的突發訪問元素。在一些數目很少但突發訪問量很大的場景下,TinyLFU將無法儲存這類元素,因為它們無法在給定時間內積累到足夠高的頻率。因此 W-TinyLFU 就是結合 LFU 和LRU,前者用來應對大多數場景,而 LRU 用來處理突發流量。
在處理頻次記錄方面,採用 Bloom Filter,對於每個key,用 n 個 byte 每個儲存一個標誌用來判斷 key 是否在集合中。原理就是使用 k 個 hash 函式來將 key 雜湊成一個整數。
在 W-TinyLFU 中使用 Count-Min Sketch 記錄 key 的訪問頻次,而它就是布隆過濾器的一個變種。
Redis 中的快取淘汰策略
Redis 支援如下 8 中淘汰策略,其中最後兩種 LFU 的是 4.0 版本之後新加的。
noeviction:當記憶體使用超過配置的時候會返回錯誤,不會驅逐任何鍵
allkeys-lru:加入鍵的時候,如果過限,首先通過LRU演演算法驅逐最久沒有使用的鍵
volatile-lru:加入鍵的時候如果過限,首先從設定了過期時間的鍵集合中驅逐最久沒有使用的鍵
allkeys-random:加入鍵的時候如果過限,從所有key隨機刪除
volatile-random:加入鍵的時候如果過限,從過期鍵的集合中隨機驅逐
volatile-ttl:從配置了過期時間的鍵中驅逐馬上就要過期的鍵
volatile-lfu:從所有配置了過期時間的鍵中驅逐使用頻率最少的鍵
allkeys-lfu:從所有鍵中驅逐使用頻率最少的鍵
最常用的就是兩種 LRU 和 兩種 LFU 的。
通過在 redis.conf 配置檔案中配置如下配置項,來設定最大容量和採用的快取淘汰策略。
maxmemory 1024M
maxmemory-policy volatile-lru
Redis 中的 LRU
Redis使用的是近似LRU演演算法,它跟常規的LRU演演算法還不太一樣,它並不維護佇列,而是隨機取樣法淘汰資料,每次隨機選出5(預設)個key,從裡面淘汰掉最近最少使用的key。
通過配置 maxmemory-samples
設定隨機取樣大小。
maxmemory-samples 5
LRU 演演算法會維護一個淘汰候選池(大小為16),池中的資料根據訪問時間進行排序,第一次隨機選取的key都會放入池中,隨後每次隨機選取的key只有在訪問時間小於池中最小的時間才會放入池中,直到候選池被放滿。當放滿後,如果有新的key需要放入,則將池中最後訪問時間最大(最近被訪問)的移除。當需要淘汰 key 的時候,則直接從池中選取最近訪問時間最小(最久沒被訪問)的 key 淘汰掉即可。
Redis 中的 LFU
LFU 演演算法是 4.0 之後才加入進來的。
上面 LRU 演演算法中會按照訪問時間進行淘汰,這個訪問時間是 Redis 中維護的一個 24 位時鐘,也就是當前時間戳,每個 key 所在的物件也維護著一個時鐘欄位,當訪問一個 key 的時候,會拿到當前的全域性時鐘,然後將這個時鐘值賦給這個 key 所在物件維護的時鐘欄位,之後的按時間比較就是根據這個時鐘欄位。
而 LFU 演演算法就是利用的這個欄位,24位分成兩部分,前16位還代表時鐘,後8位代表一個計數器。16位的情況下如果還按照秒為單位就會導致不夠用,所以一般這裡以時鐘為單位。而後8位表示當前key物件的訪問頻率,8位只能代表255,但是redis並沒有採用線性上升的方式,而是通過一個複雜的公式,通過配置兩個引數來調整資料的遞增速度。
lfu-log-factor 10
lfu-decay-time 1
在影響因子 lfu-log-factor 為10的情況下,經過1百萬次命中才能達到 255。
本文完。
送給你
種一棵樹最好的時間是十年前,其次是現在。送給各位,也送給自己。
公眾號「古時的風箏」,Java 開發者,全棧工程師,人稱遲到小王子,bug 殺手,擅長解決問題。
一個兼具深度與廣度的程式設計師鼓勵師,本打算寫詩卻寫起了程式碼的田園碼農!堅持原創乾貨輸出,你可選擇現在就關注我,或者看看歷史文章再關注也不遲。長按二維碼關注,跟我一起變優秀!