(接上文《源碼閱讀(19):Java中主要的Map結構——HashMap容器(下1)》)
3.4.6、HashMap擴容操作
3.4.6.1、HashMap擴容操作場景
在上文講解HashMap容器中的添加操作時,我們就知道在如下幾種情況下HashMap會進行擴容操作,擴容操作主要是對HashMap容器中的table數組進行容量擴充——使用一個更大的數組:
- 當table數組爲null或者長度爲0的時候,需要進行擴容:
在負責添加新的K-V鍵值對的putVal()方法中這種條件對應的代碼片對如下所示:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ......
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// ......
}
發生這種操作情況的場景,一般就是HashMap容器剛使用類似“HashMap()”這樣的構造函數完成初始化後,第一次進行K-V鍵值對添加時。
- 當新的K-V鍵值對添加後,容器中K-V鍵值對數量將超過“門檻值”的時候,需要進行擴容:
在putVal()方法中這種條件對應的代碼片對如下所示:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ......
if (++size > threshold)
resize();
// ......
}
threshold變量既是上文中介紹過的“門檻值”,根據以上的代碼片段,當HashMap容器的大小已經超過這個“門檻值”時,就進行擴容操作。threshold變量值的來源上文也介紹過——通過tableSizeFor()方法計算得到。這個tableSizeFor()方法可以計算出大於當前方法入參,並和當前tableSizeFor()方法入參“最接近”的2的冪數。擴容“門檻值”是可以變化的,具體策略可參見以下介紹的詳細擴容過程。
3.4.6.2、HashMap擴容操作過程
final Node<K,V>[] resize() {
// 將擴容前的數組應用記爲oldTab變量
Node<K,V>[] oldTab = table;
// 該變量記錄擴容前的數組大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 該變量記錄擴容操作前的“門檻值”
int oldThr = threshold;
// newCap代表擴容後新的數據容量值,請注意區別數組容量和HashMap容器的數據大小值
// newThr代表擴容後新得到的“門檻值”
int newCap, newThr = 0;
// =========操作步驟一:根據當前HashMap容器的大小,確認新的數組容量值和新的“門檻值”
// 如果擴容前的數組容量大於0,則執行一下操作
if (oldCap > 0) {
// 如果擴容前的數組容量(數組大小)大於HashMap容器設定的最大數組容量(1073741824 )
// 則設定下一次擴容門檻值爲32爲整數最大值,並且不再進行真實的擴容操作。這也意味着HashMap容器中的數組不會再擴容了
// 這種情況下,擴容操作將返回原來的數組大小
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果條件成立,說明擴容後新的數組容量將小於HashMap容器設定的最大數組容量
// 並且擴容前的數組容量大於DEFAULT_INITIAL_CAPACITY(16)
// 這是擴容操作中最常見的情況,這種情況設定新的數組容量爲原容量的2倍、設定新的門檻值爲原門檻值的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果擴容前的門檻值大於0(這個判定條件的優先級低於以oldCap參數爲依據的判定條件)
// 這種情況出現在使用諸如“HashMap(int initialCapacity, float loadFactor)”這樣的構造函數完成實例化後的第一次擴容時,
// 因爲這時原有的數組大小容量(oldCap)的值爲0,而threshold的值通過tableSizeFor(int)方法的計算後將大於0
// 這時設定新的數據容量爲原始的門檻值
// initial capacity was placed in threshold
else if (oldThr > 0)
newCap = oldThr;
// 如果擴容前數組容量等於0;並且擴容前的門檻值等於0
// 這種情況出現在使用HashMap()構造函數進行實例化,並且進行第一次擴容的情況
// zero initial threshold signifies using defaults
else {
// 新的數組的容量爲16
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的擴容的門檻值(下一次擴容的門檻值) = 默認負載因子(0.75) * 默認的初始化數組容量(16)
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的擴容門檻值等於0,這種情況承接以上代碼中“oldThr > 0”這樣的處理條件。
// 那麼新的門檻值 = 新的數組容量 * 當前的負載因子
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 通過以上計算後,得到的擴容後新的數組容量的值一定爲2的N次冪數,例如32、64、128......
// =========操作步驟二:在擴容後,對原數組中各K-V鍵值對對象重新進行平衡
// 根據新的數組容量值創建一個新的數組
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// HashMap容器擴容操作的核心點,並不在於重新計算擴容後數組的新的容量值和新的擴容門檻值,
// 而在於擴容成新的數組後,原有數組上的值如何重新“平衡”,以下就是平衡過程:
// 首要條件是隻有在擴容前的數組不爲空的的情況下,才進行原數組中各K-V鍵值對對象的再平衡操作
if (oldTab != null) {
// 依次遍歷擴容前數組上的每一個索引位,注意這些索引位可能一個K-V鍵值對都沒有
// 也可能有多個K-V鍵值對對象信息,並且以單向鏈表方式存在
// 也可能有多個K-V鍵值對對象信息,並且以紅黑樹方式存在
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果當前遍歷的索引位沒有任何K-V鍵值對信息,則不需要進行重新平衡處理
if ((e = oldTab[j]) != null) {
// 設置爲null,以便於CG
oldTab[j] = null;
// 如果條件成立,則說明基於當前索引位的桶結構上,只有一個K-V鍵值對結點
// 這是通過“e.hash & (newCap - 1)”重新計算這個K-V鍵值對象在新的數組中的存儲位置
// 注意由於採用newCap - 1的計算方式,所以這個值一定不會newCap - 1,這個計算公式的特性在後文中還要進行說明
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果條件成立,說明基於當前索引位的桶結構上是一個紅黑樹,那麼通過TreeNode.split()方法進行K-V鍵值對對象的平衡
// 實際操作是對當前索引位上的紅黑樹進行拆分
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 其它情況,說明基於當前索引位的桶結構上是一個單向鏈表,且鏈表上的對象數據一定大於1;
// 那麼就要開始進行鏈表上每一個節點位置的調整了?爲什麼要做這樣的調整呢?後文會有詳細的說明
else {
// 以下一大段代碼起到的作用,是將原數組當前索引位上(當前桶),以鏈表結構描述的結構,隨機拆分成兩個新的鏈表
// 其中一個新的鏈表,作爲新的數組相同索引位上的鏈表結構
// 另一個新的鏈表,作爲新的數組相同索引位 + oldCap的索引位上的鏈表結果。以便保證節點的重新分配。
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 將當前e節點的next引用(就是當前節點的下一個節點引用),賦值給next變量
// 請注意:第一次do循環的情況下,e.next一定有新的對象引用,因爲e.next == null的情況已將在上文中判定過
next = e.next;
// 這個條件有一定的機率隨機成立,根據條件成立情況,原來在j索引位置上的單向鏈表會構造出兩個新的鏈表
// 這兩個新的鏈表將分別以loHead、hiHead作爲頭結點的標識
// 並且將分別以loTail、hiTail作爲尾結點的標識
if ((e.hash & oldCap) == 0) {
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);
// 如果以loTail爲尾結點標識的新的單向鏈表確實存在(至少一個節點),那麼新的鏈表將替換存儲到j索引位
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 如果以hiTail爲尾結點標識的新的單向鏈表確實存在(至少一個節點),那麼新的鏈表將存儲到j + oldCap索引位
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
從以上代碼的詳細註釋說明中,我們可以看出整個HashMap容器的擴容過程實際上可以分爲兩個大的步驟。第一個步驟是根據HashMap容器當前的情況,確認HashMap容器新的容量大小和新的“下一次擴容的門檻值”。經過不同場景條件的計算後,HashMap容器新的容量一定是大於等於16,且爲2的冪次方的(16、32、64、128…)數值;新的“下一次擴容的門檻值”則根據場景的不同,而有所不同。
- 爲什麼要進行鏈表節點或者紅黑樹節點的調整?
要回答這個問題,首先就應該看一下擴容後如果不重新移動原來集合中的各個結點(包括可能的紅黑樹結點或者鏈表結點)會有什麼樣的操作效果,如下圖所示是一張只進行了數組2倍容量擴容,但是沒有進行結點位置調整的容器內部結構:
基於計算公式發生的變化——具體來說就是 “當前容量 - 1” 的結果不一樣了,所以在進行鍵值對定位時一部分存儲在原索引位上的結點不再能夠匹配正確的索引位置。這些不再能夠匹配正確索引位置的鍵值對結點就需要在擴容時進行移動——移動到正確的索引位上。這樣才能保證在擴容後,操作者通過get(K key)方法獲取鍵值對信息時,HashMap容器能夠正確定位到該鍵值對對象新的索引位置。
- 鏈表中各結點的調整效果
如果擴容前某個索引位置上的K-V鍵值對對象是以單向鏈表結構組織的,那麼就需要通過以下方式將當前鏈表中的各個結點調整爲兩個新的單向鏈表,如下圖所示:
原鏈表中(e.hash & oldCap) == 0的結點將構成新的單向鏈表,這個鏈表將依據原索引位存儲回新的HashMap容器table數組中;另外原鏈表中 (e.hash & oldCap) != 0 的結點將構成另一個新的單向鏈表,並依據“原索引位 + 原數組長度”的計算結果作爲新的索引位存儲回新的HashMap容器table數組中。爲什麼?
這是因爲HashMap容器中table數組的擴容都是以2的冪次方爲單位,也就是說原容量左移1位。在這種情況下K-V鍵值對在擴容後,是否能在原有的索引位被查找到,完全取決於新增的一位在進行與運算時是否爲0。而oldCap代表的數值剛好就是擴容後新增的一位。所以“(e.hash & oldCap) == 0”成立的節點就可以繼續在原索引位上存儲,反之則需要進行移動。
- 紅黑樹中各節點的調整效果(拆分過程)
如果擴容前某個索引位置上的K-V鍵值對是以紅黑樹的結構組織的,那麼就需要按照以上原理,將這顆紅黑樹拆成兩個新的紅黑樹或鏈表——這完全取決於新樹是否達到了節點總數大於6的閾值。一棵紅黑樹或者鏈表留在原索引位置,另一顆紅黑樹或者鏈表放到“原索引位 + 原數組容量”計算結果對應的新索引位置上。首先我們來看相關源代碼:
/**
* Splits nodes in a tree bin into lower and upper tree bins,
* or untreeifies if now too small. Called only from resize;
* see above discussion about split bits and indices.
*
* @param map the map
* @param tab the table for recording bin heads
* @param index the index of the table being split
* @param bit 從代碼上下文可以,這裏傳入的bit就是擴容前HashMap中tables數組的原始大小
*/
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;
// 通過以上代碼我們可知,循環遍歷是從當前紅黑樹的根節點開始的
// 按照紅黑樹中隱含的雙向鏈表依次進行
for (TreeNode<K,V> e = b, next; e != null; e = next) {
// 一定要割斷當前正在處理的結點的鏈表next引用
// 因爲要根據e.hash & bit的計算情況,構造兩個新的鏈表
next = (TreeNode<K,V>)e.next;
e.next = null;
// 如果條件成立,說明擴容後這個K-V鍵值對結點的hash值計算結果還是會落在原來的索引位置
// 這種情況下,就無需移動當前K-V鍵值對結點
if ((e.hash & bit) == 0) {
// 通過以下的代碼片段,就可以將這些無需移動的K-V鍵值對結點組成一個新的鏈表
// 但是個人認爲這裏的代碼有定缺陷,因爲並沒有完整個處理雙向鏈表的所有索引引用
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
// 如果(e.hash & bit) == 0的條件不成立
// 說明擴容後這個K-V鍵值對結點的hash值計算結果不會落在原來的索引位置
// 而一定會落在 當前索引位 + bit的新的索引位上,原因已經在上文解釋過,這裏不再贅述
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
// 執行到這裏,就開始了下一個大的步驟,既是對兩個新的鏈表進行樹化或者取消樹化
// 這裏的代碼也在上文紅黑樹和鏈表的轉換章節進行了介紹,這裏就不再贅述了。
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);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
============
(接下文)