JDK1.8 Collection知識點與代碼分析--LinkedHashMap


上一篇文章筆者對Collection包中非常重要的一個類HashMap進行了分析和總結, 如果你對HashMap的知識點有模糊, 建議先讀一讀JDK1.8 Collection知識點與代碼分析–HashMap但是HashMap存在一些問題, 最重要的是HashMap不能夠保證存儲元素的有序性, 這一點是因爲HashMap的遍歷是按照桶順序的, 而節點的插入順序和桶的順序無關, 並且還會在resize的時候進一步打亂. 那有沒有既能夠在常數時間進行增刪改查又能夠保持有序的Map呢, 於是就有了LinkedHashMap.

LinkedHashMapHashMap的子類, 所以其落腳點是HashMap, 在其基礎上, 通過一個雙向鏈表將節點按照插入順序或訪問順序連接起來, 就能夠以較低的成本達到上述的有序的目標.

同時, 因爲其實現了按照訪問順序的排序, 所以也是天然的LRU容器, 官方的文檔中就有建議, 可以用它來作爲LRU緩存, 按照一定的規則刪除最老的節點, 例如當size超過一個值時, 刪除最長時間未訪問的元素. 在文章的最後, 筆者也實現了一個基於LinkedHashMap的LRU管理的demo. 另一方面, 因爲是HashMap的子類,LinkedHashMap也不能保證線程安全, 如果需要進行併發, 需要使用它的synchronized的裝飾版本. 本文的所有源碼分析都是基於JDK1.8版本.

Constructor

LinkedHashMapHashMap的構造器基本一致, 但是多了accessOrder參數, 當它爲false時, 按照鏈表按照插入順序排序, 當它爲true時, 鏈表按照訪問順序排序

// 其他構造器都無法傳入accessOrder參數
public LinkedHashMap(int initialCapacity,  float loadFactor, boolean accessOrder)

對元素的put, putIfAbsent, get, getOrDefault, compute , computeIfAbsent, computeIfPresent, merge操作(假設元素存在)都視作對元素的訪問, replace只有元素被替換的時候才視作一次訪問, putAll方法對傳入的每個元素產生一次訪問, 訪問的順序取決於輸入的Map的iterator提供的順序.

Entry

Entry類是HashMap.Node的子類, 它在Node的基礎上增加了兩個成員變量, beforeafter. 因此Entry類中有三個指針: beforeafter分別指向雙向鏈表的前一個節點和後一個節點, 而next指向桶中的鏈的下一個節點.

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);
        }
    }

因此, LinkedHashMap的結構示意圖大致是這樣, 在這裏插入圖片描述
接下來的問題就是, 添加和刪除節點時link是如何處理的, 以及, 當節點被訪問時, 在鏈表中的順序調整是怎樣實現的, 以及, 如果要用它實現LRU應當怎麼做.

添加和刪除

putVal

LinkedHashMap沒有重寫put方法, 而是利用HashMapputVal的良好設計, 對afterNodeAccessafterNodeInsertion兩個回調函數以及newNode方法進行重寫.
我們首先回顧一下HashMap.putVal方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 懶惰加載初始化
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null); // 注意這裏不是new Node( )
        else {
            // 在桶的鏈表或樹中查找key, 如果不存在也是調用newNode方法創建
            // 如果超過閾值, 進行樹化
            }
            if (e != null) { // 如果key存在
                ...
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 遞增modCount, 並判斷是否擴容
        afterNodeInsertion(evict);
        return null;
    }

注意這裏的newNode函數, 是設計模式中的工廠模式, 有普通Node和TreeNode兩個版本, 在HashMap中, 內部就是簡單的調用了new產生一個新的實例返回, 但是LinkedHashMap通過重寫這兩個函數, 除了返回一個新對象, 還將這個對象添加到鏈表尾.

此外, LinkedHashMap重寫了afterNodeInsertion回調函數, 判斷是否要將鏈表表頭的元素刪除(刪除最老的節點), 重寫了afterNodeAccess回調函數, 如果是按照訪問順序排序的話, 將被訪問的節點移動到鏈表的末尾.

注意在JDK1.8以前, 這兩個函數分別是Entry.recordAccessaddEntry, 1.8把這兩個函數的邏輯部分移到putVal中, 而抽象出兩個回調函數, 供子類重寫, 邏輯上更加的清晰, 也符合面向對象中的開閉原則(開放擴展, 關閉修改).

removeNode

remove操作中, 類似的, HashMap提供了afterNodeRemoval回調函數, 而LinkedHashMap重寫了它, 做了雙向鏈表的刪除節點操作.

按照訪問順序排序

之前提到了afterAccess回調函數中, 實現了將被訪問節點移動到隊尾的操作, 源碼也就是對雙向鏈表的一個操作.

 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;
            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;
        }
    }

然後在`HashMap中, 在下面這些函數中, 去調用這個回調函數就能夠實現將最近被訪問的節點安排在隊尾.
在這裏插入圖片描述

將最久沒有訪問的節點刪除

上面提到, 每次插入時, 就會調用afterNodeInsertion這個回調函數, 因此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);
        }
    }

注意removeEldestEntry(first)函數就是判斷第一個節點是否應該被刪除的條件, 這個函數在源碼中默認爲返回false的.

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

重寫它, 你就能獲得力量就能夠用來控制LRU策略. 所以最後, 我們一起來寫一個demo用LinkedHashMap來手寫一個LRU.

LinkedHashMap 手寫LRU

public class MyLRUSample {
    public static void main(String[] args) {
        LinkedHashMap<Integer, String> LRU 
                = new LinkedHashMap<Integer, String>(16, 0.75F, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
                return size() > 4; // 當緩存超過4個時, 刪除最不長訪問的
            }
        };
        LRU.put(1, "1");LRU.put(2, "2");LRU.put(3, "3");LRU.put(4, "4");
        System.out.println("Original order in LRU");
        
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
        LRU.get(3);LRU.get(4);LRU.put(2, "Two");LRU.get(3);
        System.out.println("After some accesses, the order in LRU");
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
        
        LRU.put(5, "5");
        System.out.println("1 has been removed.");
        LRU.forEach((k,v)->System.out.println(k + ":" + v));
    }
}

輸出:

Original order in LRU
1:1
2:2
3:3
4:4
After some accesses, the order in LRU
1:1
4:4
2:Two
3:3
1 has been removed.
4:4
2:Two
3:3
5:5

結果和預期一致, 同時注意到, 重寫過的iterator的訪問順序是從鏈表頭到鏈表尾的.

參考資料:
Map 綜述(二):徹頭徹尾理解 LinkedHashMap

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章