接上一篇博文,來吧剩下的部分寫完。
整體來講,HashMap的實現內部有兩個關鍵點,第一是當表內元素和hash桶數組的比例達到某個閾值時會觸發擴容機制,不然表中的元素會愈來愈擠影響性能;
第二是保存hash衝突的鏈表若是過長,就重構爲紅黑樹提高性能。java
<!-- more -->
關於第二點,對於HashMap來講,達到O(1)的查詢性能只是平均時間複雜度,這須要key的hash值對應的位置分佈的足夠均勻。算法
來設想一種極端狀況,假設某個黑客故意構造一組特定的數據,這些數據的hash值正好同樣。當插入hash表中時,它們的位置也同樣。
那麼,這些數據會所有被組織到該位置的鏈表中,hash表退化爲鏈表,這時的查詢的時間複雜度爲O(N),也是hash表查詢時間複雜度的最壞狀況。數組
不過HashMap在鏈表過長時會將其重構爲紅黑樹,這樣,其最壞的時間複雜度就會下降爲O(logN),這樣使得hash表的適應場景更廣。函數
擴容分兩個步驟:性能
如下是第一個步驟的代碼:優化
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桶數組,而後把舊的數據遷移過來 /* ... */ }
邏輯是這樣的,首先有三種狀況,代碼寫的看起來很複雜:this
hash桶數組已經初始化過。code
雖然邏輯很明確,可是代碼寫的看起來卻很複雜。
其緣由是HashMap內部記錄的字段能表達的狀態太多,每種狀況都須要考慮周全。內存
第一階段執行完畢後,HashMap內部的部分狀態字段被更新。
最重要的是,newCap這個變量記錄了擴容以後的大小。ci
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桶數組,在其中保存有節點時,分不一樣狀況處理:
先來看下紅黑樹的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操做,乍一看,這個邏輯還有些看不懂,從代碼上來看是這樣的邏輯,對於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爲例子:
嚴謹的數學表達我實在懶得寫了,總之經過分析不可貴到這個結論:
有了以上結論,對照上面的代碼,也就不難理解這段rehash代碼的思路了:
(e.hash & oldCap) == 0
這句話是判斷hash值的對應位是否爲0,並分紅兩條不一樣的鏈表。
最後,我比較疑惑的一點是,花了這麼大力氣去優化,爲何能獲得性能或內存上的提高?
咱們分析下優化先後的時間複雜度:
看起來兩種方案都須要遍歷全部的鏈表節點,難道僅僅是減少一點時間複雜度的常數嗎?
以前說過當鏈表長度過大時會將其重構爲紅黑樹,下面來看具體的代碼。
// 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方法將其組織成紅黑樹。至於這個方法。。。各類旋轉,紅黑樹的操做算法自己是很複雜的,就略過不看了。