前言
LRU 是
Least Recently Used
的簡寫,字面意思則是
最近最少使用
。
通常用于緩存的淘汰政策實作,由于緩存的記憶體非常寶貴,是以需要根據某種規則來剔除資料保證記憶體不被撐滿。
如常用的 Redis 就有以下幾種政策:
政策 | 描述 |
volatile-lru | 從已設定過期時間的資料集中挑選最近最少使用的資料淘汰 |
volatile-ttl | 從已設定過期時間的資料集中挑選将要過期的資料淘汰 |
volatile-random | 從已設定過期時間的資料集中任意選擇資料淘汰 |
allkeys-lru | 從所有資料集中挑選最近最少使用的資料淘汰 |
allkeys-random | 從所有資料集中任意選擇資料進行淘汰 |
no-envicition | 禁止驅逐資料 |
摘抄自: https://github.com/CyC2018/Interview-Notebook/blob/master/notes/Redis.md
https://blog.didispace.com/write-LRU-cache/#%E5%AE%9E%E7%8E%B0%E4%B8%80 實作一
之前也有接觸過一道面試題,大概需求是:
- 實作一個 LRU 緩存,當緩存資料達到 N 之後需要淘汰掉最近最少使用的資料。
- N 小時之内沒有被通路的資料也需要淘汰掉。
以下是我的實作:
public class LRUAbstractMap extends java.util.AbstractMap {
private final static Logger LOGGER = LoggerFactory.getLogger(LRUAbstractMap.class);
/**
* 檢查是否超期線程
*/
private ExecutorService checkTimePool ;
/**
* map 最大size
*/
private final static int MAX_SIZE = 1024 ;
private final static ArrayBlockingQueue<Node> QUEUE = new ArrayBlockingQueue<>(MAX_SIZE) ;
/**
* 預設大小
*/
private final static int DEFAULT_ARRAY_SIZE =1024 ;
/**
* 數組長度
*/
private int arraySize ;
/**
* 數組
*/
private Object[] arrays ;
/**
* 判斷是否停止 flag
*/
private volatile boolean flag = true ;
/**
* 逾時時間
*/
private final static Long EXPIRE_TIME = 60 * 60 * 1000L ;
/**
* 整個 Map 的大小
*/
private volatile AtomicInteger size ;
public LRUAbstractMap() {
arraySize = DEFAULT_ARRAY_SIZE;
arrays = new Object[arraySize] ;
//開啟一個線程檢查最先放入隊列的值是否超期
executeCheckTime();
}
/**
* 開啟一個線程檢查最先放入隊列的值是否超期 設定為守護線程
*/
private void executeCheckTime() {
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("check-thread-%d")
.setDaemon(true)
.build();
checkTimePool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1),namedThreadFactory,new ThreadPoolExecutor.AbortPolicy());
checkTimePool.execute(new CheckTimeThread()) ;
}
@Override
public Set<Entry> entrySet() {
return super.keySet();
}
@Override
public Object put(Object key, Object value) {
int hash = hash(key);
int index = hash % arraySize ;
Node currentNode = (Node) arrays[index] ;
if (currentNode == null){
arrays[index] = new Node(null,null, key, value);
//寫入隊列
QUEUE.offer((Node) arrays[index]) ;
sizeUp();
}else {
Node cNode = currentNode ;
Node nNode = cNode ;
//存在就覆寫
if (nNode.key == key){
cNode.val = value ;
}
while (nNode.next != null){
//key 存在 就覆寫 簡單判斷
if (nNode.key == key){
nNode.val = value ;
break ;
}else {
//不存在就新增連結清單
sizeUp();
Node node = new Node(nNode,null,key,value) ;
//寫入隊列
QUEUE.offer(currentNode) ;
cNode.next = node ;
}
nNode = nNode.next ;
}
}
return null ;
}
@Override
public Object get(Object key) {
int hash = hash(key) ;
int index = hash % arraySize ;
Node currentNode = (Node) arrays[index] ;
if (currentNode == null){
return null ;
}
if (currentNode.next == null){
//更新時間
currentNode.setUpdateTime(System.currentTimeMillis());
//沒有沖突
return currentNode ;
}
Node nNode = currentNode ;
while (nNode.next != null){
if (nNode.key == key){
//更新時間
currentNode.setUpdateTime(System.currentTimeMillis());
return nNode ;
}
nNode = nNode.next ;
}
return super.get(key);
}
@Override
public Object remove(Object key) {
int hash = hash(key) ;
int index = hash % arraySize ;
Node currentNode = (Node) arrays[index] ;
if (currentNode == null){
return null ;
}
if (currentNode.key == key){
sizeDown();
arrays[index] = null ;
//移除隊列
QUEUE.poll();
return currentNode ;
}
Node nNode = currentNode ;
while (nNode.next != null){
if (nNode.key == key){
sizeDown();
//在連結清單中找到了 把上一個節點的 next 指向目前節點的下一個節點
nNode.pre.next = nNode.next ;
nNode = null ;
//移除隊列
QUEUE.poll();
return nNode;
}
nNode = nNode.next ;
}
return super.remove(key);
}
/**
* 增加size
*/
private void sizeUp(){
//在put值時候認為裡邊已經有資料了
flag = true ;
if (size == null){
size = new AtomicInteger() ;
}
int size = this.size.incrementAndGet();
if (size >= MAX_SIZE) {
//找到隊列頭的資料
Node node = QUEUE.poll() ;
if (node == null){
throw new RuntimeException("data error") ;
}
//移除該 key
Object key = node.key ;
remove(key) ;
lruCallback() ;
}
}
/**
* 數量減小
*/
private void sizeDown(){
if (QUEUE.size() == 0){
flag = false ;
}
this.size.decrementAndGet() ;
}
@Override
public int size() {
return size.get() ;
}
/**
* 連結清單
*/
private class Node{
private Node next ;
private Node pre ;
private Object key ;
private Object val ;
private Long updateTime ;
public Node(Node pre,Node next, Object key, Object val) {
this.pre = pre ;
this.next = next;
this.key = key;
this.val = val;
this.updateTime = System.currentTimeMillis() ;
}
public void setUpdateTime(Long updateTime) {
this.updateTime = updateTime;
}
public Long getUpdateTime() {
return updateTime;
}
@Override
public String toString() {
return "Node{" +
"key=" + key +
", val=" + val +
'}';
}
}
/**
* copy HashMap 的 hash 實作
* @param key
* @return
*/
public int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
private void lruCallback(){
LOGGER.debug("lruCallback");
}
private class CheckTimeThread implements Runnable{
@Override
public void run() {
while (flag){
try {
Node node = QUEUE.poll();
if (node == null){
continue ;
}
Long updateTime = node.getUpdateTime() ;
if ((updateTime - System.currentTimeMillis()) >= EXPIRE_TIME){
remove(node.key) ;
}
} catch (Exception e) {
LOGGER.error("InterruptedException");
}
}
}
}
}
感興趣的朋友可以直接從:
https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRUAbstractMap.java下載下傳代碼本地運作。
代碼看着比較多,其實實作的思路還是比較簡單:
- 采用了與 HashMap 一樣的儲存資料方式,隻是自己手動實作了一個簡易版。
- 内部采用了一個隊列來儲存每次寫入的資料。
- 寫入的時候判斷緩存是否大于了門檻值 N,如果滿足則根據隊列的 FIFO 特性将隊列頭的資料删除。因為隊列頭的資料肯定是最先放進去的。
- 再開啟了一個守護線程用于判斷最先放進去的資料是否超期(因為就算超期也是最先放進去的資料最有可能滿足超期條件。)
- 設定為守護線程可以更好的表明其目的(最壞的情況下,如果是一個使用者線程最終有可能導緻程式不能正常退出,因為該線程一直在運作,守護線程則不會有這個情況。)
以上代碼大體功能滿足了,但是有一個緻命問題。
就是最近最少使用沒有滿足,删除的資料都是最先放入的資料。
不過其中的 put get
流程算是一個簡易的 HashMap 實作,可以對 HashMap 加深一些了解。
https://blog.didispace.com/write-LRU-cache/#%E5%AE%9E%E7%8E%B0%E4%BA%8C 實作二
是以如何來實作一個完整的 LRU 緩存呢,這次不考慮過期時間的問題。
其實從上一個實作也能想到一些思路:
- 要記錄最近最少使用,那至少需要一個有序的集合來保證寫入的順序。
- 在使用了資料之後能夠更新它的順序。
基于以上兩點很容易想到一個常用的資料結構:連結清單。
- 每次寫入資料時将資料放傳入連結表頭結點。
- 使用資料時候将資料移動到頭結點。
- 緩存數量超過門檻值時移除連結清單尾部資料。
是以有了以下實作:
public class LRUMap<K, V> {
private final Map<K, V> cacheMap = new HashMap<>();
/**
* 最大緩存大小
*/
private int cacheSize;
/**
* 節點大小
*/
private int nodeCount;
/**
* 頭結點
*/
private Node<K, V> header;
/**
* 尾結點
*/
private Node<K, V> tailer;
public LRUMap(int cacheSize) {
this.cacheSize = cacheSize;
//頭結點的下一個結點為空
header = new Node<>();
header.next = null;
//尾結點的上一個結點為空
tailer = new Node<>();
tailer.tail = null;
//雙向連結清單 頭結點的上結點指向尾結點
header.tail = tailer;
//尾結點的下結點指向頭結點
tailer.next = header;
}
public void put(K key, V value) {
cacheMap.put(key, value);
//雙向連結清單中添加結點
addNode(key, value);
}
public V get(K key){
Node<K, V> node = getNode(key);
//移動到頭結點
moveToHead(node) ;
return cacheMap.get(key);
}
private void moveToHead(Node<K,V> node){
//如果是最後的一個節點
if (node.tail == null){
node.next.tail = null ;
tailer = node.next ;
nodeCount -- ;
}
//如果是本來就是頭節點 不作處理
if (node.next == null){
return ;
}
//如果處于中間節點
if (node.tail != null && node.next != null){
//它的上一節點指向它的下一節點 也就删除目前節點
node.tail.next = node.next ;
nodeCount -- ;
}
//最後在頭部增加目前節點
//注意這裡需要重新 new 一個對象,不然原本的node 還有着下面的引用,會造成記憶體溢出。
node = new Node<>(node.getKey(),node.getValue()) ;
addHead(node) ;
}
/**
* 連結清單查詢 效率較低
* @param key
* @return
*/
private Node<K,V> getNode(K key){
Node<K,V> node = tailer ;
while (node != null){
if (node.getKey().equals(key)){
return node ;
}
node = node.next ;
}
return null ;
}
/**
* 寫入頭結點
* @param key
* @param value
*/
private void addNode(K key, V value) {
Node<K, V> node = new Node<>(key, value);
//容量滿了删除最後一個
if (cacheSize == nodeCount) {
//删除尾結點
delTail();
}
//寫入頭結點
addHead(node);
}
/**
* 添加頭結點
*
* @param node
*/
private void addHead(Node<K, V> node) {
//寫入頭結點
header.next = node;
node.tail = header;
header = node;
nodeCount++;
//如果寫入的資料大于2個 就将初始化的頭尾結點删除
if (nodeCount == 2) {
tailer.next.next.tail = null;
tailer = tailer.next.next;
}
}
private void delTail() {
//把尾結點從緩存中删除
cacheMap.remove(tailer.getKey());
//删除尾結點
tailer.next.tail = null;
tailer = tailer.next;
nodeCount--;
}
private class Node<K, V> {
private K key;
private V value;
Node<K, V> tail;
Node<K, V> next;
public Node(K key, V value) {
this.key = key;
this.value = value;
}
public Node() {
}
public K getKey() {
return key;
}
public void setKey(K key) {
this.key = key;
}
public V getValue() {
return value;
}
public void setValue(V value) {
this.value = value;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder() ;
Node<K,V> node = tailer ;
while (node != null){
sb.append(node.getKey()).append(":")
.append(node.getValue())
.append("-->") ;
node = node.next ;
}
return sb.toString();
}
}
源碼:
https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRUMap.java實際效果,寫入時:
@Test
public void put() throws Exception {
LRUMap<String,Integer> lruMap = new LRUMap(3) ;
lruMap.put("1",1) ;
lruMap.put("2",2) ;
lruMap.put("3",3) ;
System.out.println(lruMap.toString());
lruMap.put("4",4) ;
System.out.println(lruMap.toString());
lruMap.put("5",5) ;
System.out.println(lruMap.toString());
}
輸出内容:
1:1-->2:2-->3:3-->
2:2-->3:3-->4:4-->
3:3-->4:4-->5:5-->
使用時:
@Test
public void get() throws Exception {
LRUMap<String,Integer> lruMap = new LRUMap(3) ;
lruMap.put("1",1) ;
lruMap.put("2",2) ;
lruMap.put("3",3) ;
System.out.println(lruMap.toString());
System.out.println("==============");
Integer integer = lruMap.get("1");
System.out.println(integer);
System.out.println("==============");
System.out.println(lruMap.toString());
}
//輸出
1:1-->2:2-->3:3-->
==============
1
==============
2:2-->3:3-->1:1-->
實作思路和上文提到的一緻,說下重點:
- 資料是直接利用 HashMap 來存放的。
- 内部使用了一個雙向連結清單來存放資料,是以有一個頭結點 header,以及尾結點 tailer。
- 每次寫入頭結點,删除尾結點時都是依賴于 header tailer,如果看着比較懵建議自己實作一個連結清單熟悉下,或結合下文的對象關系圖一起了解。
- 使用資料移動到連結清單頭時,第一步是需要在雙向連結清單中找到該節點。這裡就展現對外連結表的問題了。查找效率很低,最差需要
。之後依賴于目前節點進行移動。O(N)
- 在寫入頭結點時有判斷連結清單大小等于 2 時需要删除初始化的頭尾結點。這是因為初始化時候生成了兩個雙向節點,沒有資料隻是為了形成一個資料結構。當真實資料進來之後需要删除以友善後續的操作(這點可以繼續優化)。
- 以上的所有操作都是線程不安全的,需要使用者自行控制。
下面是對象關系圖:
https://blog.didispace.com/write-LRU-cache/#%E5%88%9D%E5%A7%8B%E5%8C%96%E6%97%B6 初始化時
https://blog.didispace.com/write-LRU-cache/#%E5%86%99%E5%85%A5%E6%95%B0%E6%8D%AE%E6%97%B6 寫入資料時
LRUMap<String,Integer> lruMap = new LRUMap(3) ;
lruMap.put("1",1) ;
lruMap.put("2",2) ;
lruMap.put("3",3) ;
lruMap.put("4",4) ;
擷取資料時
資料和上文一樣:
Integer integer = lruMap.get("2");
通過以上幾張圖應該是很好了解資料是如何存放的了。
https://blog.didispace.com/write-LRU-cache/#%E5%AE%9E%E7%8E%B0%E4%B8%89 實作三
其實如果對 Java 的集合比較熟悉的話,會發現上文的結構和 LinkedHashMap 非常類似。
對此不太熟悉的朋友可以先了解下
LinkedHashMap 底層分析是以我們完全可以借助于它來實作:
public class LRULinkedMap<K,V> {
/**
* 最大緩存大小
*/
private int cacheSize;
private LinkedHashMap<K,V> cacheMap ;
public LRULinkedMap(int cacheSize) {
this.cacheSize = cacheSize;
cacheMap = new LinkedHashMap(16,0.75F,true){
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
if (cacheSize + 1 == cacheMap.size()){
return true ;
}else {
return false ;
}
}
};
}
public void put(K key,V value){
cacheMap.put(key,value) ;
}
public V get(K key){
return cacheMap.get(key) ;
}
public Collection<Map.Entry<K, V>> getAll() {
return new ArrayList<Map.Entry<K, V>>(cacheMap.entrySet());
}
}
https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRULinkedMap.java 這次就比較簡潔了,也就幾行代碼(具體的邏輯 LinkedHashMap 已經幫我們實作好了)
實際效果:
@Test
public void put() throws Exception {
LRULinkedMap<String,Integer> map = new LRULinkedMap(3) ;
map.put("1",1);
map.put("2",2);
map.put("3",3);
for (Map.Entry<String, Integer> e : map.getAll()){
System.out.print(e.getKey() + " : " + e.getValue() + "\t");
}
System.out.println("");
map.put("4",4);
for (Map.Entry<String, Integer> e : map.getAll()){
System.out.print(e.getKey() + " : " + e.getValue() + "\t");
}
}
1 : 1 2 : 2 3 : 3
2 : 2 3 : 3 4 : 4
@Test
public void get() throws Exception {
LRULinkedMap<String,Integer> map = new LRULinkedMap(4) ;
map.put("1",1);
map.put("2",2);
map.put("3",3);
map.put("4",4);
for (Map.Entry<String, Integer> e : map.getAll()){
System.out.print(e.getKey() + " : " + e.getValue() + "\t");
}
System.out.println("");
map.get("1") ;
for (Map.Entry<String, Integer> e : map.getAll()){
System.out.print(e.getKey() + " : " + e.getValue() + "\t");
}
}
1 : 1 2 : 2 3 : 3 4 : 4
2 : 2 3 : 3 4 : 4 1 : 1
LinkedHashMap 内部也有維護一個雙向隊列,在初始化時也會給定一個緩存大小的門檻值。初始化時自定義是否需要删除最近不常使用的資料,如果是則會按照實作二中的方式管理資料。
其實主要代碼就是重寫了 LinkedHashMap 的 removeEldestEntry 方法:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
它預設是傳回 false,也就是不會管有沒有超過門檻值。
是以我們自定義大于了門檻值時傳回 true,這樣 LinkedHashMap 就會幫我們删除最近最少使用的資料。
https://blog.didispace.com/write-LRU-cache/#%E6%80%BB%E7%BB%93 總結
以上就是對 LRU 緩存的實作,了解了這些至少在平時使用時可以知其是以然。
當然業界使用較多的還有
guava的實作,并且它還支援多種過期政策。
https://blog.didispace.com/write-LRU-cache/#%E5%8F%B7%E5%A4%96 号外
最近在總結一些 Java 相關的知識點,感興趣的朋友可以一起維護。
位址: https://github.com/crossoverJie/Java-Interview