JDK11-LinkedHashMap集合

介紹

inkedHashMap 繼承自 HashMap,在 HashMap 基礎上,通過維護一條雙向鏈表,解決了 HashMap 不能隨時保持遍歷順序和插入順序一致的問題。除此之外,LinkedHashMap 對訪問順序也提供了相關支持。在一些場景下,該特性很有用,比如緩存。

數據結構

 HashMap數據結構

 LinkedHashMap 繼承自 HashMap,所以它的底層仍然是基於拉鍊式散列結構。該結構由數組和鏈表或紅黑樹組成,結構示意圖大致如下

 

LinkedHashMap數據結構

上圖中,淡藍色的箭頭表示前驅引用,紅色箭頭表示後繼引用。每當有新鍵值對節點插入,新節點最終會接在 tail 引用指向的節點後面。而 tail 引用則會移動到新的節點上,這樣一個雙向鏈表就建立起來了。

上面的結構並不是很難理解,雖然引入了紅黑樹,導致結構看起來略爲複雜了一些。但大家完全可以忽略紅黑樹,而只關注鏈表結構本身。好了,接下來進入細節分析吧。

成員變量

 /**
     *雙向鏈表的頭結點
     */
    transient LinkedHashMap.Entry<K,V> head;

    /**
     *雙向鏈表的頭結點
     */
    transient LinkedHashMap.Entry<K,V> tail;

    /**
     *是否按照順序訪問
     *
     * @serial
     */
    final boolean accessOrder;

構造函數

//使用父類中的構造,初始化容量和加載因子。  
public LinkedHashMap(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor);
        accessOrder = false;
    }

    /**
     指定初始化容量,及默認加載因子
     */
    public LinkedHashMap(int initialCapacity) {
        super(initialCapacity);
        accessOrder = false;
    }

    /**
     * Constructs an empty insertion-ordered {@code LinkedHashMap} instance
     * with the default initial capacity (16) and load factor (0.75).
       使用默認容量,默認加載因子
     */
    public LinkedHashMap() {
        super();
        accessOrder = false;
    }

    /**
      接收map類型的值轉換爲LinkedHashMap
     */
    public LinkedHashMap(Map<? extends K, ? extends V> m) {
        super();
        accessOrder = false;
        putMapEntries(m, false);
    }
//使用初始容量,加載因子,是否按照順序訪問
public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

內部類

  Entry繼承體系

上面的繼承體系乍一看還是有點複雜的,同時也有點讓人迷惑。HashMap 的內部類 TreeNode 不繼承它的了一個內部類 Node,卻繼承自 Node 的子類 LinkedHashMap 內部類 Entry。這裏這樣做是有一定原因的,這裏先不說。先來簡單說明一下上面的繼承體系。LinkedHashMap 內部類 Entry 繼承自 HashMap 內部類 Node,並新增了兩個引用,分別是 before 和 after。這兩個引用的用途不難理解,也就是用於維護雙向鏈表。同時,TreeNode 繼承 LinkedHashMap 的內部類 Entry 後,就具備了和其他 Entry 一起組成鏈表的能力。但是這裏需要大家考慮一個問題。當我們使用 HashMap 時,TreeNode 並不需要具備組成鏈表能力。如果繼承 LinkedHashMap 內部類 Entry ,TreeNode 就多了兩個用不到的引用,這樣做不是會浪費空間嗎?簡單說明一下這個問題(水平有限,不保證完全正確),這裏這麼做確實會浪費空間,但與 TreeNode 通過繼承獲取的組成鏈表的能力相比,這點浪費是值得的。在 HashMap 的設計思路註釋中,有這樣一段話:

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins. In
usages with well-distributed user hashCodes, tree bins are
rarely used.

大致的意思是 TreeNode 對象的大小約是普通 Node 對象的2倍,我們僅在桶(bin)中包含足夠多的節點時再使用。當桶中的節點數量變少時(取決於刪除和擴容),TreeNode 會被轉成 Node。當用戶實現的 hashCode 方法具有良好分佈性時,樹類型的桶將會很少被使用。

通過上面的註釋,我們可以瞭解到。一般情況下,只要 hashCode 的實現不糟糕,Node 組成的鏈表很少會被轉成由 TreeNode 組成的紅黑樹。也就是說 TreeNode 使用的並不多,浪費那點空間是可接受的。假如 TreeNode 機制繼承自 Node 類,那麼它要想具備組成鏈表的能力,就需要 Node 去繼承 LinkedHashMap 的內部類 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);
        }
    }

是在HashMap的基礎上重寫了Entry並增加了before和after兩個變量,使其拓展成了雙向鏈表。詳細的應該是這樣的:entey<——before+entry(hash+key+value+next)+after——>entry。可能next和after全都指向下一個entry,也有可能next——>null,而after——>entry。

添加元素

  調用的是HashMap的 put方法

// 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) {...}
    // 通過節點 hash 定位節點所在的桶位置,並檢測桶中是否包含節點引用
    if ((p = tab[i = (n - 1) & hash]) == null) {...}
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) {...}
        else {
            // 遍歷鏈表,並統計鏈表長度
            for (int binCount = 0; ; ++binCount) {
                // 未在單鏈表中找到要插入的節點,將新節點接在單鏈表的後面
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) {...}
                    break;
                }
                // 插入的節點已經存在於單鏈表中
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) {...}
            afterNodeAccess(e);    // 回調方法,後續說明
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) {...}
    afterNodeInsertion(evict);    // 回調方法,後續說明
    return null;
}

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

