LinkedHashMap如何保證順序性


一. 前言

先看一個例子,我們想在頁面展示一周內的消費變化情況,用echarts面積圖進行展示。如下:

我們在后台將數據構造完成

HashMap<String, Integer> map = new HashMap<>();
map.put("星期一", 40);
map.put("星期二", 43);
map.put("星期三", 35);
map.put("星期四", 55);
map.put("星期五", 45);
map.put("星期六", 35);
map.put("星期日", 30);

然而頁面上一展示,發現並非如此,我們打印出來看,發現順序並非我們所想,先put進去的先get出來

for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}
/**
 * 結果如下:
 * key: 星期二, value: 40
 * key: 星期六, value: 35
 * key: 星期三, value: 50
 * key: 星期四, value: 55
 * key: 星期五, value: 45
 * key: 星期日, value: 65
 * key: 星期一, value: 30
 */

那么如何保證預期展示結果如我們所想呢,這個時候就需要用到LinkedHashMap實體。

二. 初識LinkedHashMap

首先我們把上述代碼用LinkedHashMap進行重構

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("星期一", 40);
map.put("星期二", 43);
map.put("星期三", 35);
map.put("星期四", 55);
map.put("星期五", 45);
map.put("星期六", 35);
map.put("星期日", 30);
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

這個時候,結果正如我們所預期

key: 星期一, value: 40
key: 星期二, value: 43
key: 星期三, value: 35
key: 星期四, value: 55
key: 星期五, value: 45
key: 星期六, value: 35
key: 星期日, value: 30

LinkedHashMap繼承了HashMap類,是HashMap的子類,LinkedHashMap的大多數方法的實現直接使用了父類HashMap的方法,關於HashMap在前面的章節已經講過了,《HashMap原理(一) 概念和底層架構》《HashMap原理(二) 擴容機制及存取原理》

LinkedHashMap可以說是HashMap和LinkedList的集合體,既使用了HashMap的數據結構,又借用了LinkedList雙向鏈表的結構(關於LinkedList可參考Java集合 LinkedList的原理及使用),那么這樣的結構如何實現的呢,我們看一下LinkedHashMap的類結構

我們看到LinkedHashMap中定義了一個Entry靜態內部類,定義了5個構造器,一些成員變量,如head,tail,accessOrder,並繼承了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);
    }
}

我們看到這個靜態內部類很簡單,繼承了HashMap的Node內部類,我們知道Node類是HashMap的底層數據結構,實現了數組+鏈表/紅黑樹的結構,而Entry類保留了HashMap的數據結構,同時通過before,after實現了雙向鏈表結構(HashMap中Node類只有next屬性,並不具備雙向鏈表結構)。那么before,after和next到底什么關系呢。

看上面的結構圖,定義了頭結點head,當我們調用迭代器進行遍歷時,通過head開始遍歷,通過before屬性可以不斷找到下一個,直到tail尾結點,從而實現順序性。而在同一個hash(在上圖中表現了同一行)鏈表內部after和next效果是一樣的。不同點在於before和after可以連接不同hash之間的鏈表。

前面我們發現數據結構已經完全支持其順序性了,接下來我們再看一下構造方法,看一下比起HashMap的構造方法是否有不同。

// 構造方法1,構造一個指定初始容量和負載因子的、按照插入順序的LinkedList
public LinkedHashMap(int initialCapacity, float loadFactor) {
    super(initialCapacity, loadFactor);
    accessOrder = false;
}
// 構造方法2,構造一個指定初始容量的LinkedHashMap,取得鍵值對的順序是插入順序
public LinkedHashMap(int initialCapacity) {
    super(initialCapacity);
    accessOrder = false;
}
// 構造方法3,用默認的初始化容量和負載因子創建一個LinkedHashMap,取得鍵值對的順序是插入順序
public LinkedHashMap() {
    super();
    accessOrder = false;
}
// 構造方法4,通過傳入的map創建一個LinkedHashMap,容量為默認容量(16)和(map.zise()/DEFAULT_LOAD_FACTORY)+1的較大者,裝載因子為默認值
public LinkedHashMap(Map<? extends K, ? extends V> m) {
    super(m);
    accessOrder = false;
}
// 構造方法5,根據指定容量、裝載因子和鍵值對保持順序創建一個LinkedHashMap
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

我們發現除了多了一個變量accessOrder之外,並無不同,此變量到底起了什么作用?

/**
 * The iteration ordering method for this linked hash map: <tt>true</tt>
 * for access-order, <tt>false</tt> for insertion-order.
 *
 * @serial
 */
final boolean accessOrder;

通過注釋發現該變量為true時access-order,即按訪問順序遍歷,如果為false,則表示按插入順序遍歷。默認為false,在哪些地方使用到該變量了,同時怎么理解?我們可以看下面的方法介紹

二. put方法

