集合系列 Map(十二):HashMap

640?wx_fmt=png

HashMap 是 Map 基於哈希散列算法的實現,其在 JDK1.7 中採用了數組+鏈表的數據結構。在 JDK1.8 中爲了提高查詢效率,採用了數組+鏈表+紅黑樹的數據結構。本文所有講解均基於 JDK1.8 進行講解。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

從上面 HashMap 的定義可以看出,其繼承了 AbstractMap,實現了 Map 接口。

原理

我們將從類成員變量、構造方法、核心方法、擴容機制幾個方向介紹 HashMap 的原理。

類成員變量

// 默認大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大大小
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認擴展因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 樹化閾值。當超過這個閾值時,鏈表轉成紅黑樹。
static final int TREEIFY_THRESHOLD = 8;
// 鏈化閾值。當低於這個閾值時,紅黑樹轉成鏈表。
static final int UNTREEIFY_THRESHOLD = 6;
// 允許樹化的最小容量。只有容量超過此值時,才允許進行樹化操作。
static final int MIN_TREEIFY_CAPACITY = 64;
// 桶數組
transient Node<K,V>[] table;
// 大小
transient int size;
// 調整閾值
int threshold;
// 擴展因子
final float loadFactor;
// 省略其他不重要的變量

在上面的類成員變量中,最重要的是 table 這個變量,其實一個 Node 類型的數組。我們知道 HashMap 是一個數組 + 鏈表 + 紅黑樹的結構,其示意圖如下所示:

640?wx_fmt=jpeg

這裏的 table 數組就相當於上圖中的 bucket 數組,而每個數組下標都對應着一個個的 Node 節點。這個 Node 節點可能是鏈表節點,也可能是紅黑樹節點。說到 Node 節點,我們有必要詳細說說 Node 節點的類關係圖。

640?wx_fmt=jpeg

在上面的類關係圖中,最上層是 Map.Entry 接口,其是一條數據的抽象化,有 key 和 value 各種操作。接着,HashMap.Node 實現了 Map.Entry 接口,並且增加了 hash、key、value、next 等屬性,表示其是一個哈希節點。接着,LinkedHashMap.Entry 繼承了 HashMap.Node 節點,並且增加了 before、after 節點用來存儲元素的插入順序,表示其實一個維護着插入順序的鏈表哈希節點。最後,HashMap.TreeNode 繼承了 LinkedHashMap.Entry,並且增加了 parent、left、right、prev、red 節點用來存儲紅黑樹相關信息,表示其實一個紅黑樹的節點。但因爲其繼承自 LinkedHashMap.Entry,所以其也維護了插入元素的順序。

看完 Node 節點的類關係圖,我們再來看 HashMap 中定義的 Node 類型 table 數組。我們會發現這個 Node 類型,其實就是 HashMap.Node。如果該桶是鏈表,那麼插入的是 HashMap.Node 對象。如果是紅黑樹,那麼插入的是 HashMap.TreeNode 對象。

構造方法

HashMap 一共有 4 個構造方法。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

上面這幾個構造方法中,比較值得注意的是第 3 個構造方法。其中有這個一行代碼:

this.threshold = tableSizeFor(initialCapacity);

從上面的代碼我們可以看到:其調用了 tableSizeFor 方法,並將 initialCapacity 作爲參數傳入,最後將返回值設置給了 this.threshold。我們先看看 tableSizeFor 方法。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

簡單地說,tableSizeFor 的用途是:找到大於或等於 cap 的最小2的冪。具體的計算過程可以參考下圖。

640?wx_fmt=jpeg

最後我們回到剛剛的那個構造方法:

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

我們仔細看就會發現,雖然我們傳入了 initialCapacity,但是貌似沒有進行數據初始化工作呀。沒錯,HashMap 在創建的時候並不會進行數據的初始化,而是在真正插入的時候才進行初始化操作。這一部分的代碼在 resize (擴容)方法中,我們後續會講到。

