1. 程式人生 > 實用技巧 >Java原始碼系列3——LinkedHashMap

Java原始碼系列3——LinkedHashMap

什麼是LinkedHashMap?

LinkedHashMapHashMap 的有序實現。LinkedHashMap 用一條雙向連結串列來維護順序,迭代的時候也使用自己實現的迭代器。

public static void main(String[] args) {
    HashMap<String, Integer> h = new HashMap<>(33);
    h.put("one", 1);
    h.put("two", 2);
    h.put("three", 3);
    h.put("four", 4);
    for (String key : h.keySet()) {
        System.out.println("key:" + key + "value:" + h.get(key));
    }

    LinkedHashMap<String, Integer> lh = new LinkedHashMap<>(33);
    lh.put("one", 1);
    lh.put("two", 2);
    lh.put("three", 3);
    lh.put("four", 4);
    for (String key : lh.keySet()) {
        System.out.println("key:" + key + "value:" + lh.get(key));
    }
}

輸出

key:twovalue:2
key:threevalue:3
key:fourvalue:4
key:onevalue:1

key:onevalue:1
key:twovalue:2
key:threevalue:3
key:fourvalue:4

底層陣列結構

HashMap的底層是由陣列,連結串列,紅黑樹組成的。陣列用來儲存節點,當出現雜湊碰撞時使用連結串列儲存,當連結串列超過一定長度後會優化成紅黑樹。

LinkedHashMap 的底層除了繼承自 HashMap 的陣列,連結串列,紅黑樹,還多了連結所有節點的雙向連結串列(圖中紅色和綠色箭頭),用於儲存各個節點的順序。

Entry的繼承關係

LinkedHashMap.Entry 繼承了 HashMap.Node,多維護了 before 和 after 兩個指標,這兩個屬性指向該Entry的前一個Entry和後一個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);
    }
}

但是 LinkedHashMap 中確沒有覆寫 HashMap 中 TreeNode 的程式碼,那紅黑樹中各個節點的順序是如何儲存的。

我們可以從 HashMap.TreeNode 的繼承關係中找出端倪:

呦吼,這一小家子也真夠亂的,子類繼承了父類的內部類,父類的內部類又繼承了子類的內部類,上演一出雞生蛋,蛋生雞的戲碼。

// 繼承了 LinkedHashMap.Entry
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

為什麼 HashMap.TreeNode 要繼承 LinkedHashMap.Entry,繼承過來的 before 和 after 指標在 HashMap 中也沒有被用到,何不直接繼承 HashMap.Node

這樣的繼承關係其實並不是為 HashMap 設計的,在 HashMap 中確實沒什麼用。但在 LinkedHashMap 中,就可以直接使用繼承過來的 HashMap.TreeNode,因為 TreeNode 這個類通過繼承已經擁有了 before 和 after 指標。

這就是為什麼,LinkedHashMap 中有一個繼承了 HashMap.Node 的內部類,卻沒有繼承 HashMap.TreeNode 的內部類。

連結串列的建立過程

連結串列的建立過程是在第一個元素插入的時候才開始的,一開始連結串列的頭部(head)和尾巴(tail)都為null。

LinkedHashMap 沒有覆寫父類的put方法,元素的插入流程基本相同,只是 HashMap 插入的是 Node 型別的節點,LinkedHashMap 插入的是 Entry 型別的節點,並且更新連結串列。

那麼 LinkedHashMap 是怎麼插入節點,並且更新連結串列的呢?

// HashMap 中實現
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

// HashMap 中實現
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 ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 取模得到節點在桶中的索引位置,並且該位置沒有元素,直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 雜湊碰撞了,本節不介紹,可以看上一篇講 HashMap 的文章
    else {
        // ... 省略部分程式碼
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

// HashMap 中實現的 newNode
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

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

// LinkedHashMap 中實現
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    // 如果連結串列為空,頭部和尾部都賦值為p
    if (last == null)
        head = p;
    // 把新插入的節點放在連結串列尾部
    else {
        p.before = last;
        last.after = p;
    }
}

從程式碼裡可以很明顯的看出,LinkedHashMap 中索引的計算,桶的賦值,雜湊碰撞時連結串列或者紅黑樹的建立,都使用的 HashMap 的實現。LinkedHashMap 只需要覆寫節點的建立,並且在建立節點的時候,更新儲存順序的連結串列。真的是把複用利用到了極致。

節點的刪除

與插入操作一樣,LinkedHashMap 也是使用的父類的刪除操作,然後覆寫了回撥方法 afterNodeRemoval,用於維護雙向連結串列。