前面我們提到LinkedHashMap的put方法沿用了父類HashMap的put方法,但我們也提到了像LinkedHashMap的Entry類就是繼承了HashMap的Node類,同樣的,HashMap的put方法中調用的其他方法在LinkedHashMap中已經被重寫。我們先看一下HashMap的put方法,這個在《HashMap原理(二) 擴容機制及存取原理》中已經有說明,我們主要關注於其中的不同點

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /**
     * 如果當前HashMap的table數組還未定義或者還未初始化其長度,則先通過resize()進行擴容,
     * 返回擴容后的數組長度n
     */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通過數組長度與hash值做按位與&運算得到對應數組下標,若該位置沒有元素,則new Node直接將新元素插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //否則該位置已經有元素了,我們就需要進行一些其他操作
    else {
        Node<K,V> e; K k;
        //如果插入的key和原來的key相同,則替換一下就完事了
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        /**
         * 否則key不同的情況下,判斷當前Node是否是TreeNode,如果是則執行putTreeVal將新的元素插入
         * 到紅黑樹上。
         */
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //如果不是TreeNode,則進行鏈表遍歷
        else {
            for (int binCount = 0; ; ++binCount) {
                /**
                 * 在鏈表最后一個節點之后並沒有找到相同的元素,則進行下面的操作,直接new Node插入,
                 * 但條件判斷有可能轉化為紅黑樹
                 */
                if ((e = p.next) == null) {
                    //直接new了一個Node
                    p.next = newNode(hash, key, value, null);
                    /**
                     * TREEIFY_THRESHOLD=8,因為binCount從0開始,也即是鏈表長度超過8(包含)時,
                     * 轉為紅黑樹。
                     */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                /**
                 * 如果在鏈表的最后一個節點之前找到key值相同的(和上面的判斷不沖突,上面是直接通過數組
                 * 下標判斷key值是否相同),則替換
                 */
                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;
            //onlyIfAbsent為true時:當某個位置已經存在元素時不去覆蓋
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //最后判斷臨界值,是否擴容。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1. newNode方法

首先:LinkedHashMap重寫了newNode()方法,通過此方法保證了插入的順序性。

/**
 * 使用LinkedHashMap中內部類Entry
 */
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;
}
/**
 * 將新創建的節點p作為尾結點tail,
 * 當然如果存儲的第一個節點,那么它即是head節點,也是tail節點,此時節點p的before和after都為null
 * 否則,建立與上一次尾結點的鏈表關系,將當前尾節點p的前一個節點(before)設置為上一次的尾結點last,
 * 將上一次尾節點last的后一個節點(after)設置為當前尾結點p
 * 通過此方法實現了雙向鏈表功能,完成before,after,head,tail的值設置
 */
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;
    }
}

2. afterNodeAccess方法

其次:關於afterNodeAccess()方法,在HashMap中沒給具體實現,而在LinkedHashMap重寫了,目的是保證操作過的Node節點永遠在最后,從而保證讀取的順序性,在調用put方法和get方法時都會用到。

/**
 * 當accessOrder為true並且傳入的節點不是最后一個時,將傳入的node移動到最后一個
 */
void afterNodeAccess(Node<K,V> e) {
    //在執行方法前的上一次的尾結點
    LinkedHashMap.Entry<K,V> last;
    //當accessOrder為true並且傳入的節點並不是上一次的尾結點時,執行下面的方法
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        //p:當前節點
        //b:當前節點的前一個節點
        //a:當前節點的后一個節點;
        
        //將p.after設置為null,斷開了與后一個節點的關系,但還未確定其位置
        p.after = null;
        /**
         * 因為將當前節點p拿掉了,那么節點b和節點a之間斷開了,我們先站在節點b的角度建立與節點a
         * 的關聯,如果節點b為null,表示當前節點p是頭結點,節點p拿掉后,p的下一個節點a就是頭節點了;
         * 否則將節點b的后一個節點設置為節點a
         */
        if (b == null)
            head = a;
        else
            b.after = a;
        /**
         * 因為將當前節點p拿掉了,那么節點a和節點b之間斷開了,我們站在節點a的角度建立與節點b
         * 的關聯,如果節點a為null,表示當前節點p為尾結點,節點p拿掉后,p的前一個節點b為尾結點,
         * 但是此時我們並沒有直接將節點p賦值給tail,而是給了一個局部變量last(即當前的最后一個節點),因為
         * 直接賦值給tail與該方法最終的目標並不一致;如果節點a不為null將節點a的前一個節點設置為節點b
         *
         * (因為前面已經判斷了(last = tail) != e,說明傳入的節點並不是尾結點,既然不是尾結點,那么
         * e.after必然不為null,那為什么這里又判斷了a == null的情況?
         * 以我的理解,java可通過反射機制破壞封裝,因此如果都是反射創建出的Entry實體,可能不會滿足前面
         * 的判斷條件)
         */
        if (a != null)
            a.before = b;
        else
            last = b;
        /**
         * 正常情況下last應該也不為空,為什么要判斷,原因和前面一樣
         * 前面設置了p.after為null,此處再將其before值設置為上一次的尾結點last,同時將上一次的尾結點
         * last設置為本次p
         */
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        //最后節點p設置為尾結點,完事
        tail = p;
        ++modCount;
    }
}

