LRU算法全稱是最近最少使用算法(Least Recently Use),普遍的應用於緩存機制中。當緩存使用的空間達到上限後,就須要從已有的數據中淘汰一部分以維持緩存的可用性,而淘汰數據的選擇就是經過LRU算法完成的。java
LRU算法的基本思想是基於局部性原理的時間局部性:node
若是一個信息項正在被訪問,那麼在近期它極可能還會被再次訪問。git
因此顧名思義,LRU算法會選出最近最少使用的數據進行淘汰。github
通常來說,LRU將訪問數據的順序或時間和數據自己維護在一個容器當中。當訪問一個數據時:算法
當數據的總量達到上限後,則移除容器中優先級最低的數據。下圖是一個簡單的LRU原理示意圖:sql
若是咱們按照7 0 1 2 0 3 0 4
的順序來訪問數據,且數據的總量上限爲3,則如上圖所示,LRU算法會依次淘汰7 1 2
這三個數據。apache
那麼咱們如今就按照上面的原理,實現一個樸素的LRU算法。下面有三種方案:數組
基於數組緩存
方案:爲每個數據附加一個額外的屬性——時間戳,當每一次訪問數據時,更新該數據的時間戳至當前時間。當數據空間已滿後,則掃描整個數組,淘汰時間戳最小的數據。併發
不足:維護時間戳須要耗費額外的空間,淘汰數據時須要掃描整個數組。
基於長度有限的雙向鏈表
方案:訪問一個數據時,當數據不在鏈表中,則將數據插入至鏈表頭部,若是在鏈表中,則將該數據移至鏈表頭部。當數據空間已滿後,則淘汰鏈表最末尾的數據。
不足:插入數據或取數據時,須要掃描整個鏈表。
基於雙向鏈表和哈希表
方案:爲了改進上面須要掃描鏈表的缺陷,配合哈希表,將數據和鏈表中的節點造成映射,將插入操做和讀取操做的時間複雜度從O(N)降至O(1)
下面咱們就基於雙向鏈表和哈希表實現一個LRU算法
public class LRUCache {
private int size; // 當前容量
private int capacity; // 限制大小
private Map<Integer, DoubleQueueNode> map; // 數據和鏈表中節點的映射
private DoubleQueueNode head; // 頭結點 避免null檢查
private DoubleQueueNode tail; // 尾結點 避免null檢查
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>(capacity);
this.head = new DoubleQueueNode(0, 0);
this.tail = new DoubleQueueNode(0, 0);
this.head.next = tail;
}
public Integer get(Integer key) {
DoubleQueueNode node = map.get(key);
if (node == null) {
return null;
}
// 數據在鏈表中,則移至鏈表頭部
moveToHead(node);
return node.val;
}
public Integer put(Integer key, Integer value) {
Integer oldValue;
DoubleQueueNode node = map.get(key);
if (node == null) {
// 淘汰數據
eliminate();
// 數據不在鏈表中,插入數據至頭部
DoubleQueueNode newNode = new DoubleQueueNode(key, value);
DoubleQueueNode temp = head.next;
head.next = newNode;
newNode.next = temp;
newNode.pre = head;
temp.pre = newNode;
map.put(key, newNode);
size++;
oldValue = null;
} else {
// 數據在鏈表中,則移至鏈表頭部
moveToHead(node);
oldValue = node.val;
node.val = value;
}
return oldValue;
}
public Integer remove(Integer key) {
DoubleQueueNode deletedNode = map.get(key);
if (deletedNode == null) {
return null;
}
deletedNode.pre.next = deletedNode.next;
deletedNode.next.pre = deletedNode.pre;
map.remove(key);
return deletedNode.val;
}
// 將節點插入至頭部節點
private void moveToHead(DoubleQueueNode node) {
node.pre.next = node.next;
node.next.pre = node.pre;
DoubleQueueNode temp = head.next;
head.next = node;
node.next = temp;
node.pre = head;
temp.pre = node;
}
private void eliminate() {
if (size < capacity) {
return;
}
// 將鏈表中最後一個節點去除
DoubleQueueNode last = tail.pre;
map.remove(last.key);
last.pre.next = tail;
tail.pre = last.pre;
size--;
last = null;
}
}
// 雙向鏈表節點
class DoubleQueueNode {
int key;
int val;
DoubleQueueNode pre;
DoubleQueueNode next;
public DoubleQueueNode(int key, int val) {
this.key = key;
this.val = val;
}
}
複製代碼
基本上就是把上述LRU算法思路用代碼實現了一遍,比較簡單,只須要注意一下pre和next兩個指針的指向和同步更新哈希表,put()和get()操做的時間複雜度都是O(1),空間複雜度爲O(N)。
其實咱們能夠直接根據JDK給咱們提供的LinkedHashMap直接實現LRU。由於LinkedHashMap的底層即爲雙向鏈表和哈希表的組合,因此能夠直接拿來使用。
public class LRUCache extends LinkedHashMap {
private int capacity;
public LRUCache(int capacity) {
// 注意這裏將LinkedHashMap的accessOrder設爲true
super(16, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return super.size() >= capacity;
}
}
複製代碼
默認LinkedHashMap並不會淘汰數據,因此咱們重寫了它的removeEldestEntry()方法,當數據數量達到預設上限後,淘汰數據,accessOrder設爲true意爲按照訪問的順序排序。整個實現的代碼量並不大,主要都是應用LinkedHashMap的特性。
正由於LinkedHashMap這麼好用,因此咱們能夠看到Dubbo的LRU緩存LRUCache也是基於它實現的。
樸素的LRU算法已經可以知足緩存的要求了,可是仍是有一些不足。當熱點數據較多時,有較高的命中率,可是若是有偶發性的批量操做,會使得熱點數據被非熱點數據擠出容器,使得緩存受到了「污染」。因此爲了消除這種影響,又衍生出了下面這些優化方法。
LRU-K算法是對LRU算法的改進,將原先進入緩存隊列的評判標準從訪問一次改成訪問K次,能夠說樸素的LRU算法爲LRU-1。
LRU-K算法有兩個隊列,一個是緩存隊列,一個是數據訪問歷史隊列。當訪問一個數據時,首先先在訪問歷史隊列中累加訪問次數,當歷史訪問記錄超過K次後,纔將數據緩存至緩存隊列,從而避免緩存隊列被污染。同時訪問歷史隊列中的數據能夠按照LRU的規則進行淘汰。具體以下圖所示:
下面咱們來實現一個LRU-K緩存:
// 直接繼承咱們前面寫好的LRUCache
public class LRUKCache extends LRUCache {
private int k; // 進入緩存隊列的評判標準
private LRUCache historyList; // 訪問數據歷史記錄
public LRUKCache(int cacheSize, int historyCapacity, int k) {
super(cacheSize);
this.k = k;
this.historyList = new LRUCache(historyCapacity);
}
@Override
public Integer get(Integer key) {
// 記錄數據訪問次數
Integer historyCount = historyList.get(key);
historyCount = historyCount == null ? 0 : historyCount;
historyList.put(key, ++historyCount);
return super.get(key);
}
@Override
public Integer put(Integer key, Integer value) {
if (value == null) {
return null;
}
// 若是已經在緩存裏則直接返回緩存中的數據
if (super.get(key) != null) {
return super.put(key, value);;
}
// 若是數據歷史訪問次數達到上限,則加入緩存
Integer historyCount = historyList.get(key);
historyCount = historyCount == null ? 0 : historyCount;
if (historyCount >= k) {
// 移除歷史訪問記錄
historyList.remove(key);
return super.put(key, value);
}
}
}
複製代碼
上面只是個簡單的模型,並無加上必要的併發控制。
通常來說,當K的值越大,則緩存的命中率越高,可是也會使得緩存難以被淘汰。綜合來講,使用LRU-2的性能最優。
Two Queue能夠說是LRU-2的一種變種,將數據訪問歷史改成FIFO隊列。好處的明顯的,FIFO更簡易,耗用資源更少,可是相比LRU-2會下降緩存命中率。
// 直接繼承LinkedHashMap
public class TwoQueueCache extends LinkedHashMap<Integer, Integer> {
private int k; // 進入緩存隊列的評判標準
private int historyCapacity; // 訪問數據歷史記錄最大大小
private LRUCache lruCache; // 咱們前面寫好的LRUCache
public TwoQueueCache(int cacheSize, int historyCapacity, int k) {
// 注意這裏設置LinkedHashMap的accessOrder爲false
super();
this.historyCapacity = historyCapacity;
this.k = k;
this.lruCache = new LRUCache(cacheSize);
}
public Integer get(Integer key) {
// 記錄數據訪問記錄
Integer historyCount = super.get(key);
historyCount = historyCount == null ? 0 : historyCount;
super.put(key, historyCount);
return lruCache.get(key);
}
public Integer put(Integer key, Integer value) {
if (value == null) {
return null;
}
// 若是已經在緩存裏則直接返回緩存中的數據
if (lruCache.get(key) != null) {
return lruCache.put(key, value);
}
// 若是數據歷史訪問次數達到上限,則加入緩存
Integer historyCount = super.get(key);
historyCount = historyCount == null ? 0 : historyCount;
if (historyCount >= k) {
// 移除歷史訪問記錄
super.remove(key);
return lruCache.put(key, value);
}
return null;
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return super.size() >= historyCapacity;
}
}
複製代碼
這裏直接繼承LinkedHashMap,而且accessOrder默認爲false,意爲按照插入順序進行排序,兩者結合即爲一個FIFO的隊列。經過重寫removeEldestEntry()方法來自動淘汰最先插入的數據。
相比於上面兩種優化,Multi Queue的實現則複雜的多,顧名思義,Multi Queue是由多個LRU隊列組成的。每個LRU隊列都有一個相應的優先級,數據會根據訪問次數計算出相應的優先級,並放在該隊列中。
數據插入和訪問:當數據首次插入時,會放入到優先級最低的Q0隊列。當再次訪問時,根據LRU的規則,會移至隊列頭部。當根據訪問次數計算的優先級提高後,會將該數據移至更高優先級的隊列的頭部,並刪除原隊列的該數據。一樣的,當該數據的優先級下降時,會移至低優先級的隊列中。
數據淘汰:數據淘汰老是從最低優先級的隊列的末尾數據進行,並將它加入到Q-history隊列的頭部。若是數據在Q-history數據中被訪問,則從新計算該數據的優先級,並將它加入到相應優先級的隊列中。不然就是按照LRU算法徹底淘汰。
Multi Queue也能夠看作是LRU-K的變種,將原來兩個隊列擴展爲多個隊列,好處就是不管是加入緩存仍是淘汰緩存數據都變得更加細膩,可是會帶來額外開銷。
本文講解了基本的LRU算法及它的幾種優化策略,並比較了他們之間的異同和優劣。之前沒有想到LRU還有這麼些門道,後續還會有Redis、Mysql對於LRU算法應用的文章。