Java LinkedHashMap 和 LRU算法

什麼是LRU算法

LRU(Least Recently Used),也就是最近最少使用。一種有限的空間資源管理的解決方案,會在空間資源不足的情況下移除掉最近沒被使用過的數據,以保證接下來需要的空間資源。

在現在通用的操作系統中爲了解決內存不足這個問題,提出了虛擬內存這種解決方案,其實虛擬內存也就是將機器的內存分爲多個頁面(提個小問題,一個頁面包含了多少kb的空間?),內存中只存放當前需要的頁面信息,暫時不使用的內存數據就保存到磁盤中。這可以很好的解決內存不足的問題。當然了這就無故出現頁面交換的情況,使得讀取內存的速度降低(磁盤的讀取速度遠小於內存的讀取速度),這種方案肯定有利有弊,只需要我們的服務能夠接受這種情況,那就完全沒有問題。

Redis做爲一種內存數據庫,內存大小對數據庫的影響更重要,所以redis也需要及時的移除掉那些過期數據。在redis中有定時清楚、惰性刪除、定期刪除,但是其策略主要分爲兩種,基於訪問時間和基於訪問頻率。基於訪問時間就是LRU算法,看看LRU算法的圖解過程,如下圖。

  • 先定義好一個定長的隊列
  • 按照FIFO的流程,依次申請一段空間
  • 直到隊列被佔滿了,出現內存不足的情況,淘汰策略開始工作
  • 會淘汰隊列中最先進入的數據,最先進去的數據也就是最近最久未被使用的數據,然後把其移除出隊列

LRU 算法小demo

整體的算法實現沒有太多的難度,維護一個有限長度的隊列的進出,需要移除或者插入數據。時間複雜度可能會是個問題。

  • 隊列如果是鏈表,則移除數據的時間複雜度是O(1),但是查找數據的時間複雜度是O(n)
  • 隊列如果是數組,則移除數據的時間複雜度是O(n),而且移除數據還伴隨着數組的平行移動,查找數據也是O(n),除非另外再加一個Map存儲其索引值會使得其查找的速度降低到O(1),但是卻又提高了空間複雜度

接下來寫個基於數組的LRU的簡單代碼

public class LruDemo<T> {

    private Object[] items;

    private HashMap<T, Integer> map;

    private int size;

    private int index;

    public LruDemo() {
        this(8);
    }

    public LruDemo(int size) {
        this.size = size;
        this.items = new Object[size];
        this.map = new HashMap<>(16);
        this.index = 0;
    }

    public void put(T t) {
        Integer value = map.get(t);
        if (value == null) {
            if (index >= size) {
                // 滿了,需要移除第一個元素
                for(int i=1; i<size;i++) {
                    items[i-1] = items[i];
                    map.replace((T)items[i-1], i);
                }
                index -= 1;
            }
            items[index] = t;
            map.put(t, index);
            index += 1;
        } else {
            for(int i=value; i<index; i++) {
                items[i] = items[i+1];
                map.replace((T)items[i], i);
            }
            items[index-1] = t;
            map.replace(t, index-1);
        }
    }

    public void getAll() {
        for(int i=0;i<index; i++) {
            System.out.println(items[i]);
        }
        System.out.println("======");
    }

    public static void main(String[] args) {
        LruDemo<String> lruDemo = new LruDemo<String>(6);
        lruDemo.put("aliace");
        lruDemo.put("bob");
        lruDemo.put("cat");
        lruDemo.put("dog");
        lruDemo.put("egg");
        lruDemo.getAll();
        lruDemo.put("bob");
        lruDemo.getAll();
        lruDemo.put("fine");
        lruDemo.put("good");
        lruDemo.getAll();
    }
}

 輸出的結果是

aliace
bob
cat
dog
egg
======
aliace
cat
dog
egg
bob
======
cat
dog
egg
bob
fine
good
======

這只是一種簡單的寫法,而且效率也比較低,現在就來介紹下將要學習的LinkedHashMap

LinkedHashMap

LinkedHashMap是繼承自HashMap的,只是另外添加了排序相關的功能,使得其成爲了有序hashmap,關於HashMap的介紹可以看看Java8的HashMap原理學習,接下來重點關注LinkedHashMap相比HashMap拓展了哪些功能呢?

Entry節點信息

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

頭尾節點

transient LinkedHashMap.Entry<K,V> head;

transient LinkedHashMap.Entry<K,V> tail;

Entry節點就包含了前置節點和後置節點的地址信息,再加上在LinkedHashMap又中添加了head和tail頭尾節點,這樣就使得之前的鏈表+數據的數據結構基礎上又加上了雙向鏈表,通過雙向鏈表實現有序性,並且 LinkedHashMap = Linked + HashMap

accessOrder 值

final boolean accessOrder; 是一個非常關鍵的字段值,暫時按下不表,接下來會知道其真正的含義

get操作

HashMap進行get操作還是很簡單的,通過hash獲取index,再可能涉及到鏈表(紅黑樹)的遍歷操作,在LinkedHashMap中同樣重寫了相關方法

