今天在和同時討論HashMap
的時候,提到了擴容和衝哈希的事情,然後我發現大家都是一種半懂不懂的狀態。於是回去做了一番功課,寫下這篇文章。
HashMap
的擴容,又被很多人叫rehash、重哈希,我本人是很反對這個叫法的,事實上HashMap擴容的時候,Node中存儲的Key的hash值並沒有發生變化,只是Node的位置發生了變化。
首先說爲什麼需要擴容?這個問題好像有點簡單,不就是因爲容量滿了就需要庫容了嗎?這種思想對於鏈表來說是沒有問題的,但是對於HashMap
來說,並不是因爲這個原因,而是HashMap
認爲性能不夠好時。
原因我簡單說下,當然關於哈希表我就不再過多說了,還不懂的同學趕緊百度。
爲什麼擴容?
我們知道理論上哈希表的讀時間複雜度是O(1),但是沒有一種哈希方法能保證絕對的哈希均勻,爲了解決哈希衝突又往往採用鏈地址法解決,那這樣時間複雜度愈發偏離O(1)了,此時進行擴容,其實是讓哈希表分散的更均勻,解決性能不夠好的問題。
關於JDK1.8中的HashMap
結構,在這裏我就不多說了,哈希表+鏈表,長度達到一定時鏈表轉換成紅黑樹(這時候是不是得有面試官出來問,長度多長才轉紅黑樹啊?),爲了讓小夥伴們看的更直觀,我這裏偷一張圖上來:
什麼時候擴容?
那麼什麼時候需要擴容?答案也很簡單:1.初始化後放入元素時 2.達到閾值時
-
創建對象以後,
HashMap
並不是立即初始化table,而是在第一次放入元素時,纔會初始化table,這很HashMap
節省內存得一種機制,而table的初始化其實是resize
方法實現的。 -
達到閾值時,這個就比較有意思,所謂閾值,就是
HashMap
中threshold
這個屬性,閾值的計算方式很簡單,基本上就是capacity(table容量) * loadFactor(負載因子)
,這裏我覺得capacity
應該稱爲理論容量,是因爲正常情況下達到閾值就擴容了,達到閾值時HashMap
認爲哈希衝突的次數會不能接受,因此需要擴容。
因爲這裏我是以JDK1.8源碼作爲樣本分析的,如果我沒記錯的話,JDK1.7中還存在rehash方法,但是JDK1.8中已經改名叫resize
方法了,那我們就不管JDK1.7中是如何實現擴容,直接上JDK1.8源碼。
如何擴容?
先來看擴容函數的前半部分:
final Node<K,V>[] resize() {
// 擴容前原本的table
Node<K,V>[] oldTab = table;
// 這裏進行判斷,區分尚未初始化的情況
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // 非初始化情況
/*
* 當原本的capacity已經超過最大值以後
* HashMap選擇不再擴容,然後threshold置爲最大值
*/
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
/*
* 這種是常見的的擴容情況,table容量會擴大兩倍
* 同時HashMap的閾值也跟着擴大兩倍
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 2倍閾值
}
else if (oldThr > 0) // 指定大小n的初始化情況下,table容量取>n的最小2倍數
newCap = oldThr;
else { // 不指定初始化大小,table容量取默認值16
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 指定大小初始化情況下,閾值 = table容量 * 負載因子(默認0.75)
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 最後確定閾值
threshold = newThr;
待續......
}
resize()
方法的前半部分主要是對於新閾值和新容量的確定,這裏有三種情況:
- 初始化已完成的正常擴容邏輯:table容量和閾值都擴大2倍
- 指定大小的初始化邏輯:算出大於等於指定初始化容量的最小2的倍數,作爲table容量
- 未指定大小的初始化邏輯:默認table容量16,閾值爲16 * 0.75 = 12
關於第二種邏輯,簡單來說就是,指定初始化值爲3,那麼table容量就是4,如果指定初始化大小是10,那麼table容量就是16,如果是2的次冪,就直接作爲table容量。
再看resize
方法的後半段:
final Node<K,V>[] resize() {
......接上文
@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) {
// 遍歷原本的table
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 爲了GC
if (e.next == null)
// 如果table上沒有鏈表的情況下,直接轉移到對應位置
// 轉移到的位置就是get方法中取的下標位置,tab[(n - 1) & hash]
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是紅黑樹,就進入紅黑樹的拆分邏輯,這裏不展開來說
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 原本槽內的一個鏈表,會被拆分成兩個兩個鏈表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 如果entry的哈希值高位爲0,會被拆分到lo鏈表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 如果entry的哈希值高位爲1,會被拆分到hi鏈表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
// 高位是0,因此lo鏈表不需要移位,
// hash & (newCap-1)和hash & (oldCap-1)獲得下標位置一樣
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 高位是1,hi鏈表移位到 j+oldCap位置,
// j + oldCap相當於高位補1,直接移到這個位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
這裏爲了能說的更通俗易懂一些,我舉個簡單的例子:
首先假設原本有幾個key,他們的的hash值爲:
key | hash值 | 下標 hash & (length-1) |
---|---|---|
key1 | 00000101 | 00000101 = 5 |
key2 | 00010101 | 00000101 = 5 |
key3 | 00100101 | 00000101 = 5 |
key4 | 00110101 | 00000101 = 5 |
而假設原本table容量 oldCap = 16;
他們在table中存儲的下標位置都是:
hash & (table.length-1) = 0101 & 0111
那麼這幾個key都會存儲在table[5]
位置,如下圖 :
當進行擴容時,容量擴大一倍也就是newCap = 32,此時table.length -1 = 31
hash & (table.length - 1) 就會出現兩種情況:
- key1 和 key3 的下標還是5 (0000 0101)
- key2 和 key4 的下標變爲了 21 (0001 0101)
key | hash值 | 下標 hash & (length-1) |
---|---|---|
key1 | 00000101 | 00000101 = 5 |
key2 | 00010101 | 00001101 = 21 |
key3 | 00100101 | 00000101 = 5 |
key4 | 00110101 | 00001101 = 21 |
到這應該能明白,resize
方法裏的lo列表和hi列表
是什麼意思了,其實就是看key高一位的哈希值是1還是0,來決定是放到哪個隊列裏。 移位後的HashMap如下圖:
這裏HashMap
非常精妙的實現了擴容,沒有重新計算對象的哈希值,甚至連下標的重新計算也只需要進行一位相與的計算(hash高位 & newCap-1 )。