核心方法

對於 HashMap 來說,作爲核心的幾個方法爲:get、put、remove。

get

HashMap 的查找操作比較簡單,首先定位鍵值對所在桶的位置,之後再對鏈表或紅黑樹進行查找。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
    
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1.桶不爲空,那麼進行查找,否則直接返回 null。
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 1.1 檢查要查找的是否是第一個節點
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 1.2 沿着第一個節點繼續查找
        if ((e = first.next) != null) {
            // 1.2.1 如果是紅黑樹,那麼調用紅黑樹的方法查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 1.2.2 如果是鏈表,那麼採用鏈表的方式查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

上面的邏輯其實不難看懂。但在上面計算 key 所在桶位置時,我們採用的是與運算,而不是取餘操作。

first = tab[(n - 1) & hash]) != null

HashMap 中的桶數組大小總是爲 2 的冪。在這個情況下,(n - 1) & hash 等價於對 length 取餘。但取餘的計算效率沒有位運算高,所以(n - 1) & hash也是一個小的優化。

另外,在計算哈希值的時候,我們會發現 hash 方法並不是直接用 key.hashCode 方法產生的哈希值,而是做了一些位操作。

/**
 * 計算鍵的 hash 值
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

看這個方法的邏輯好像是通過位運算重新計算 hash,那麼這裏爲什麼要這樣做呢?爲什麼不直接用鍵的 hashCode 方法產生的 hash 呢?

在上面的講解我們知道,我們最終會用 n - 1 和 hash 進行與運算,就像下面這樣。

640?wx_fmt=jpeg

但很多時候我們的 n(桶大小)都比較小,也就是說 n - 1 非常小。這樣就會導致做與操作時,無論 hash 值的高 4 位是什麼值,n - 1 & hash 的值的高四位都爲 0。也就是說hash 只有低4位參與了計算,高位的計算可以認爲是無效的。這樣會導致哈希出來的值只受 hash 低 4 位的影響,大大增加哈希碰撞的概率。

而 hash 方法中的 (h = key.hashCode()) ^ (h >>> 16) 其實是將 hash 值的高 16 位於低 16 位進行一次異或運算,從而加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程如下:

640?wx_fmt=jpeg

put

我們先看看 put 方法的實現。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

可以看到 HashMap 的 put 方法其實是調用了 putVal 方法。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. 如果未初始化,那麼調用 resize() 進行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 如果桶爲空,那麼直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 2.1 如果插入的元素與桶第一個元素相同,那麼直接跳出
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 2.2 如果第一個元素是紅黑樹節點,那麼調用紅黑樹的插入方法
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 2.3 如果是鏈表節點,那麼遍歷到鏈表尾部插入。但如果中間找到了相同的節點,那麼直接退出
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash); //判斷是否樹化
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 3. 如果插入的key已經存在,那麼根據參數判斷是否替代舊值
        // 這裏的 e 如果不爲 null,那麼就存儲着舊值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 4.判斷是否擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

上面代碼的大致邏輯爲:

  1. 如果桶數組 table 爲空,那麼通過擴容的方式初始化 table 數組。

  2. 如果插入的桶爲空,那麼直接插入。如果插入的桶不爲空,那麼判斷是否與該桶第一個節點相同。如果相同,那麼直接退出。否則根據節點不同類型,調用不同的插入方式。如果是紅黑樹節點,那麼調用 putTreeVal 方法。如果是鏈表節點,那麼直接插入鏈表尾部。在遍歷鏈表的過程中,會判斷節點是否存在。如果存在,則會直接跳出循環。

  3. 根據條件判斷 key 是否存在,如果存在則根據參數判斷是否替換舊值。

  4. 最後根據參數判斷是否擴容。

remove

HashMap 的刪除操作並不複雜,僅需三個步驟即可完成。第一步是定位桶位置,第二步遍歷鏈表並找到鍵值相等的節點,第三步刪除節點。相關源碼如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

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;
        // 1. 查找到要刪除的節點
        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 {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 2. 刪除查找到的節點
        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;
}

擴容機制

在 HashMap 中,桶數組的長度均是2的冪,閾值大小爲桶數組長度與負載因子的乘積。當 HashMap 中的鍵值對數量超過閾值時,進行擴容。

HashMap 的擴容機制與其他變長集合的套路不太一樣,HashMap 按當前桶數組長度的2倍進行擴容,閾值也變爲原來的2倍(如果計算過程中,閾值溢出歸零,則按閾值公式重新計算)。擴容之後,要重新計算鍵值對的位置,並把它們移動到合適的位置上去。以上就是 HashMap 的擴容大致過程,接下來我們來看看具體的實現:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 1.根據不同情況,設置新的容量和閾值
    // 1.1 如果不爲空,表示已經初始化了。
    if (oldCap > 0) {
        // 超過最大容量,不再擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 按照舊容量和舊閾值的2倍計算新容量和新閾值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 1.2 走到這裏,表示 oldCap <= 0。如果此時,oldThr > 0,表示有設置了初始值
    // 那麼將初始值 oldThr 作爲新的容量大小。
    // 注意:我們在初始化時調用 tableForSize 參數,將初始大小存在了threshold中
    // 所以此時 oldThr 就是我們設置的 initCapacity
    else if (oldThr > 0)
        newCap = oldThr;
    else {
    // 1.3 到這裏,說明之前沒有初始化,也沒有設置初始值,那麼就按照默認值進行設置
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 2. 如果新的閾值爲0,那麼就按照閾值計算公式計算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 3. 開始複製到新的數組
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 3.1 循環遍歷舊的 table 數組
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 3.1.1 如果該桶只有一個元素,那麼直接複製
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 3.1.2 如果死紅黑樹,那麼對紅黑樹進行拆分
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 3.1.3 遍歷鏈表,將原鏈表節點分成lo和hi兩個鏈表
                // 其中 lo 表示在原來的桶位置,hi 表示在新的桶位置
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 3.1.3.1 hash & oldCap 等於0,表示在原位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 3.1.3.2 hash & oldCap 不等於0,表示要移位
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 3.1.3.3 將分組後的鏈表映射到新桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

在上面的擴容過程中,最重要的是其怎麼將鏈表拆分到合適的位置上。我們先來看一個例子。

640?wx_fmt=jpeg

在上面這個例子中,capacity 爲 8,在 第 4 個桶上有:35、27、19、43 這四個節點。其擴容前後的計算過程如下:

// 擴容前
100011  // 35
000111  // n-1=8-1=7
000011  // 35&n-1 = 3
// 擴容後
100011  // 35
001111  // n-1=16-1=15
000011  // 35&n-1 = 3

你會發現其擴容前後的值都爲 3。我們再來看看 27 這個節點在擴容前和擴容後的計算過程:

// 擴容前
011011  // 27
000111  // n-1=8-1=7
000011  // 27&n-1 = 3
// 擴容後
011011  // 27
001111  // n-1=16-1=15
001011  // 27&n-1 = 11

你會發現 27 這個節點擴容後的桶位置發生了變化。這是因爲擴容後,參與模運算的位數由4位變爲了5位。由於兩個 27 和 35 兩個節點第5位的值是不一樣,所以兩個 hash 算出的結果也不一樣。而且其變化後的位置爲原來的位置加上第5位的值,也就是 olcCapacity + 原位置(對於本例中的 27 就是:3 + 8 = 11)。

知道了這個規律,那麼我們再來看鏈表分組的代碼就簡單多了。

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    // 3.1.3.1 hash & oldCap 等於0,表示在原位置
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    // 3.1.3.2 hash & oldCap 不等於0,表示要移位
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);

從上面的代碼可以看到最外層是一個循環,遍歷整個鏈表。3.1.3.1 就是判斷如果是 e.hash & oldCap = 0(即原 hash 值某一位位0,那麼其位置就不變),那麼就放在 loTail 爲首的鏈表中,這個鏈表存的是擴容後放置在原來桶位置的節點。而 hiTail 放置的則是要移位到 oldCapacity + 原位置 的鏈表。

// 3.1.3.3 將分組後的鏈表映射到新桶中
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

當處理完成後,我們可以看到其直接將 table 桶指向了 loHead 或 hiHead 節點。

鏈表樹化

當桶中鏈表長度超過 TREEIFY_THRESHOLD(默認爲8)時,就會調用 treeifyBin 方法進行樹化操作。但此時並不一定會樹化,因爲在 treeifyBin 方法中還會判斷 HashMap 的容量是否大於等於 64。如果容量大於等於 64,那麼進行樹化,否則優先進行擴容。

爲什麼樹化還要判斷整體容量是否大於 64 呢?

當桶數組容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致鏈表長度較長,從而導致查詢效率低下。這時候我們有兩種選擇,一種是擴容,讓哈希碰撞率低一些。另一種是樹化,提高查詢效率。

如果我們採用擴容,那麼我們需要做的就是做一次鏈表數據的複製。而如果我們採用樹化,那麼我們需要將鏈表轉化成紅黑樹。到這裏,貌似沒有什麼太大的區別,但我們讓場景繼續延伸下去。當插入的數據越來越多,我們會發現需要轉化成樹的鏈表越來越多。

到了一定容量,我們就需要進行擴容了。這個時候我們有許多樹化的紅黑樹,在擴容之時,我們需要將許多的紅黑樹拆分成鏈表,這是一個挺大的成本。而如果我們在容量小的時候就進行擴容,那麼需要樹化的鏈表就越少,我們擴容的成本也就越低。

接下來我們看看鏈表樹化是怎麼做的:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 1. 容量小於 MIN_TREEIFY_CAPACITY,優先擴容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 2. 桶不爲空,那麼進行樹化操作
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        // 2.1 先將鏈表轉成 TreeNode 表示的雙向鏈表
        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);
        // 2.2 將 TreeNode 表示的雙向鏈表樹化
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

我們可以看到鏈表樹化的整體思路也比較清晰。首先將鏈表轉成 TreeNode 表示的雙向鏈表,之後再調用 treeify() 方法進行樹化操作。那麼我們繼續看看 treeify() 方法的實現。

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    // 1. 遍歷雙向 TreeNode 鏈表,將 TreeNode 節點一個個插入
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        // 2. 如果 root 節點爲空,那麼直接將 x 節點設置爲根節點
        if (root == null) {
            x.parent = null;
            x.red = false;
            root = x;
        }
        else {
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            // 3. 如果 root 節點不爲空,那麼進行比較並在合適的地方插入
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                // 4. 計算 dir 值,-1 表示要從左子樹查找插入點,1表示從右子樹
                if ((ph = p.hash) > h)
                    dir = -1;
                else if (ph < h)
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);

                TreeNode<K,V> xp = p;
                // 5. 如果查找到一個 p 點,這個點是葉子節點
                // 那麼這個就是插入位置
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    // 做插入後的動平衡
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    // 6.將 root 節點移動到鏈表頭
    moveRootToFront(tab, root);
}

從上面代碼可以看到,treeify() 方法其實就是將雙向 TreeNode 鏈表進一步樹化成紅黑樹。其中大致的步驟爲:

  • 遍歷 TreeNode 雙向鏈表,將 TreeNode 節點一個個插入

  • 如果 root 節點爲空,那麼表示紅黑樹現在爲空,直接將該節點作爲根節點。否則需要查找到合適的位置去插入 TreeNode 節點。

  • 通過比較與 root 節點的位置,不斷尋找最合適的點。如果最終該節點的葉子節點爲空,那麼該節點 p 就是插入節點的父節點。接着,將 x 節點的 parent 引用指向 xp 節點,xp 節點的左子節點或右子節點指向 x 節點。

  • 接着,調用 balanceInsertion 做一次動態平衡。

  • 最後,調用 moveRootToFront 方法將 root 節點移動到鏈表頭。

關於 balanceInsertion() 動平衡可以參考紅黑樹的插入動平衡,這裏暫不深入講解。最後我們繼續看看 moveRootToFront 方法。

static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
    int n;
    if (root != null && tab != null && (n = tab.length) > 0) {
        int index = (n - 1) & root.hash;
        TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
        // 如果插入紅黑樹的 root 節點不是鏈表的第一個元素
        // 那麼將 root 節點取出來,插在 first 節點前面
        if (root != first) {
            Node<K,V> rn;
            tab[index] = root;
            TreeNode<K,V> rp = root.prev;
            // 下面的兩個 if 語句,做的事情是將 root 節點取出來
            // 讓 root 節點的前繼指向其後繼節點
            // 讓 root 節點的後繼指向其前繼節點
            if ((rn = root.next) != null)
                ((TreeNode<K,V>)rn).prev = rp;
            if (rp != null)
                rp.next = rn;
            // 這裏直接讓 root 節點插入到 first 節點前方
            if (first != null)
                first.prev = root;
            root.next = first;
            root.prev = null;
        }
        assert checkInvariants(root);
    }
}

上面的代碼註釋寫得非常清楚了,這裏就不再細講了。

紅黑樹拆分

擴容後,普通節點需要重新映射,紅黑樹節點也不例外。按照一般的思路,我們可以先把紅黑樹轉成鏈表,之後再重新映射鏈表即可。但因爲紅黑樹插入的時候,TreeNode 保存了元素插入的順序,所以直接可以按照插入順序還原成鏈表。這樣就避免了將紅黑樹轉成鏈表後再進行哈希映射,無形中提高了效率。

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;
    // 1. 將紅黑樹當成是一個 TreeNode 組成的雙向鏈表
    // 按照鏈表擴容一樣,分別放入 loHead 和 hiHead 開頭的鏈表中
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        // 1.1. 擴容後的位置不變,還是原來的位置,該節點放入 loHead 鏈表
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        // 1.2 擴容後的位置改變了,放入 hiHead 鏈表
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    // 2. 對 loHead 和 hiHead 進行樹化或鏈表化
    if (loHead != null) {
        // 2.1 如果鏈表長度小於閾值,那就鏈表化,否則樹化
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            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);
        }
    }
}

從上面的代碼我們知道紅黑樹的擴容也和鏈表的轉移是一樣的,不同的是其轉化成 hiHead 和 loHead 之後,會根據鏈表長度選擇拆分成鏈表還是繼承維持紅黑樹的結構。

紅黑樹鏈化

我們在說到紅黑樹拆分的時候說到,紅黑樹結構在擴容的時候如果長度低於閾值,那麼就會將其轉化成鏈表。其實現代碼如下:

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    for (Node<K,V> q = this; q != null; q = q.next) {
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

因爲紅黑樹中包含了插入元素的順序,所以當我們將紅黑樹拆分成兩個鏈表 hiHead 和 loHead 時,其還是保持着原來的順序的。所以此時我們只需要循環遍歷一遍,然後將 TreeNode 節點換成 Node 節點即可。

本文部分圖片來源於田小波的博客

總結

HashMap 中涉及到的細節還有非常多,這裏也沒有事無鉅細地將所有細節寫完。如果有興趣可以自己再研讀一下 HashMap 的源碼,特別是關於紅黑樹節點 TreeNode 的實現。

  • HashMap擴容每次都爲原來的兩倍。

  • 當鏈表長度大於8的時候,如果HashMap容量大於64,那麼會將鏈表樹化,提高查詢效率。


推薦閱讀

640?wx_fmt=png

公衆號@陳樹義,用最簡單的語言,分享我的技術見解。

↑↑創作不易,如果喜歡請轉發↑↑

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