1. 程式人生 > >LinkedHashMap如何保證順序性

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的解釋,以及常用方法的闡釋,若有不對之處,請批評指正,望共同進步,謝謝