接上一篇博文,來吧剩下的部分寫完。
總體來說,HashMap的實現內部有兩個關鍵點,第一是當表內元素和hash桶陣列的比例達到某個閾值時會觸發擴容機制,否則表中的元素會越來越擠影響效能;
第二是儲存hash衝突的連結串列如果過長,就重構為紅黑樹提升效能。
<!– more –>
關於第二點,對於HashMap來說,達到O(1)的查詢效能只是平均時間複雜度,這需要key的hash值對應的位置分佈的足夠均勻。
來設想一種極端情況,假設某個黑客故意構造一組特定的資料,這些資料的hash值正好一樣。當插入hash表中時,它們的位置也一樣。
那麼,這些資料會全部被組織到該位置的連結串列中,hash表退化為連結串列,這時的查詢的時間複雜度為O(N),也是hash表查詢時間複雜度的最壞情況。
不過HashMap在連結串列過長時會將其重構為紅黑樹,這樣,其最壞的時間複雜度就會降低為O(logN),這樣使得hash表的適應場景更廣。
resize擴容
擴容分兩個步驟:
- 計算擴容之後的大小。
- 進行具體的擴容操作。
計算擴容後大小
以下是第一個步驟的程式碼:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // 已經初始化過的情況
// 對邊界情況的處理:如果hash桶陣列的大小已經達到了最大值MAXINUM_CAPACITY 這裡是2的30次方
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
} // 擴容兩倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold // 這種情況對照建構函式看
newCap = oldThr;
else { // zero initial threshold signifies using defaults // 這種情況對照建構函式看
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { // =_= 邏輯好繞
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 到此,newCap是新的hash桶陣列大小,newThr是新的擴容閾值
threshold = newThr;
// 分配一個新的hash桶陣列,然後把舊的資料遷移過來
/* ... */
}
邏輯是這樣的,首先有三種情況,程式碼寫的看起來很複雜:
-
hash桶陣列已經初始化過。
- 擴容後是會溢位,也即達到了2的30次方。
- 擴容後不會溢位,這種情況擴容兩倍。擴容後hash桶陣列的大小依然是2的冪。
- hash桶陣列沒有初始化過,但是指定了初始化大小。
- hash桶陣列沒有初始化過,也沒有指定初始化大小。
雖然邏輯很明確,但是程式碼寫的看起來卻很複雜。
其原因是HashMap內部記錄的欄位能表達的狀態太多,每種情況都需要考慮周全。
第一階段執行完畢後,HashMap內部的部分狀態欄位被更新。
最重要的是,newCap這個變數記錄了擴容之後的大小。
執行擴容操作
final Node<K,V>[] resize() {
/* ... */
// 分配一個新的hash桶陣列,然後把舊的資料遷移過來
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 只有一個元素的情況
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 二叉樹的情況
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
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;
if ((e.hash & oldCap) == 0) {
// 如果注意到hash桶陣列擴容是從2^N 到 2^(N +1) 這一事實,從二進位制的角度分析取餘運算,就不難發現優化思路。
// 總之,這個迭代的程式碼是把這條連結串列拆分成兩條,然而不同的處理邏輯。
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 對於這兩種不同型別的連結串列,移動的方式不一樣
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
從思路上,總結如下:
- 分配一個新的hash桶陣列,這是擴容後的陣列。之後需要把之前的節點遷移過來。
-
遍歷舊的hash桶陣列,在其中儲存有節點時,分不同情況處理:
- 只有一個節點的情況,直接將這個節點rehash到新的陣列中。
- 該節點代表一棵紅黑樹。呼叫紅黑樹的相關方法完成操作。
- 該節點代表一個連結串列。將連結串列節點rehash到新的陣列中。
先來看下紅黑樹的split函式:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
/* ... */
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
// 只是這裡面有一個邏輯,即如果拆分出的樹太小,就重新轉換回連結串列
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
/* ... */
}
紅黑樹的各種操作程式碼我是無心看,各種旋轉太複雜了。這裡面主要有一個關鍵點,在於rehash的時候,會將紅黑樹節點也rehash。
同樣,和連結串列的rehash一樣,也是將紅黑樹拆分成兩條子樹。至於為什麼是拆分為兩條後面會說。
但是,如果拆分出來的子樹太小了,就會重新將其重構回連結串列。
順便說一句,由於刪除操作的邏輯沒有什麼新東西之前就沒有分析。我也沒有在其中找到刪除節點時,如果紅黑樹太小會將其重構回連結串列的操作。
rehash優化
對於連結串列的rehash操作,乍一看,這個邏輯還有些看不懂,從程式碼上來看是這樣的邏輯,對於hash桶陣列中第j個位置上的一個連結串列,進行遍歷,根據條件分成兩條:
(e.hash & oldCap) == 0
滿足上述條件的串成一條連結串列loHead
,不滿足上述條件的串成一條連結串列hiHead
。之後:
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
實際上,由於HashMap的hash桶陣列的大小一定為2的冪這一性質,取餘操作能夠被優化。前面也說過這一點,這裡以大小為8,也即0001000為例子:
- 設一個2的冪次方數N,如00001000,二進位制寫法中一定只有一個1.
- 任意一個數B餘N,反映到二進位制上,就是高於等於1的對應位置0,低於的保留。如00111110 % 00001000 = 00000110,前5位置0,後4位保留。
- 假如讓這個數餘2N,不難發現,反映到二進位制上,變成了前4位置0,後4為保留。
-
嚴謹的數學表達我實在懶得寫了,總之通過分析不難得到這個結論:
- 如果數B的第3位(從低位從0開始數)為0,那麼B % N = B % 2N。
- 如果數B的第3位(從低位從0開始數)為1,那麼B % N結果的第3位給置1等於B % 2N,也即B % N + N = B % 2N
有了以上結論,對照上面的程式碼,也就不難理解這段rehash程式碼的思路了:
(e.hash & oldCap) == 0
這句話是判斷hash值的對應位是否為0,並分成兩條不同的連結串列。
- 如果為0,則rehash後的位置不變。
- 如果不為0,則為以前的位置加上舊錶的大小。
最後,我比較疑惑的一點是,花了這麼大力氣去優化,為什麼能得到效能或記憶體上的提升?
我們分析下優化前後的時間複雜度:
- 如果不優化,則是遍歷舊的hash桶陣列,然後遍歷每一個連結串列,並且把連結串列的每個節點rehash到新的hash桶陣列上去。將連結串列插入到新的陣列只需O(1)的時間,也即整個操作的時間複雜度為O(N),N為hash表中元素的個數。
- 如果優化,則是遍歷舊的hash桶陣列,然後同樣需要遍歷每一個連結串列,把每一個節點分開到兩條不同的子連結串列上去。。。時間複雜度仍然是O(N)…
看起來兩種方案都需要遍歷所有的連結串列節點,難道僅僅是減小一點時間複雜度的常數嗎?
treeifyBin操作
之前說過當連結串列長度過大時會將其重構為紅黑樹,下面來看具體的程式碼。
// 8. 把連結串列轉換成二叉樹
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果hash桶陣列的大小太小還得擴容。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 所需要的hash引數是為了定位是hash桶陣列中的那個連結串列,可為啥不直接傳index...
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
// 遍歷單連結串列,然後把給它們一個個的分配TreeNode節點
// 看下面這程式碼,這個TreeNode,記得擁有next和prev欄位,看下面的程式碼是把它們串成雙連結串列
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)
// 呼叫TreeNod.treeify()函式將這個已經組成雙連結串列的TreeNode節點重構成紅黑樹
hd.treeify(tab);
}
}
之前提到過TreeNode擁有next和prev欄位,因此它不僅能夠用來組織紅黑樹,還能夠組織雙向連結串列。
這裡看到了,這裡首先將單連結串列的元素複製到TreeNode節點構成的雙向連結串列中,然後通過TreeNode的treeify方法將其組織成紅黑樹。至於這個方法。。。各種旋轉,紅黑樹的操作演算法本身是很複雜的,就略過不看了。