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 是一個數組 + 鏈表 + 紅黑樹的結構,其示意圖如下所示:
這里的 table 數組就相當於上圖中的 bucket 數組,而每個數組下標都對應着一個個的 Node 節點。這個 Node 節點可能是鏈表節點,也可能是紅黑樹節點。說到 Node 節點,我們有必要詳細說說 Node 節點的類關系圖。
在上面的類關系圖中,最上層是 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的冪。具體的計算過程可以參考下圖。
最后我們回到剛剛的那個構造方法:
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 進行與運算,就像下面這樣。
但很多時候我們的 n(桶大小)都比較小,也就是說 n - 1 非常小。這樣就會導致做與操作時,無論 hash 值的高 4 位是什么值,n - 1 & hash 的值的高四位都為 0。也就是說hash 只有低4位參與了計算,高位的計算可以認為是無效的。這樣會導致哈希出來的值只受 hash 低 4 位的影響,大大增加哈希碰撞的概率。
而 hash 方法中的 (h = key.hashCode()) ^ (h >>> 16)
其實是將 hash 值的高 16 位於低 16 位進行一次異或運算,從而加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程如下:
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;
}
上面代碼的大致邏輯為:
- 如果桶數組 table 為空,那么通過擴容的方式初始化 table 數組。
- 如果插入的桶為空,那么直接插入。如果插入的桶不為空,那么判斷是否與該桶第一個節點相同。如果相同,那么直接退出。否則根據節點不同類型,調用不同的插入方式。如果是紅黑樹節點,那么調用 putTreeVal 方法。如果是鏈表節點,那么直接插入鏈表尾部。在遍歷鏈表的過程中,會判斷節點是否存在。如果存在,則會直接跳出循環。
- 根據條件判斷 key 是否存在,如果存在則根據參數判斷是否替換舊值。
- 最后根據參數判斷是否擴容。
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;
}
在上面的擴容過程中,最重要的是其怎么將鏈表拆分到合適的位置上。我們先來看一個例子。
在上面這個例子中,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,那么會將鏈表樹化,提高查詢效率。