// LinkedHashMap 中覆寫
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);
    // 將 Entry 接在雙向鏈表的尾部
    linkNodeLast(p);
    return p;
}

// LinkedHashMap 中實現
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    // last 爲 null,表明鏈表還未建立
    if (last == null)
        head = p;
    else {
        // 將新節點 p 接在鏈表尾部
        p.before = last;
        last.after = p;
    }
}

上就是 LinkedHashMap 維護插入順序的相關分析。本節的最後,再額外補充一些東西。大家如果仔細看上面的代碼的話,會發現有兩個以after開頭方法,在上文中沒有被提及。在 JDK 11 HashMap 的源碼中,相關的方法有3個:

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

根據這三個方法的註釋可以看出,這些方法的用途是在增刪查等操作後,通過回調的方式,讓 LinkedHashMap 有機會做一些後置操作。上述三個方法的具體實現在 LinkedHashMap 中,本節先不分析這些實現,相關分析會在後續章節中進行。

刪除操作

與插入操作一樣,LinkedHashMap 刪除操作相關的代碼也是直接用父類的實現。在刪除節點時,父類的刪除邏輯並不會修復 LinkedHashMap 所維護的雙向鏈表,這不是它的職責。那麼刪除及節點後,被刪除的節點該如何從雙鏈表中移除呢?當然,辦法還算是有的。上一節最後提到 HashMap 中三個回調方法運行 LinkedHashMap 對一些操作做出響應。所以,在刪除及節點後,回調方法 afterNodeRemoval 會被調用。LinkedHashMap 覆寫該方法,並在該方法中完成了移除被刪除節點的操作。相關源碼如下:

//HashMap中實現
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
//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 (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                       //遍歷單鏈表,尋找要刪除的節點,並賦值給node
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.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節點的前驅後繼引用置空
        p.before = p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        //a爲null 表明P是尾結點
        if (a == null)
            tail = b;
        else
            a.before = b;
    }

刪除的過程並不複雜,上面這麼多代碼其實就做了三件事:

  1. 根據 hash 定位到桶位置
  2. 遍歷鏈表或調用紅黑樹相關的刪除方法
  3. 從 LinkedHashMap 維護的雙鏈表中移除要刪除的節點

獲取操作

默認情況下,LinkedHashMap 是按插入順序維護鏈表。不過我們可以在初始化 LinkedHashMap,指定 accessOrder 參數爲 true,即可讓它按訪問順序維護鏈表。訪問順序的原理上並不複雜,當我們調用get/getOrDefault/replace等方法時,只需要將這些方法訪問的節點移動到鏈表的尾部即可

//LinkedHashMap重寫
public V get(Object key) {
        Node<K,V> e;
        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爲null,表明p是頭結點
            if (b == null)
                head = a;
            else
                //將當前節點的前驅節點的後繼節點指向,當前節點的後繼節點
                b.after = a;
            //當前節點的後繼節點不爲null,當前節點的後繼節點的前驅節點指向,當前節點的前驅節點
            if (a != null)
                a.before = b;
            else
            //當前節點的後繼節點爲null,(疑問,父條件以及確保e不是尾節點,那麼a也不會爲null,eles不會被執行到啊)
                last = b;
            if (last == null)
                head = p;
            else {
                //將p接在鏈表的最後
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }

 

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

上面的源碼的核心邏輯在一般情況下都不會被執行,所以之前並沒有進行分析。上面的代碼做的事情比較簡單,就是通過一些條件,判斷是否移除最近最少被訪問的節點。看到這裏,大家應該知道上面兩個方法的用途了。當我們基於 LinkedHashMap 實現緩存時,通過覆寫removeEldestEntry方法可以實現自定義策略的 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;
    }

    public V save(K key, V val) {
        return put(key, val);
    }

    public V getOne(K key) {
        return get(key);
    }

    public boolean exists(K key) {
        return containsKey(key);
    }
    
    /**
     * 判斷節點數是否超限
     * @param eldest
     * @return 超限返回 true,否則返回 false
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > limit;
    }
}

測試代碼:

public class SimpleCacheTest {
    public static void main(String[] args) {
        SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);

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

        System.out.println("插入10個鍵值對後,緩存內容:");
        System.out.println(cache + "\n");

        System.out.println("訪問鍵值爲7的節點後,緩存內容:");
        cache.getOne(7);
        System.out.println(cache + "\n");

        System.out.println("插入鍵值爲1的鍵值對後,緩存內容:");
        cache.save(1, 1);
        System.out.println(cache);
    }
}

結果如下:

 

在測試代碼中,設定緩存大小爲3。在向緩存中插入10個鍵值對後,只有最後3個被保存下來了,其他的都被移除了。然後通過訪問鍵值爲7的節點,使得該節點被移到雙向鏈表的最後位置。當我們再次插入一個鍵值對時,鍵值爲7的節點就不會被移除。

總結:

  從 LinkedHashMap 維護雙向鏈表的角度對 LinkedHashMap 的源碼進行了分析,並在文章的結尾基於 LinkedHashMap 實現了一個簡單的 Cache。在日常開發中,LinkedHashMap 的使用頻率雖不及 HashMap,但它也個重要的實現。在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三個映射類基於不同的數據結構,並實現了不同的功能。HashMap 底層基於拉鍊式的散列結構,並在 JDK 1.8 中引入紅黑樹優化過長鏈表的問題。基於這樣結構,HashMap 可提供高效的增刪改查操作。LinkedHashMap 在其之上,通過維護一條雙向鏈表,實現了散列數據結構的有序遍歷。TreeMap 底層基於紅黑樹實現,利用紅黑樹的性質,實現了鍵值對排序功能。

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