我們前面說到的linkNodeLast(Entry e)方法和現在的afterNodeAccess(Node e)都是將傳入的Node節點放到最后,那么它們的使用場景如何呢?

在前面講解HashMap時,提到了HashMap的put流程,如果在對應的hash位置上還沒有元素,那么直接new Node()放到數組table中,這個時候對應到LinkedHashMap中,調用了newNode()方法,就會用到linkNodeLast(),將新node放到最后,而如果對應的hash位置上有元素,進行元素值的覆蓋時,就會調用afterNodeAccess(),將原本可能不是最后的node節點拿到了最后。如

LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("1月", 20);
//此時就會調用到linkNodeLast()方法,也會調用afterNodeAccess()方法,但會被阻擋在
//if (accessOrder && (last = tail) != e) 之外
map.put("2月", 30);
map.put("3月", 65);
map.put("4月", 43);
//這時不會調用linkNodeLast(),會調用afterNodeAccess()方法將key為“1月”的元素放到最后
map.put("1月", 35);
//這時不會調用linkNodeLast(),會調用afterNodeAccess()方法將key為“2月”的元素放到最后
map.get("2月");
//調用打印方法
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

結果如下:

key: 3月, value: 65
key: 4月, value: 43
key: 1月, value: 35
key: 2月, value: 30

而如果是執行下面這段代碼,將accessOrder改為false

LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, false);
map.put("1月", 20);
//此時就會調用到linkNodeLast()方法,也會調用afterNodeAccess()方法,但會被阻擋在
//if (accessOrder && (last = tail) != e) 之外
map.put("2月", 30);
map.put("3月", 65);
map.put("4月", 43);
//這時不會調用linkNodeLast(),會調用afterNodeAccess()方法將key為“1月”的元素放到最后
map.put("1月", 35);
map.get("2月");
//調用打印方法
for (Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println("key: " + entry.getKey() + ", value: " + entry.getValue());
}

結果如下:

key: 1月, value: 35
key: 2月, value: 30
key: 3月, value: 65
key: 4月, value: 43

大家看到區別了嗎,accessOrder為false時,你訪問的順序就是按照你第一次插入的順序;而accessOrder為true時,你任何一次的操作,包括put、get操作,都會改變map中已有的存儲順序。

3. afterNodeInsertion方法

我們看到在LinkedHashMap中還重寫了afterNodeInsertion(boolean evict)方法,它的目的是移除鏈表中最老的節點對象,也就是當前在頭部的節點對象,但實際上在JDK8中不會執行,因為removeEldestEntry方法始終返回false。看源碼:

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

三. get方法

LinkedHashMap的get方法與HashMap中get方法的不同點也在於多了afterNodeAccess()方法

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()方法在HashMap章節已經講過,而前面剛把afterNodeAccess講了。

四.remove方法

remove方法也直接使用了HashMap中的remove,在HashMap章節並沒有講解,因為remove的原理很簡單,通過傳遞的參數key計算出hash,據此可找到對應的Node節點,接下來如果該Node節點是直接在數組中的Node,則將table數組該位置的元素設置為node.next;如果是鏈表中的,則遍歷鏈表,直到找到對應的node節點,然后建立該節點的上一個節點的next設置為該節點的next。

LinkedHashMap重寫了其中的afterNodeRemoval(Node e),該方法在HashMap中沒有具體實現,通過此方法在刪除節點的時候調整了雙鏈表的結構。

void afterNodeRemoval(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    //將待刪除節點的before和after都設置為null
    p.before = p.after = null;
    /**
     * 如果節點b為null,表示待刪除節點p為頭部節點,該節點拿掉后,該節點的下一個節點a就為頭部節點head
     * 否則設置待刪除節點的上一個節點b的after屬性為節點a 
     */
    if (b == null)
        head = a;
    else
        b.after = a;
    /**
     * 如果節點a為null,表示待刪除節點p為尾部節點,該節點拿掉后,該節點的上一個節點a就為尾部節點tail
     * 否則設置待刪除節點的下一個節點a的before屬性為節點b 
     */
    if (a == null)
        tail = b;
    else
        a.before = b;
}

五. 總結

LinkedHashMap使用的也較為頻繁,它基於HashMap,用於HashMap的特點,又增加了雙鏈表的結構,從而保證了順序性,本文主要從源碼的角度分析其如何保證順序性,accessOrder的解釋,以及常用方法的闡釋,若有不對之處,請批評指正,望共同進步,謝謝!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM