文章目錄
1.背景
我們在各類緩存等應用場景中都涉及到LRU算法。接下來我仔細分析一下LRU算法的設計思路及代碼實現。以及介紹一些擴展知識點。
2.LRU詳解
2.1 什麼是LRU?
LRU是什麼?按照英文的直接原義就是Least Recently Used,最近最久未使用法(去掉最久未使用的數據),它是按照一個非常著名的計算機操作系統基礎理論得來的:最近使用的頁面數據會在未來一段時期內仍然被使用,已經很久沒有使用的頁面很有可能在未來較長的一段時間內仍然不會被使用。基於這個思想,會存在一種緩存淘汰機制,每次從內存中找到最久未使用的數據然後置換出來,從而存入新的數據!它的主要衡量指標是使用的時間(順序),附加指標是使用的次數。在計算機中大量使用了這個機制,它的合理性在於優先篩選熱點數據,所謂熱點數據,就是最近最多使用的數據!因此,利用LRU我們可以解決很多實際開發中的問題,並且很符合業務場景。
2.2 LRU算法設計
2.2.1、數據結構。
由LRU概念原理分析,我們需要的這個數據結構必須滿足條件:查找快,插入快,刪除快,有順序之分。那麼,什麼數據結構同時符合上述條件呢?哈希表查找快,但是數據無固定順序;鏈表有順序之分,插入刪除快,但是查找慢。所以結合一下,形成一種新的數據結構:哈希鏈表。LRU 緩存算法的核心數據結構就是哈希鏈表,雙向鏈表和哈希表的結合體。這個數據結構長這樣:
雙向鏈表有一個特點就是它的鏈表是雙路的,我們定義好頭節點和尾節點,然後利用先進先出(FIFO),最近被放入的數據會最早被獲取。其中主要涉及到新增、訪問、修改、刪除操作。
2.2.2、新增&修改
新增和修改的邏輯是一樣的:
- 先查詢鏈表中此key是否存在。
- 如果存在,則爲修改,用新值覆蓋舊值。將整個節點移動至隊尾。
- 如果不存在,則是新增。先判斷是否超過鏈表容量,如果超過則將隊首(最久未使用)節點刪除後進行下一步。如果未超過容量,直接進行下一步。
- 將新節點,放在鏈表隊尾,其他的元素順序往隊首移動;
具體實現時,判斷鏈表容量時,還有擴容一說。具體原理參考HashMap存儲擴容原理。
2.2.3、訪問
- 先查詢鏈表中此key是否存在。若不存在則返回null。
- 若存在,則返回對應的value。
- 若節點在隊尾則不用管。若在隊中或隊首,則將對應節點移動至隊尾。
- 返回爲null還有一種情況,即對應key的節點存在,但其值爲null。
2.2.4、刪除
- 判斷節點存在。
- 移除節點,其他節點順序移動位置。
3.LRU實現
3.1 Java自己封裝實現
- 定義基本的鏈表操作節點
public class Node {
//鍵
Object key;
//值
Object value;
//上一個節點
Node pre;
//下一個節點
Node next;
public Node(Object key, Object value) {
this.key = key;
this.value = value;
}
}
- 鏈表基本定義
我們定義一個LRU類,然後定義它的大小、容量、隊尾節點、隊首節點等部分,然後一個基本的構造方法
public class LRU<K, V> {
private int currentSize;//當前的大小
private int capcity;//總容量
private HashMap<K, Node> caches;//所有的node節點
private Node last;//隊尾節點
private Node first;//隊首節點
public LRU(int size) {
currentSize = 0;
this.capcity = size;
caches = new HashMap<K, Node>(size);
}
- 添加(修改)元素
添加(修改)元素的時候首先判斷是不是新的元素,如果是新元素,判斷當前的大小是不是大於總容量了,防止超過總鏈表大小,如果大於的話直接拋棄隊首第一個節點,然後再以傳入的key\value值創建新的節點。對於已經存在的元素,直接覆蓋舊值,再將該元素移動到隊尾部,然後保存在map中。
/**
* 添加(修改)元素
* @param key
* @param value
*/
public void put(K key, V value) {
Node node = caches.get(key);
//如果新元素
if (node == null) {
//如果超過元素容納量
if (caches.size() >= capcity) {
//移除隊首第一個節點
caches.remove(first.key);
removeLast();
}
//創建新節點
node = new Node(key,value);
}
//已經存在的元素覆蓋舊值
node.value = value;
//把元素移動到隊尾部
moveToLast(node);
caches.put(key, node);
}
如下所示,訪問key=3這個節點的時候,需要把3移動到隊尾,這樣能保證整個鏈表的隊尾(最近)節點一定是特點數據(最近使用的數據!)
4. 訪問元素
通過key值來訪問元素,主要的做法就是先判斷如果是不存在的,直接返回null。如果存在,把數據移動到隊尾部成爲最後的節點,然後再返回舊值。
/**
* 通過key獲取元素
* @param key
* @return
*/
public Object get(K key) {
Node node = caches.get(key);
if (node == null) {
return null;
}
//把訪問的節點移動到尾部
moveToLast(node);
return node.value;
}
- 節點刪除操作
在根據key刪除節點的操作中,我們需要做的是把節點的前一個節點的指針指向當前節點下一個位置,再把當前節點的下一個的節點的上一個指向當前節點的前一個,這麼說有點繞,我們來畫圖來看:
/**
* 根據key移除節點
* @param key
* @return
*/
public Object remove(K key) {
Node node = caches.get(key);
if (node != null) {
if (node.pre != null) {
node.pre.next = node.next;
}
if (node.next != null) {
node.next.pre = node.pre;
}
if (node == last) {
last = node.next;
}
if (node == first) {
first = node.pre;
}
}
return caches.remove(key);
}
假設現在要刪除3這個元素,我們第一步要做的就是把3的pre節點4(這裏說的都是key值)的下一個指針指向3的下一個節點2,再把3的下一個節點2的上一個指針指向3的上一個節點4,這樣3就消失了,從4和2之間斷開了,4和2再也不需要3來進行連接,從而實現刪除的效果。
- 移動元素到隊尾節點
首先把當前節點移除,類似於刪除的效果(但是沒有移除該元素),然後再將隊尾節點設爲當前節點的下一個,再把當前節點設爲隊尾節點的前一個節點。當前節點設爲隊尾節點。再把當前節點的前一個節點設爲null,這樣就是間接替換了隊尾節點爲當前節點。
/**
* 把當前節點移動到隊尾部
* @param node
*/
private void moveToLast(Node node) {
if (last == node) {
return;
}
if (node.next != null) {
node.next.pre = node.pre;
}
if (node.pre != null) {
node.pre.next = node.next;
}
if (node == fisrt) {
fisrt= fisrt.pre;
}
if (last == null || first == null) {
last = first = node;
return;
}
node.next = last;
last.pre = node;
last= node;
last.pre = null;
}
3.2 基於LinkedHashMap實現
對於上述的實現思路,java.util.LinkedHashMap已經實現了其中的99%,因此直接基於LinkedHashMap實現LRUCache非常簡單。
LinkedHashMap爲LRUCache鋪墊了什麼
-
LinkedHashMap本質上還是複用HashMap的絕大部分功能,包括底層的Node<K, V>[],因此能支持原本HashMap的功能。
-
構造方法提供了accessOrder選項,傳入true後,get時會將訪問的節點移至隊尾(爲最近使用節點)。
-
覆蓋了父類HashMap的newNode方法和newTreeNode方法,這兩個方法在HashMap中只是創建Node用的,而在LinkedHashMap中不但創建Node,還將Node放在鏈表末尾。
-
父類HashMap提供了3個void的Hook方法,方法沒做任何事:
afterNodeRemoval 父類在remove一個集合中存在的元素後調用
afterNodeInsertion 父類在put、compute、merge後調用
afterNodeAccess 父類在replace、compute、merge等替換值後會調用LinkedHashMap實現了父類HashMap的這3個Hook方法,並新增一個方法:
afterNodeRemoval 實現鏈表的刪除操作
afterNodeInsertion 並沒有實現鏈表的插入操作
afterNodeAccess 如前所述,設置accessOrder爲true後會將被操作的節點放在鏈表末尾,保證鏈表順序按訪問順序逆序排列
removeEldestEntry 新添 加了一個Hook方法boolean removeEldestEntry,當這個Hook方法返回true時,刪除鏈表隊首的節點,但這個方法在LinkedHashMap中的實現永遠返回false,要使用需要複寫。
到這爲止,實現一個LRUCache就很簡單了:實現這個removeEldestEntryHook方法,給LinkedHashMap設置一個閾值,那麼超過這個閾值時就會進行LRU淘汰。
網上隨處可見的Java代碼實現
// 繼承LinkedHashMap
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int MAX_CACHE_SIZE;
public LRUCache(int cacheSize) {
// 使用構造方法 public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
// initialCapacity 初始化容量大小、loadFactor 負載因子
// accessOrder要設置爲true,按訪問排序
super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
MAX_CACHE_SIZE = cacheSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// 超過閾值時返回true,進行LRU淘汰
return size() > MAX_CACHE_SIZE;
}
}
看似幾行代碼解決的事兒,其實只是冰山一角而已。以前百度三面的時候,遇到面時官問我爲什麼要基於LinkedHashMap實現,就是以上這個知識點。
4.彩蛋
4.1 FIFO算法及實現
先進先出,如果緩存容量滿,則優先移出最早加入緩存的數據;其內部可以使用隊列實現。
- 特點
- Object get(key):獲取保存的數據,如果數據不存在或者已經過期,則返回null。
- void put(key,value,expireTime):加入緩存,無論此key是否已存在,均作爲新key處理(移除舊key);如果空間不足,則移除已過期的key,如果沒有,則移除最早加入緩存的key。過期時間未指定,則表示永不自動過期。
- 此題需要注意,我們允許key是有過期時間的,這一點與普通的FIFO有所區別,所以在設計此題時需要注意。(也是面試考察點,此題偏設計而非算法)
普通的FIFO或許大家都能很簡單的寫出,此處增加了過期時間的特性,所以在設計時需要多考慮。如下示例,爲FIFO的簡易設計,尚未考慮併發環境場景。
- 設計思路
- 用普通的hashMap保存緩存數據。
- 我們需要額外的map用來保存key的過期特性,例子中使用了TreeMap,將“剩餘存活時間”作爲key,利用treemap的排序特性。
public class FIFOCache {
//按照訪問時間排序,保存所有key-value
private final Map<String,Value> CACHE = new LinkedHashMap<>();
//過期數據,只保存有過期時間的key
//暫不考慮併發,我們認爲同一個時間內沒有重複的key,如果改造的話,可以將value換成set
private final TreeMap<Long, String> EXPIRED = new TreeMap<>();
private final int capacity;
public FIFOCache(int capacity) {
this.capacity = capacity;
}
public Object get(String key) {
//
Value value = CACHE.get(key);
if (value == null) {
return null;
}
//如果不包含過期時間
long expired = value.expired;
long now = System.nanoTime();
//已過期
if (expired > 0 && expired <= now) {
CACHE.remove(key);
EXPIRED.remove(expired);
return null;
}
return value.value;
}
public void put(String key,Object value) {
put(key,value,-1);
}
public void put(String key,Object value,int seconds) {
//如果容量不足,移除過期數據
if (capacity < CACHE.size()) {
long now = System.nanoTime();
//有過期的,全部移除
Iterator<Long> iterator = EXPIRED.keySet().iterator();
while (iterator.hasNext()) {
long _key = iterator.next();
//如果已過期,或者容量仍然溢出,則刪除
if (_key > now) {
break;
}
//一次移除所有過期key
String _value = EXPIRED.get(_key);
CACHE.remove(_value);
iterator.remove();
}
}
//如果仍然容量不足,則移除最早訪問的數據
if (capacity < CACHE.size()) {
Iterator<String> iterator = CACHE.keySet().iterator();
while (iterator.hasNext() && capacity < CACHE.size()) {
String _key = iterator.next();
Value _value = CACHE.get(_key);
long expired = _value.expired;
if (expired > 0) {
EXPIRED.remove(expired);
}
iterator.remove();
}
}
//如果此key已存在,移除舊數據
Value current = CACHE.remove(key);
if (current != null && current.expired > 0) {
EXPIRED.remove(current.expired);
}
//如果指定了過期時間
if(seconds > 0) {
long expireTime = expiredTime(seconds);
EXPIRED.put(expireTime,key);
CACHE.put(key,new Value(expireTime,value));
} else {
CACHE.put(key,new Value(-1,value));
}
}
private long expiredTime(int expired) {
return System.nanoTime() + TimeUnit.SECONDS.toNanos(expired);
}
public void remove(String key) {
Value value = CACHE.remove(key);
if(value == null) {
return;
}
long expired = value.expired;
if (expired > 0) {
EXPIRED.remove(expired);
}
}
class Value {
long expired; //過期時間,納秒
Object value;
Value(long expired,Object value) {
this.expired = expired;
this.value = value;
}
}
}
4.2 LFU算法及實現
最近最不常用,當緩存容量滿時,移除訪問次數最少的元素,如果訪問次數相同的元素有多個,則移除最久訪問的那個。設計要求參見leetcode 460( LFU Cache)
public class LFUCache {
//主要容器,用於保存k-v
private Map<String, Object> keyToValue = new HashMap<>();
//記錄每個k被訪問的次數
private Map<String, Integer> keyToCount = new HashMap<>();
//訪問相同次數的key列表,按照訪問次數排序,value爲相同訪問次數到key列表。
private TreeMap<Integer, LinkedHashSet<String>> countToLRUKeys = new TreeMap<>();
private int capacity;
public LFUCache(int capacity) {
this.capacity = capacity;
//初始化,默認訪問1次,主要是解決下文
}
public Object get(String key) {
if (!keyToValue.containsKey(key)) {
return null;
}
touch(key);
return keyToValue.get(key);
}
/**
* 如果一個key被訪問,應該將其訪問次數調整。
* @param key
*/
private void touch(String key) {
int count = keyToCount.get(key);
keyToCount.put(key, count + 1);//訪問次數增加
//從原有訪問次數統計列表中移除
countToLRUKeys.get(count).remove(key);
//如果符合最少調用次數到key統計列表爲空,則移除此調用次數到統計
if (countToLRUKeys.get(count).size() == 0) {
countToLRUKeys.remove(count);
}
//然後將此key的統計信息加入到管理列表中
LinkedHashSet<String> countKeys = countToLRUKeys.get(count + 1);
if (countKeys == null) {
countKeys = new LinkedHashSet<>();
countToLRUKeys.put(count + 1,countKeys);
}
countKeys.add(key);
}
public void put(String key, Object value) {
if (capacity <= 0) {
return;
}
if (keyToValue.containsKey(key)) {
keyToValue.put(key, value);
touch(key);
return;
}
//容量超額之後,移除訪問次數最少的元素
if (keyToValue.size() >= capacity) {
Map.Entry<Integer,LinkedHashSet<String>> entry = countToLRUKeys.firstEntry();
Iterator<String> it = entry.getValue().iterator();
String evictKey = it.next();
it.remove();
if (!it.hasNext()) {
countToLRUKeys.remove(entry.getKey());
}
keyToCount.remove(evictKey);
keyToValue.remove(evictKey);
}
keyToValue.put(key, value);
keyToCount.put(key, 1);
LinkedHashSet<String> keys = countToLRUKeys.get(1);
if (keys == null) {
keys = new LinkedHashSet<>();
countToLRUKeys.put(1,keys);
}
keys.add(key);
}
}
4.3 HashMap數據結構及源碼解析
HashMap數據結構及主要方法解析,我推薦下面這位兄弟的幾篇博客,他已經解釋的很詳細了,所以我就不再轉述。
- HashMap-----數據結構、常量、成員變量、構造方法
- HashMap-----tableSizeFor()
- HashMap-----resize()
- HashMap-----put()
- HashMap-----get(key)、containsKey(key)
關於HashMap相關知識點我記錄幾點我需要注意的:
-
白話數據結構及查找。
HashMap是一個數組結構,數組結構中每一個元素(即Bucket)是鏈表結構。每一個Bucket有着不同的key產出的相同的hashcode,不同的key-value存儲在這個鏈表結構中。當查詢時,先拿着key產出hashcode,找到對應的bucket,然後去比較key,找到對應的value。
-
鏈表擴容。
-
新增元素時,先判斷鏈表是不是空,如果是空,則初始化。所以很多時候懷疑是懶加載實現。
-
鏈表擴容裏面有幾個參數:
DEFAULT_INITIAL_CAPACITY = 1 << 4(桶個數16)默認容量
DEFAULT_LOAD_FACTOR = 0.75f(負載因子0.75)
TREEIFY_THRESHOLD = 8(樹化閾值8)由鏈表轉換成紅黑樹
MIN_TREEIFY_CAPACITY = 64(樹化要求的最少哈希表元素數量)最小紅黑樹的容量
UNTREEIFY_THRESHOLD = 6(解除樹化的閾值,在resize階段)由紅黑樹轉換成鏈表
MAXIMUM_CAPACITY = 1 << 30 (最大的容量值230)1). 當判斷Bucket個數(容量)時,先判斷了當前容量是否需要擴容,是否能擴容。
2). 如果需要且能擴容,則擴容1倍。比如默認16個Bucket,當第13>16*0.75=12個節點需要加入的時候,先完成擴容變爲可以裝32個Bucket後,纔將第13個節點加至隊尾。
3). 當每個Bucket中的Map中的元素達到樹化閾值=8時,此時這個Bucket的結構從鏈表結構變爲樹結構存儲。當然此時它的容量最小都是64,即每個Bucket至少能存64個元素。當每個Bucket中Map中的元素元素減少,由大於8個減少至6個時,又由樹結構轉換成鏈表結構存儲。
4). 當達到最大容量,不能再擴容時,就依據LRU算法,去掉最近最少使用的節點,爲新節點騰坑。
- 容量 ,必須是2的指數冪。
tableSizeFor(initialCapacity): 將傳入的initialCapacity做計算,返回一個大於等於initialCapacity的最小2的冪次方。(就是不管你傳入的初始化Hash桶的長度參數爲多少,最後通過這個方法會將Hash表的初始化長度改爲2的冪次方 )例如:你傳入的initialCapacity=6,計算出來的結果就爲8。傳入10,結果就是16.
至於原因爲何我沒看懂。具體可以去根據這篇文章分析:HashMap-----tableSizeFor()這裏可以解釋一下爲什麼要求table的長度爲2的冪
n爲2的冪,那麼化成二進制就是100…00,減一之後成爲0111…11
對於小於n-1的hash值,索引位置就是hash,大於n-1的就是取模,這樣在獲取table索引可以提高&運算的速度且最後一位爲1,這樣保證散列的均勻性。
本文有參考:
https://www.cnblogs.com/wyq178/p/9976815.html
https://www.cnblogs.com/kyoner/p/11179766.html
https://www.jianshu.com/p/a8a012195385
https://juejin.im/post/5d244abfe51d454fa33b1953
https://blog.csdn.net/meng_lemon/article/details/88857670
對以上文章的作者表示感謝。