1. LRU
1.1.?原理
LRU(Least?recently?used,最近最少使用)算法根據(jù)數(shù)據(jù)的歷史訪問記錄來進行淘汰數(shù)據(jù),其核心思想是“如果數(shù)據(jù)最近被訪問過,那么將來被訪問的幾率也更高”。
最常見的實現(xiàn)是使用一個鏈表保存緩存數(shù)據(jù),詳細算法實現(xiàn)如下:
1.?新數(shù)據(jù)插入到鏈表頭部;
2.?每當緩存命中(即緩存數(shù)據(jù)被訪問),則將數(shù)據(jù)移到鏈表頭部;
3.?當鏈表滿的時候,將鏈表尾部的數(shù)據(jù)丟棄。
【命中率】
當存在熱點數(shù)據(jù)時,LRU的效率很好,但偶發(fā)性的、周期性的批量操作會導(dǎo)致LRU命中率急劇下降,緩存污染情況比較嚴重。
【復(fù)雜度】
實現(xiàn)簡單。
【代價】
命中時需要遍歷鏈表,找到命中的數(shù)據(jù)塊索引,然后需要將數(shù)據(jù)移到頭部。
LRU-K中的K代表最近使用的次數(shù),因此LRU可以認為是LRU-1。LRU-K的主要目的是為了解決LRU算法“緩存污染”的問題,其核心思想是將“最近使用過1次”的判斷標準擴展為“最近使用過K次”。
相比LRU,LRU-K需要多維護一個隊列,用于記錄所有緩存數(shù)據(jù)被訪問的歷史。只有當數(shù)據(jù)的訪問次數(shù)達到K次的時候,才將數(shù)據(jù)放入緩存。當需要淘汰數(shù)據(jù)時,LRU-K會淘汰第K次訪問時間距當前時間最大的數(shù)據(jù)。詳細實現(xiàn)如下:
1.?數(shù)據(jù)第一次被訪問,加入到訪問歷史列表;
2.?如果數(shù)據(jù)在訪問歷史列表里后沒有達到K次訪問,則按照一定規(guī)則(FIFO,LRU)淘汰;
3.?當訪問歷史隊列中的數(shù)據(jù)訪問次數(shù)達到K次后,將數(shù)據(jù)索引從歷史隊列刪除,將數(shù)據(jù)移到緩存隊列中,并緩存此數(shù)據(jù),緩存隊列重新按照時間排序;
4.?緩存數(shù)據(jù)隊列中被再次訪問后,重新排序;
5.?需要淘汰數(shù)據(jù)時,淘汰緩存隊列中排在末尾的數(shù)據(jù),即:淘汰“倒數(shù)第K次訪問離現(xiàn)在最久”的數(shù)據(jù)。
LRU-K具有LRU的優(yōu)點,同時能夠避免LRU的缺點,實際應(yīng)用中LRU-2是綜合各種因素后最優(yōu)的選擇,LRU-3或者更大的K值命中率會高,但適應(yīng)性差,需要大量的數(shù)據(jù)訪問才能將歷史訪問記錄清除掉。
【命中率】
LRU-K降低了“緩存污染”帶來的問題,命中率比LRU要高。
【復(fù)雜度】
LRU-K隊列是一個優(yōu)先級隊列,算法復(fù)雜度和代價比較高。
【代價】
由于LRU-K還需要記錄那些被訪問過、但還沒有放入緩存的對象,因此內(nèi)存消耗會比LRU要多;當數(shù)據(jù)量很大的時候,內(nèi)存消耗會比較可觀。
LRU-K需要基于時間進行排序(可以需要淘汰時再排序,也可以即時排序),CPU消耗比LRU要高。
Two?queues(以下使用2Q代替)算法類似于LRU-2,不同點在于2Q將LRU-2算法中的訪問歷史隊列(注意這不是緩存數(shù)據(jù)的)改為一個FIFO緩存隊列,即:2Q算法有兩個緩存隊列,一個是FIFO隊列,一個是LRU隊列。
當數(shù)據(jù)第一次訪問時,2Q算法將數(shù)據(jù)緩存在FIFO隊列里面,當數(shù)據(jù)第二次被訪問時,則將數(shù)據(jù)從FIFO隊列移到LRU隊列里面,兩個隊列各自按照自己的方法淘汰數(shù)據(jù)。詳細實現(xiàn)如下:
1.?新訪問的數(shù)據(jù)插入到FIFO隊列;
2.?如果數(shù)據(jù)在FIFO隊列中一直沒有被再次訪問,則最終按照FIFO規(guī)則淘汰;
3.?如果數(shù)據(jù)在FIFO隊列中被再次訪問,則將數(shù)據(jù)移到LRU隊列頭部;
4.?如果數(shù)據(jù)在LRU隊列再次被訪問,則將數(shù)據(jù)移到LRU隊列頭部;
5.?LRU隊列淘汰末尾的數(shù)據(jù)。
注:上圖中FIFO隊列比LRU隊列短,但并不代表這是算法要求,實際應(yīng)用中兩者比例沒有硬性規(guī)定。
【命中率】
2Q算法的命中率要高于LRU。
【復(fù)雜度】
需要兩個隊列,但兩個隊列本身都比較簡單。
【代價】
FIFO和LRU的代價之和。
2Q算法和LRU-2算法命中率類似,內(nèi)存消耗也比較接近,但對于最后緩存的數(shù)據(jù)來說,2Q會減少一次從原始存儲讀取數(shù)據(jù)或者計算數(shù)據(jù)的操作。
MQ算法根據(jù)訪問頻率將數(shù)據(jù)劃分為多個隊列,不同的隊列具有不同的訪問優(yōu)先級,其核心思想是:優(yōu)先緩存訪問次數(shù)多的數(shù)據(jù)。
MQ算法將緩存劃分為多個LRU隊列,每個隊列對應(yīng)不同的訪問優(yōu)先級。訪問優(yōu)先級是根據(jù)訪問次數(shù)計算出來的,例如
詳細的算法結(jié)構(gòu)圖如下,Q0,Q1....Qk代表不同的優(yōu)先級隊列,Q-history代表從緩存中淘汰數(shù)據(jù),但記錄了數(shù)據(jù)的索引和引用次數(shù)的隊列:
如上圖,算法詳細描述如下:
1.?新插入的數(shù)據(jù)放入Q0;
2.?每個隊列按照LRU管理數(shù)據(jù);
3.?當數(shù)據(jù)的訪問次數(shù)達到一定次數(shù),需要提升優(yōu)先級時,將數(shù)據(jù)從當前隊列刪除,加入到高一級隊列的頭部;
4.?為了防止高優(yōu)先級數(shù)據(jù)永遠不被淘汰,當數(shù)據(jù)在指定的時間里訪問沒有被訪問時,需要降低優(yōu)先級,將數(shù)據(jù)從當前隊列刪除,加入到低一級的隊列頭部;
5.?需要淘汰數(shù)據(jù)時,從最低一級隊列開始按照LRU淘汰;每個隊列淘汰數(shù)據(jù)時,將數(shù)據(jù)從緩存中刪除,將數(shù)據(jù)索引加入Q-history頭部;
6.?如果數(shù)據(jù)在Q-history中被重新訪問,則重新計算其優(yōu)先級,移到目標隊列的頭部;
7.?Q-history按照LRU淘汰數(shù)據(jù)的索引。
【命中率】
MQ降低了“緩存污染”帶來的問題,命中率比LRU要高。
【復(fù)雜度】
MQ需要維護多個隊列,且需要維護每個數(shù)據(jù)的訪問時間,復(fù)雜度比LRU高。
【代價】
MQ需要記錄每個數(shù)據(jù)的訪問時間,需要定時掃描所有隊列,代價比LRU要高。
注:雖然MQ的隊列看起來數(shù)量比較多,但由于所有隊列之和受限于緩存容量的大小,因此這里多個隊列長度之和和一個LRU隊列是一樣的,因此隊列掃描性能也相近。
由于不同的訪問模型導(dǎo)致命中率變化較大,此處對比僅基于理論定性分析,不做定量分析。
對比點
對比
命中率
LRU-2?>?MQ(2)?>?2Q?>?LRU
復(fù)雜度
LRU-2?>?MQ(2)?>?2Q?>?LRU
代價
LRU-2??>?MQ(2)?>?2Q?>?LRU
實際應(yīng)用中需要根據(jù)業(yè)務(wù)的需求和對數(shù)據(jù)的訪問情況進行選擇,并不是命中率越高越好。例如:雖然LRU看起來命中率會低一些,且存在”緩存污染“的問題,但由于其簡單和代價小,實際應(yīng)用中反而應(yīng)用更多。
java中最簡單的LRU算法實現(xiàn),就是利用jdk的LinkedHashMap,覆寫其中的removeEldestEntry(Map.Entry)方法即可
如果你去看LinkedHashMap的源碼可知,LRU算法是通過雙向鏈表來實現(xiàn),當某個位置被命中,通過調(diào)整鏈表的指向?qū)⒃撐恢谜{(diào)整到頭位置,新加入的內(nèi)容直接放在鏈表頭,如此一來,最近被命中的內(nèi)容就向鏈表頭移動,需要替換時,鏈表最后的位置就是最近最少使用的位置。
import java.util.ArrayList;?
?import java.util.Collection;?
?import java.util.LinkedHashMap;?
?import java.util.concurrent.locks.Lock;?
?import java.util.concurrent.locks.ReentrantLock;?
?import java.util.Map;? ? ??
? /**? * 類說明:利用LinkedHashMap實現(xiàn)簡單的緩存, 必須實現(xiàn)removeEldestEntry方法,具體參見JDK文檔? *??
?*?@author dennis? *??
?* @param*?
@param*/?
public class LRULinkedHashMapextends LinkedHashMap{? ? ??
private final int maxCapacity;? ? ? ?
?private static final float DEFAULT_LOAD_FACTOR = 0.75f;? ? ? ?
?private final Lock lock = new ReentrantLock();? ? ? ??
?public LRULinkedHashMap(int maxCapacity) {? ? ? ? ?
?super(maxCapacity, DEFAULT_LOAD_FACTOR, true);? ? ? ? ??
this.maxCapacity = maxCapacity;? ? ? }? ? ? ??
?@Override? ??
?protected boolean removeEldestEntry(java.util.Map.Entryeldest) {? ? ? ? ?
?return size() > maxCapacity;? ??
? }? ? ??
@Override? ??
?public boolean containsKey(Object key) {? ? ? ? ??
try {? ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ??
return super.containsKey(key);? ? ? ? ?
?} finally {? ? ? ? ? ? ??
lock.unlock();? ? ? ? ?
?}? ? ??
}? ? ? ? ? ? ? ??
@Override? ??
?public V get(Object key) {? ? ? ? ?
?try {? ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ??
return super.get(key);? ? ? ? ?
?} finally {? ? ? ? ? ? ?
?lock.unlock();? ? ? ? ??
}? ? ?
?}? ? ? ??
?@Override? ?
?public V put(K key, V value) {? ? ? ? ?
?try {? ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ??
return super.put(key, value);? ? ? ? ?
?} finally {? ? ? ? ? ? ??
lock.unlock();? ? ? ??
? }? ? ?
?}? ? ??
? public int size() {? ? ? ? ?
?try { ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ?
?return super.size();? ? ? ? ?
?} finally {? ? ? ? ? ? ?
?lock.unlock();? ? ? ? ??
}? ? ??
}? ? ? ??
?public void clear() {? ? ? ? ??
try {? ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ?
?super.clear();? ? ? ? ?
?} finally {? ? ? ? ? ? ??
lock.unlock();? ? ? ? ?
?}? ? ?
?}? ? ? ?
?public Collection> getAll() {? ? ? ? ??
try {? ? ? ? ? ? ??
lock.lock();? ? ? ? ? ? ??
return new ArrayList>(super.entrySet());
} finally {
lock.unlock();
}
}
}
基于雙鏈表 的LRU實現(xiàn):
傳統(tǒng)意義的LRU算法是為每一個Cache對象設(shè)置一個計數(shù)器,每次Cache命中則給計數(shù)器+1,而Cache用完,需要淘汰舊內(nèi)容,放置新內(nèi)容時,就查看所有的計數(shù)器,并將最少使用的內(nèi)容替換掉。
它的弊端很明顯,如果Cache的數(shù)量少,問題不會很大, 但是如果Cache的空間過大,達到10W或者100W以上,一旦需要淘汰,則需要遍歷所有計算器,其性能與資源消耗是巨大的。效率也就非常的慢了。
它的原理: 將Cache的所有位置都用雙連表連接起來,當一個位置被命中之后,就將通過調(diào)整鏈表的指向,將該位置調(diào)整到鏈表頭的位置,新加入的Cache直接加到鏈表頭中。
這樣,在多次進行Cache操作后,最近被命中的,就會被向鏈表頭方向移動,而沒有命中的,而想鏈表后面移動,鏈表尾則表示最近最少使用的Cache。
當需要替換內(nèi)容時候,鏈表的最后位置就是最少被命中的位置,我們只需要淘汰鏈表最后的部分即可。
上面說了這么多的理論, 下面用代碼來實現(xiàn)一個LRU策略的緩存。
我們用一個對象來表示Cache,并實現(xiàn)雙鏈表,