1. 程式人生 > 實用技巧 >Java原始碼系列4——HashMap擴容時究竟對連結串列和紅黑樹做了什麼?

Java原始碼系列4——HashMap擴容時究竟對連結串列和紅黑樹做了什麼?

我們知道 HashMap 的底層是由陣列,連結串列,紅黑樹組成的,在 HashMap 做擴容操作時,除了把陣列容量擴大為原來的兩倍外,還會對所有元素重新計算 hash 值,因為長度擴大以後,hash值也隨之改變。

如果是簡單的 Node 物件,只需要重新計算下標放進去就可以了,如果是連結串列和紅黑樹,那麼操作就會比較複雜,下面我們就來看下,JDK1.8 下的 HashMap 在擴容時對連結串列和紅黑樹做了哪些優化?

rehash 時,連結串列怎麼處理?

假設一個 HashMap 原本 bucket 大小為 16。下標 3 這個位置上的 19, 3, 35 由於索引衝突組成連結串列。

image

當 HashMap 由 16 擴容到 32 時,19, 3, 35 重新 hash 之後拆成兩條連結串列。

image

檢視 JDK1.8 HashMap 的原始碼,我們可以看到關於連結串列的優化操作如下:

// 把原有連結串列拆成兩個連結串列
// 連結串列1存放在低位(原索引位置)
Node<K,V> loHead = null, loTail = null;
// 連結串列2存放在高位(原索引 + 舊陣列長度)
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    // 連結串列1
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    // 連結串列2
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
// 連結串列1存放於原索引位置
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
// 連結串列2存放原索引加上舊陣列長度的偏移量
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

正常我們是把所有元素都重新計算一下下標值,再決定放入哪個桶,JDK1.8 優化成直接把連結串列拆成高位和低位兩條,通過位運算來決定放在原索引處或者原索引加原陣列長度的偏移量處。我們通過位運算來分析下。

先回顧一下原 hash 的求餘過程:

image

再看一下 rehash 時,判斷時做的位操作,也就是這句e.hash & oldCap

image

再看下擴容後的實際求餘過程:

image

這波操作是不是很666,為什麼 2 的整數冪 - 1可以作 & 操作可以代替求餘計算,因為 2 的整數冪 - 1 的二進位制比較特殊,就是一串 11111,與這串數字 1 作 & 操作,結果就是保留下原數字的低位,去掉原數字的高位,達到求餘的效果。2 的整數冪的二進位制也比較特殊,就是一個 1 後面跟上一串 0。

HashMap 的擴容都是擴大為原來大小的兩倍,從二進位制上看就是給這串數字加個 0,比如 16 -> 32 = 10000 -> 100000,那麼他的 n - 1 就是 15 -> 32 = 1111 -> 11111。也就是多了一位,所以擴容後的下標可以從原有的下標推算出來。差異就在於上圖我標紅的地方,如果標紅處是 0,那麼擴容後再求餘結果不變,如果標紅處是 1,那麼擴容後再求餘就為原索引 + 原偏移量。如何判斷標紅處是 0 還是 1,就是把e.hash & oldCap

rehash 時,紅黑樹怎麼處理?

// 紅黑樹轉連結串列閾值
static final int UNTREEIFY_THRESHOLD = 6;

// 擴容操作
final Node<K,V>[] resize() {
    // ....
    else if (e instanceof TreeNode)
       ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
    // ...
}

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    // 和連結串列同樣的套路,分成高位和低位
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    /**
      * TreeNode 是間接繼承於 Node,保留了 next,可以像連結串列一樣遍歷
      * 這裡的操作和連結串列的一毛一樣
      */
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        // bit 就是 oldCap
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
            // 尾插
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    // 樹化低位連結串列
    if (loHead != null) {
        // 如果 loHead 不為空,且連結串列長度小於等於 6,則將紅黑樹轉成連結串列
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            /**
              * hiHead == null 時,表明擴容後,
              * 所有節點仍在原位置,樹結構不變,無需重新樹化
              */
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    // 樹化高位連結串列,邏輯與上面一致
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

從原始碼可以看出,紅黑樹的拆分和連結串列的邏輯基本一致,不同的地方在於,重新對映後,會將紅黑樹拆分成兩條連結串列,根據連結串列的長度,判斷需不需要把連結串列重新進行樹化。

摘自
https://www.jianshu.com/p/87d2ef48e645