title: Java源碼系列4——HashMap擴容時究竟對鏈表和紅黑樹做了什麼?
date: 2020-06-04 11:27:53
updated: 2020-06-04 11:27:53
tags:
- Java
- Java源碼系
Photo by hippopx.com
我們知道 HashMap 的底層是由數組,鏈表,紅黑樹組成的,在 HashMap 做擴容操作時,除了把數組容量擴大爲原來的兩倍外,還會對所有元素重新計算 hash 值,因爲長度擴大以後,hash值也隨之改變。
如果是簡單的 Node 對象,只需要重新計算下標放進去就可以了,如果是鏈表和紅黑樹,那麼操作就會比較複雜,下面我們就來看下,JDK1.8 下的 HashMap 在擴容時對鏈表和紅黑樹做了哪些優化?
rehash 時,鏈表怎麼處理?
假設一個 HashMap 原本 bucket 大小爲 16。下標 3 這個位置上的 19, 3, 35 由於索引衝突組成鏈表。
當 HashMap 由 16 擴容到 32 時,19, 3, 35 重新 hash 之後拆成兩條鏈表。
查看 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 的求餘過程:
再看一下 rehash 時,判斷時做的位操作,也就是這句 e.hash & oldCap
:
再看下擴容後的實際求餘過程:
這波操作是不是很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);
}
}
}
從源碼可以看出,紅黑樹的拆分和鏈表的邏輯基本一致,不同的地方在於,重新映射後,會將紅黑樹拆分成兩條鏈表,根據鏈表的長度,判斷需不需要把鏈表重新進行樹化。
源碼系列文章
參考
本文首發於我的個人博客 http://chaohang.top
作者張小超
轉載請註明出處
歡迎關注我的微信公衆號 【超超不會飛】,獲取第一時間的更新。