[緩存算法系列]LRU算法及其優化策略——算法篇

LRU算法全稱是最近最少使用算法(Least Recently Use),普遍的應用於緩存機制中。當緩存使用的空間達到上限後,就須要從已有的數據中淘汰一部分以維持緩存的可用性,而淘汰數據的選擇就是經過LRU算法完成的。java

LRU算法的基本思想是基於局部性原理的時間局部性:node

若是一個信息項正在被訪問,那麼在近期它極可能還會被再次訪問。git

因此顧名思義,LRU算法會選出最近最少使用的數據進行淘汰。github

原理

通常來說,LRU將訪問數據的順序或時間和數據自己維護在一個容器當中。當訪問一個數據時:算法

  1. 該數據不在容器當中,則設置該數據的優先級爲最高並放入容器中。
  2. 該數據在容器當中,則更新該數據的優先級至最高。

當數據的總量達到上限後,則移除容器中優先級最低的數據。下圖是一個簡單的LRU原理示意圖:sql

LRU原理示意圖.jpg

若是咱們按照7 0 1 2 0 3 0 4的順序來訪問數據,且數據的總量上限爲3,則如上圖所示,LRU算法會依次淘汰7 1 2這三個數據。apache

樸素的LRU算法

那麼咱們如今就按照上面的原理,實現一個樸素的LRU算法。下面有三種方案:數組

  1. 基於數組緩存

    方案:爲每個數據附加一個額外的屬性——時間戳,當每一次訪問數據時,更新該數據的時間戳至當前時間。當數據空間已滿後,則掃描整個數組,淘汰時間戳最小的數據。併發

    不足:維護時間戳須要耗費額外的空間,淘汰數據時須要掃描整個數組。

  2. 基於長度有限的雙向鏈表

    方案:訪問一個數據時,當數據不在鏈表中,則將數據插入至鏈表頭部,若是在鏈表中,則將該數據移至鏈表頭部。當數據空間已滿後,則淘汰鏈表最末尾的數據。

    不足:插入數據或取數據時,須要掃描整個鏈表。

  3. 基於雙向鏈表和哈希表

    方案:爲了改進上面須要掃描鏈表的缺陷,配合哈希表,將數據和鏈表中的節點造成映射,將插入操做和讀取操做的時間複雜度從O(N)降至O(1)

基於雙向鏈表 + 哈希表實現LRU

下面咱們就基於雙向鏈表和哈希表實現一個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)。

基於LinkedHashMap實現的LRU

其實咱們能夠直接根據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算法已經可以知足緩存的要求了,可是仍是有一些不足。當熱點數據較多時,有較高的命中率,可是若是有偶發性的批量操做,會使得熱點數據被非熱點數據擠出容器,使得緩存受到了「污染」。因此爲了消除這種影響,又衍生出了下面這些優化方法。

LRU-K

LRU-K算法是對LRU算法的改進,將原先進入緩存隊列的評判標準從訪問一次改成訪問K次,能夠說樸素的LRU算法爲LRU-1。

LRU-K算法有兩個隊列,一個是緩存隊列,一個是數據訪問歷史隊列。當訪問一個數據時,首先先在訪問歷史隊列中累加訪問次數,當歷史訪問記錄超過K次後,纔將數據緩存至緩存隊列,從而避免緩存隊列被污染。同時訪問歷史隊列中的數據能夠按照LRU的規則進行淘汰。具體以下圖所示:

LRU-K.png

下面咱們來實現一個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

Two Queue能夠說是LRU-2的一種變種,將數據訪問歷史改成FIFO隊列。好處的明顯的,FIFO更簡易,耗用資源更少,可是相比LRU-2會下降緩存命中率。

Two Queue.png

// 直接繼承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的實現則複雜的多,顧名思義,Multi Queue是由多個LRU隊列組成的。每個LRU隊列都有一個相應的優先級,數據會根據訪問次數計算出相應的優先級,並放在該隊列中。

Multi Queue.png

  • 數據插入和訪問:當數據首次插入時,會放入到優先級最低的Q0隊列。當再次訪問時,根據LRU的規則,會移至隊列頭部。當根據訪問次數計算的優先級提高後,會將該數據移至更高優先級的隊列的頭部,並刪除原隊列的該數據。一樣的,當該數據的優先級下降時,會移至低優先級的隊列中。

  • 數據淘汰:數據淘汰老是從最低優先級的隊列的末尾數據進行,並將它加入到Q-history隊列的頭部。若是數據在Q-history數據中被訪問,則從新計算該數據的優先級,並將它加入到相應優先級的隊列中。不然就是按照LRU算法徹底淘汰。

Multi Queue也能夠看作是LRU-K的變種,將原來兩個隊列擴展爲多個隊列,好處就是不管是加入緩存仍是淘汰緩存數據都變得更加細膩,可是會帶來額外開銷。

總結

本文講解了基本的LRU算法及它的幾種優化策略,並比較了他們之間的異同和優劣。之前沒有想到LRU還有這麼些門道,後續還會有Redis、Mysql對於LRU算法應用的文章。

相關文章
相關標籤/搜索