// HashMap 中實現
final Node<K,V> removeNode(int hash, Object key, Object value,
                            boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // ... 省略部分程式碼
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                                (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            // 刪除後回撥
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

// LinkedHashMap 中覆寫
void afterNodeRemoval(Node<K,V> e) { // unlink
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    p.before = p.after = null;
    // 如果b為空,則為頭節點
    if (b == null)
        head = a;
    else
        b.after = a;
    // 如果a為空,則為尾節點
    if (a == null)
        tail = b;
    else
        a.before = b;
}

訪問順序的維護

如果我們在初始化 LinkedHashMap 時,把 accessOrder 引數設為 true,那麼我們不僅在插入的時候會維護連結串列,在訪問節點的時候也會維護連結串列。

當我們呼叫 get, getOrDefault, replace 等方法時,會更新連結串列,把訪問的節點移動到連結串列尾部。

// LinkedHashMap 中覆寫
public V get(Object key) {
    Node<K,V> e;
    // 呼叫了 HashMap 中的 getNode 方法
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 如果accessOrder為true,呼叫afterNodeAccess
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

// LinkedHashMap 中覆寫
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;
        // 如果b為空,則為頭部
        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
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}

使用測試程式碼體驗一下效果

public static void main(String[] args) {
    LinkedHashMap<String, Integer> lh = new LinkedHashMap<>(33, 0.75f, true);
    lh.put("one", 1);
    lh.put("two", 2);
    lh.put("three", 3);
    lh.put("four", 4);
    lh.get("two");
    for (String key : lh.keySet()) {
        System.out.println("key:" + key + "value:" + lh.get(key));
    }
}

竟然報錯了

看一下 LinkedHashMap 覆寫的迭代器程式碼

final LinkedHashMap.Entry<K,V> nextNode() {
    LinkedHashMap.Entry<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    current = e;
    next = e.after;
    return e;
}

ConcurrentModificationException 這個報錯是為了防止併發條件下,遍歷的同時連結串列發生變化。因為我們在遍歷的時候又呼叫了 get 方法,導致連結串列發生變化,才會拋這個錯。

accessOrder 為 true 時的正確遍歷姿勢如下,使用 LinkedHashMap 覆寫forEach 方法,就不會在讀取值的時候修改順序連結串列了。

lh.forEach((String k, Integer v) -> {
    System.out.println("key:" + k + ", value:" + v);
});

使用 LinkedHashMap 實現簡單的 LRU

LRU 全稱 Least Recently Used,也就是最近最少使用的意思,是一種記憶體管理演算法,該演算法最早應用於 Linux 作業系統。

這個演算法基於一種假設:長期不被使用的資料,在未來被用到的機率也不大。因此,當資料所佔記憶體達到一定閾值時,我們要移除最近最少被使用的資料。

下面我們介紹一下前置知識。

afterNodeInsertion 是一個回撥方法,在插入元素的時候回撥。LinkedHashMap 覆寫了這個方法,主要用來判斷是否需要將連結串列的 head 移除。

// LinkedHashMap 中覆寫
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // 根據條件判斷是否移除連結串列的head節點
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

// LinkedHashMap 中實現,預設返回false
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

下面我們將繼承 LinkedHashMap,通過覆寫 removeEldestEntry,達到當 Map 的節點個數超過指定閾值時,刪除最少訪問的節點。從而實現 LRU 快取策略。

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

    private static final int MAX_NODE_NUM = 100;

    private int limit;

    public SimpleCache(){
        this(MAX_NODE_NUM);
    }

    public SimpleCache(int limit) {
        super(limit, 0.75f, true);
        this.limit = limit;
    }

    /**
     * 判斷節點數是否超出限制
     * @param eldest
     * @return boolean
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > limit;
    }

    /**
     * 測試
     */
    public static void main(String[] args) {
        SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);

        for (int i = 0; i < 10; i++) {
            cache.put(i, i * i);
        }

        System.out.println("插入10個鍵值對後,快取內容:");
        System.out.println(cache);

        System.out.println("訪問鍵值為7的節點後,快取的內容:");
        cache.get(7);
        System.out.println(cache);

        System.out.println("插入鍵值為1的鍵值對後,快取的內容:");
        cache.put(1, 1);
        System.out.println(cache);
    }
}

測試結果如下:

總結

本文圍繞 LinkedHashMap 如何維護儲存順序的雙向連結串列展開,介紹了 LinkedHashMap 和 HashMap 節點類的繼承關係,介紹了新增,刪除,訪問時,LinkedHashMap 如何在複用 HashMap 的同時,維護雙向連結串列。最後通過繼承 LinkedHashMap 很簡單的實現了 LRU 快取策略。

全文的程式碼量較多,但都較為好理解。理解JDK的設計思路,探尋背後的實現原理,也是一件很有趣的事。

本文討論的原始碼都基於JDK1.8版本。

參考資料

LinkedHashMap 原始碼詳細分析(JDK1.8)

【Java入門提高篇】Day28 Java容器類詳解(十)LinkedHashMap詳解

原始碼系列文章

Java原始碼系列1——ArrayList

Java原始碼系列2——HashMap

Java原始碼系列3——LinkedHashMap

本文首發於我的個人部落格 http://chaohang.top

作者張小超

轉載請註明出處

歡迎關注我的微信公眾號 【超超不會飛】,獲取第一時間的更新。