public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

進行常規的getNode操作後在找到對應的節點e之後,當accessOrder是true時,調用afterNodeAccess方法,從其名稱也可以看出來時訪問節點後的操作。

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        // b 和 a 分別是訪問的節點e的前置和後置節點
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
            
        if (a != null)
            a.before = b;
        else
            last = b;
            
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        // 把其移動到雙向鏈表的尾部節點
        tail = p;
        ++modCount;
    }
}

也就是說當accessOrder爲true時,會修改其雙向鏈表的節點順序,而且搜索整個類也會發現accessOrder只在這裏發揮用處。順帶觀察下其key、value、entry的迭代器遍歷情況,可以發現都是使用了for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) 這種條件去循環遍歷。

所以accessOrder就是起到控制訪問順序的作用,設置爲true之後每訪問一個元素,就將該元素移動到雙向鏈表的尾部節點,通過改變節點在雙向鏈表的位置實現對鏈表順序的控制。

put 操作

在HashMap中通過put方法插入一個新的節點數據,LinkedHashMap並沒有重寫該方法。在HashMap中先檢查是否存在對應的key,如果不存在則會通過newNode方法創建一個新節點,然後等待插入到合適的位置,LinkedHashMap則重寫了newNode方法,如下代碼塊:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    linkNodeLast(p);
    return p;
}

// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

創建完一個LinkedHashMap.Entry節點p後,p節點的before, after都是null,然後調用linkNodeLast方法,採取尾插法,形成新的尾節點(這裏有一種情況是最早的時候tail==head==null的情況,會使得頭節點和尾節點都指向同一個節點)。

新插入一個節點後還會調用afterNodeInsertion方法,看起方法名稱也知道是在node節點插入後的操作,在HashMap中是空實現,在LinkedHashMap則實現了該方法,如下代碼塊:

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}
    
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

默認傳入的evict值是true,而removeEldestEntry方法默認返回false,也就是什麼都不做。當在put一個已經存在的節點的情況,會調用afterNodeAccess方法,也會去改變在鏈表中的位置。重寫removeEldestEntry方法並且當起返回true時,調用removeNode節點移除head節點這個就包含了LRU最近最少使用的實現原理

自定義LRU算法

設置accessOrder爲true後,每次訪問、新增的節點都會移動到尾部,當removeEldestEntry返回true時就會移除頭節點,那麼只需要設置一種特定的判斷邏輯使得removeEldestEntry返回true就可以了。按照上面LRU算法的思想,只有當空間滿了的情況下才會移除頭節點數據,同理只需要判斷當前map中的節點數是否達到相關的閾值即可。繼承LinkedHashMap重載removeEldestEntry方法,代碼如下:

public class LruMap<K,V> extends LinkedHashMap<K, V> {

    private int maxSize;

    public LruMap(int initialCapacity, float loadFactor, boolean accessOrder, int maxSize) {
        super(initialCapacity, loadFactor, accessOrder);
        this.maxSize = maxSize;
    }

    public LruMap(int maxSize) {
        this(16, 0.75f, true, maxSize);
    }

    public LruMap(int tableSize, int maxSize) {
        this(tableSize, 0.75f, true, maxSize);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        boolean flag = size() > maxSize;
        if (flag) {
            System.out.println("移除頭節點, key:" + eldest.getKey() + ", value:" + eldest.getValue());
        }
        return flag;
    }

    public static void main(String[] args) {
        LruMap<Integer, String> lruMap = new LruMap<>(4, 4);

        lruMap.put(1, "aaaa");
        lruMap.put(2, "bbbb");
        lruMap.put(3, "cccc");
        lruMap.put(4, "dddd");
        lruMap.put(5, "eeee");

        lruMap.entrySet().forEach(node -> {
            System.out.println("key:" + node.getKey() + ", value:" + node.getValue());
        });
    }
}

運行結果如下圖,測試了table大小時4個,當插入了1~4 4個節點後,再插入5這個節點時,會移除頭節點也就是節點1,從輸出的日誌也很清楚了。

如果利用get方法改變一下雙向鏈表的順序,可以控制移除的節點,修改一下測試數據,如下代碼塊

lruMap.put(1, "aaaa");
lruMap.put(2, "bbbb");
lruMap.put(3, "cccc");
lruMap.get(1);
lruMap.get(2);
lruMap.put(4, "dddd");
lruMap.put(5, "eeee");

這時候訪問節點1和2,就會使得1和2移動到雙向鏈表的尾部,頭節點就是節點3,所以移除的頭節點肯定是節點3,如下圖符合我們的設想。

總結

本篇學習筆記主要是介紹了LRU算法和LinkedHashMap,並且根據LinkedHashMap的功能實現一個簡單的LRU算法,關於LinkedHashMap只需要瞭解到accessOrder值和雙向鏈表的順序有關,而LRU刪除節點則是在每次插入之後確認是否達到某種需要移除節點的條件。

發佈了25 篇原創文章 · 獲贊 17 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章