HashMap1.8源碼分析(紅黑樹)


轉載:https://segmentfault.com/a/1190000012926722?utm_source=tag-newest

https://blog.csdn.net/weixin_40255793/article/details/80748946(方法全面)

方法

treeifyBin(普通節點鏈表轉換成樹形節點鏈表)

static final int TREEIFY_THRESHOLD = 8;

/**
 * 當桶數組容量小於該值時,優先進行擴容,而不是樹化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

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

/**
 * 將普通節點鏈表轉換成樹形節點鏈表
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶數組容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 為頭節點(head),tl 為尾節點(tail)
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 將普通節點替換成樹形節點
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);  // 將普通鏈表轉成由樹形節點鏈表
        if ((tab[index] = hd) != null)
            // 將樹形鏈表轉換成紅黑樹
            hd.treeify(tab);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}
View Code

在擴容過程中,樹化要滿足兩個條件:

  1. 鏈表長度大於等於 TREEIFY_THRESHOLD    8
  2. 桶數組容量大於等於 MIN_TREEIFY_CAPACITY    64

第一個條件比較好理解,這里就不說了。這里來說說加入第二個條件的原因,個人覺得原因如下:

當桶數組容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致鏈表長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶數組容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新映射。所以在桶容量比較小的情況下,將長鏈表轉成紅黑樹是一件吃力不討好的事。

我們繼續看一下 treeifyBin 方法。該方法主要的作用是將普通鏈表轉成為由 TreeNode 型節點組成的鏈表,並在最后調用 treeify 是將該鏈表轉為紅黑樹。TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原鏈表的節點順序最終通過 next 引用被保存下來。我們假設樹化前,鏈表結構如下:

HashMap 在設計之初,並沒有考慮到以后會引入紅黑樹進行優化。所以並沒有像 TreeMap 那樣,要求鍵類實現 comparable 接口或提供相應的比較器。但由於樹化過程需要比較兩個鍵對象的大小,在鍵類沒有實現 comparable 接口的情況下,怎么比較鍵與鍵之間的大小了就成了一個棘手的問題。為了解決這個問題,HashMap 是做了三步處理,確保可以比較出兩個鍵的大小,如下:

  1. 比較鍵與鍵之間 hash 的大小,如果 hash 相同,繼續往下比較
  2. 檢測鍵類是否實現了 Comparable 接口,如果實現調用 compareTo 方法進行比較
  3. 如果仍未比較出大小,就需要進行仲裁了,仲裁方法為 tieBreakOrder(大家自己看源碼吧)

tie break 是網球術語,可以理解為加時賽的意思,起這個名字還是挺有意思的。

通過上面三次比較,最終就可以比較出孰大孰小。比較出大小后就可以構造紅黑樹了,最終構造出的紅黑樹如下:

橙色的箭頭表示 TreeNode 的 next 引用。由於空間有限,prev 引用未畫出。可以看出,鏈表轉成紅黑樹后,原鏈表的順序仍然會被引用仍被保留了(紅黑樹的根節點會被移動到鏈表的第一位),我們仍然可以按遍歷鏈表的方式去遍歷上面的紅黑樹。這樣的結構為后面紅黑樹的切分以及紅黑樹轉成鏈表做好了鋪墊,我們繼續往下分析。

split(紅黑樹拆分)

擴容后,普通節點需要重新映射,紅黑樹節點也不例外。按照一般的思路,我們可以先把紅黑樹轉成鏈表,之后再重新映射鏈表即可。這種處理方式是大家比較容易想到的,但這樣做會損失一定的效率。不同於上面的處理方式,HashMap 實現的思路則很好。如上節所說,在將普通鏈表轉成紅黑樹時,HashMap 通過兩個額外的引用 next 和 prev 保留了原鏈表的節點順序。這樣再對紅黑樹進行重新映射時,完全可以按照映射鏈表的方式進行。這樣就避免了將紅黑樹轉成鏈表后再進行映射,無形中提高了效率。

// 紅黑樹轉鏈表閾值
static final int UNTREEIFY_THRESHOLD = 6;

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;
    /* 
     * 紅黑樹節點仍然保留了 next 引用,故仍可以按鏈表方式遍歷紅黑樹。
     * 下面的循環是對紅黑樹節點進行分組,與上面類似
     */
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        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 {
            tab[index] = loHead;
            /* 
             * hiHead == null 時,表明擴容后,
             * 所有節點仍在原位置,樹結構不變,無需重新樹化
             */
            if (hiHead != null) 
                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);
        }
    }
}
View Code

從源碼上可以看得出,重新映射紅黑樹的邏輯和重新映射鏈表的邏輯基本一致。不同的地方在於,重新映射后,會將紅黑樹拆分成兩條由 TreeNode 組成的鏈表。如果鏈表長度小於 UNTREEIFY_THRESHOLD,則將鏈表轉換成普通鏈表。否則根據條件重新將 TreeNode 鏈表樹化。

被 transient 所修飾 table 變量

如果大家細心閱讀 HashMap 的源碼,會發現桶數組 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變量不會被默認的序列化機制序列化。我們再回到源碼中,考慮一個問題:桶數組 table 是 HashMap 底層重要的數據結構,不序列化的話,別人還怎么還原呢?

這里簡單說明一下吧,HashMap 並沒有使用默認的序列化機制,而是通過實現readObject/writeObject兩個方法自定義了序列化的內容。這樣做是有原因的,試問一句,HashMap 中存儲的內容是什么?不用說,大家也知道是鍵值對。所以只要我們把鍵值對序列化了,我們就可以根據鍵值對數據重建 HashMap。有的朋友可能會想,序列化 table 不是可以一步到位,后面直接還原不就行了嗎?這樣一想,倒也是合理。但序列化 talbe 存在着兩個問題:

  1. table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間
  2. 同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。

以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終調用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實現,產生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平台下可能會產生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。

綜上所述,大家應該能明白 HashMap 不序列化 table 的原因了。


免責聲明!

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



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