一、前言
最近在閱讀HashMap
的源碼,已經將代碼基本過了一遍,對它的實現已經有了一個較為全面的認識。今天就來分享一下HashMap
中比較重要的一個方法——resize
方法。我將對resize
方法的源代碼進行逐句的分析。
若想要看懂這個方法的源代碼,首先得對HashMap
的底層結構和實現有一個清晰的認識,若不清楚的,可以看看我之前寫的一篇博客,這篇博客對HashMap
的底層結構和實現進行了一個比較清晰和全面的講解,同時博客的最底下附上了兩篇阿里架構師對HashMap
的分析,寫的非常好,很有參考價值:
- Hexo鏈接 —— HashMap源碼解讀——深入理解HashMap高效的原因
- 博客園鏈接 —— https://www.cnblogs.com/tuyang1129/p/12362959.html
二、解析
2.1 resize方法的作用
沒有閱讀過HashMap
源碼的人可能並不知道它有一個叫resize
的方法,因為這不是一個public
方法,這個方法並沒有加上訪問修飾符,也就是說,這個方法HashMap
所在的包下使用。很多人應該都知道,HashMap
的基本實現是數組+鏈表(從JDK1.8
開始已經變成了數組+鏈表+紅黑樹),而這個方法的作用也很簡單:
- 當數組並未初始化時,對數組進行初始化;
- 若數組已經初始化,則對數組進行擴容,也就是創建一個兩倍大小的新數組,並將原來的元素放入新數組中;
2.2 resize方法中用到的變量
HashMap
中定義了很多的成員變量,而很多都在resize
方法中有用到,所以為了看懂這個方法,首先需要了解這些變量的含義:
- table:用來存儲數據的數組,即數組+鏈表結構的數組部分;
- threshold:閾值,表示當前允許存入的元素數量,當元素數量超過這個值時,將進行擴容;
- MAXIMUM_CAPACITY:
HashMap
允許的最大容量,值為1<<30
,也就是2^30
; - DEFAULT_INITIAL_CAPACITY:
HashMap
的默認初始容量,值為16
; - loadFactor:負載因子,表示
HashMap
中的元素數量可以到達總容量的百分之多少,默認是75%
,也就是說,默認情況下,當元素數量達到總容量的75%
時,將進行擴容; - DEFAULT_LOAD_FACTOR:負載因子的默認值,也就是
0.75
;
2.3 resize方法源碼解讀
下面就來看看resize方法的源碼吧,我用注釋的方式,對每一句代碼進行了解讀:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final HashMap.Node<K,V>[] resize() {
HashMap.Node<K,V>[] oldTab = table;
// 記錄Map當前的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 記錄Map允許存儲的元素數量,即閾值(容量*負載因子)
int oldThr = threshold;
// 聲明兩個變量,用來記錄新的容量和閾值
int newCap, newThr = 0;
// 若當前容量不為0,表示存儲數據的數組已經被初始化過
if (oldCap > 0) {
// 判斷當前容量是否超過了允許的最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
// 若超過最大容量,表示無法再進行擴容
// 則更新當前的閾值為int的最大值,並返回舊數組
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 將舊容量*2得到新容量,若新容量未超過最大值,並且舊容量大於默認初始容量(16),
// 才則將舊閾值*2得到新閾值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 若不滿足上面的oldCap > 0,表示數組還未初始化,
// 若當前閾值不為0,就將數組的新容量記錄為當前的閾值;
// 為什么這里的oldThr在未初始化數組的時候就有值呢?
// 這是因為HashMap有兩個帶參構造器,可以指定初始容量,
// 若你調用了這兩個可以指定初始容量的構造器,
// 這兩個構造器就會將閾值記錄為第一個大於等於你指定容量,且滿足2^n的數(可以看看這兩個構造器)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 若上面的條件都不滿足,表示你是調用默認構造器創建的HashMap,且還沒有初始化table數組
else { // zero initial threshold signifies using defaults
// 則將新容量更新為默認初始容量(10)
// 閾值即為(容量*負載因子)
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 經過上面的步驟后,newCap一定有值,但是若運行的是上面的第二個分支時,newThr還是0
// 所以若當前newThr還是0,則計算出它的值(容量*負載因子)
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 將計算出的新閾值更新到成員變量threshold上
threshold = newThr;
// 創建一個記錄新數組用來存HashMap中的元素
// 若數組不是第一次初始化,則這里就是創建了一個兩倍大小的新數組
@SuppressWarnings({"rawtypes","unchecked"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
// 將新數組的引用賦值給成員變量table
table = newTab;
// 開始將原來的數據加入到新數組中
if (oldTab != null) {
// 遍歷原數組
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
// 若原數組的j位置有節點存在,才進一步操作
if ((e = oldTab[j]) != null) {
// 清除舊數組對節點的引用
oldTab[j] = null;
// 若table數組的j位置只有一個節點,則直接將這個節點放入新數組
// 使用 & 替代 % 計算出余數,即下標
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 若第一個節點是一個數節點,表示原數組這個位置的鏈表已經被轉為了紅黑樹
// 則調用紅黑樹的方法將節點加入到新數組中
else if (e instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 上面兩種情況都不滿足,表示這個位置是一條不止一個節點的鏈表
// 以下操作相對復雜,所以單獨拿出來講解
else { // preserve order
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 將新創建的數組返回
return newTab;
}
上面的代碼中,最后一部分比較難理解,所以我將在下面單獨拿出來講解。
2.4 resize方法中的鏈表拆分
resize
方法中的最后一部分,是將原數組中的一條鏈表的節點,放入到擴容后的新數組中,而這一部分相對來說比較難理解。首先我們得知道是怎么實現的,然后再來逐句分析代碼。
首先,我們得知道一個結論,那就是:原數組中一條鏈表上的所有節點,若將它們加入到擴容后的新數組中,它們最多將會分布在新數組中的兩條鏈表上。
在HashMap
中,使用按位與運算替代了取模運算來計算下標,因為num % 2^n == num & (2^n - 1)
,而HashMap
的容量一定是2^n
,所以可以使用這條定理(這里我假設大家已經了解了HashMap
的容量機制,若不了解的,可以先看看我最上面給出的那篇博客)。我們看下面這張圖,左邊是擴容前的數組+鏈表,右邊是擴容后的數組+鏈表,鏈表矩形中的數字表示節點的hash
值。左邊數組的容量為2^3==8
,只包含一條四個節點的鏈表,右邊數組的容量為2^4 == 16
,左邊鏈表上的節點重新存儲后,變成了右邊兩條鏈表。正對應了我們上面說的結論。
那這個結論是怎么來的呢?我們先說左邊第一個節點,它的hash
值是2
,轉換成二進制就是0010
,而容量為2^3 == 8
,通過num % 2^n == num & (2^n - 1)
這個公式,我們知道2
與容量8
的余數是2 & (8 - 1) == 0010 & 0111 == 0010
。任何數與0111做與運算(&),實際上就是取這個數二進制的最后三位。而擴容之后,容量變成了2^4 == 16
,這時候,取模就是與16-1 == 15
做與運算了,而15
的二進制是1111
,我們發現,1111
與之前的0111
唯一的區別就是第四位也變成了1(以下說的第幾位都是從右往左)。而2 & 15 == 0010 & 1111 == 0010
,和0010 & 0111
結果是一樣的。為什么?因為0010
的第四位是0
,所以從0111
變成1111
,並不會對計算結果造成影響,因為0
和任何數做與運算,結果都是0
。所以擴容后,2
這個節點,還是放在數字下標為2
的位置。我們在來看看剩下的三個數:
hash值為10,轉換成二進制1010,1010的第四位為1,所以 1010 & 0111 != 1010 & 1111
hash值為18,轉換成二進制10010,10010的第四位為0,所以 10010 & 0111 == 10010 & 1111
hash值為26,轉換成二進制11010,11010的第四位為1,所以 11010 & 0111 != 11010 & 1111
所以擴容后,余數是否發生改變,實際上只取決於多出來的那一位而已,那一位只有兩種結果:0
或者1
,所以這些節點的新下標最終也只有兩種結果。而多出來的那一位是哪一位呢?8
轉換成二進制是1000
,而從8
擴容到16
,取余的數從0111
變成了1111
,多出的這個1
剛好在第四位,也就是1000
中,唯一一個1
所在的位置;16
的二進制是10000
,擴容成32
后,取余的數從1111
變成11111
,在第五位多出了一個1
,正好是10000
的1所在的位置。所以我們可以知道,擴容后,節點的下標是否需要發生改變,取決於舊容量的二進制中,1
那一位。所以容量為8
,擴容后,若節點的二進制hash
值的第四位為0
,則節點在新數組中的下標不變;若為1
,節點的下標改變,而且改變的大小正好是+8
,因為多出了最高位的1
,例如1010 & 0111 = 0010
,而1010 & 1111 = 1010
,結果相差1000
,也就是舊容量的大小8
;所以若下標要發生改變,改變的大小將正好是舊數組的容量。
我們如何判斷hash
值多出來的那一位是0
還是1
呢,很簡單,只要用hash
值與舊容量做與運算,結果不為0
表示多出的這一位是1
,否則就是0。比如說,容量為8
(二進制1000
),擴容后多出來的是第四位,於是讓hash
值與1000
做與運算,若hash
值的第四位是1
,與1000
做與運算后結果就是1000
,若第四位是0
,與1000
做與運算后就是0
。好,下面我們來看看代碼吧:
// 創建兩個頭尾節點,表示兩條鏈表
// 因為舊鏈表上的元素放入新數組中,最多將變成兩條鏈表
// 一條下標不變的鏈表,一條下標+oldCap
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
// 循環遍歷原鏈表上的每一個節點
do {
// 記錄當前節點的下一個節點
next = e.next;
// 注意:e.hash & oldCap這一步就是前面說的判斷多出的這一位是否為1
// 若與原容量做與運算,結果為0,表示將這個節點放入到新數組中,下標不變
if ((e.hash & oldCap) == 0) {
// 若這是不變鏈表的第一個節點,用loHead記錄
if (loTail == null)
loHead = e;
// 否則,將它加入下標不變鏈表的尾部
else
loTail.next = e;
// 更新尾部指針指向新加入的節點
loTail = e;
}
// 若與原容量做與運算,結果為1,表示將這個節點放入到新數組中,下標將改變
else {
// 若這是改變下標鏈表的第一個節點,用hiHead記錄
if (hiTail == null)
hiHead = e;
// 否則,將它加入改變下標鏈表的尾部
else
hiTail.next = e;
// 更新尾部指針指向新加入的節點
hiTail = e;
}
} while ((e = next) != null);
// 所有節點遍歷完后,判斷下標不變的鏈表是否有節點在其中
if (loTail != null) {
// 將這條鏈表的最后一個節點的next指向null
loTail.next = null;
// 同時將其放入新數組的相同位置
newTab[j] = loHead;
}
// 另一條鏈表與上同理
if (hiTail != null) {
hiTail.next = null;
// 這條鏈表放入的位置要在原來的基礎上加上oldCap
newTab[j + oldCap] = hiHead;
}
三、總結
resize的邏輯並不算太難,可能只有鏈表拆分這一部分比較難理解。為了能盡可能地說清楚,我描述的可能有點啰嗦了,希望對看到的人能夠有所幫助吧。
四、參考
https://blog.csdn.net/weixin_41565013/article/details/93190786