HashMap 是面試的釘子戶了,網上分析的文章也有很多,相信大家對於原理已經爛俗於心了。但最近在看原始碼時,發現其中一些實現細節其實不太好理解,所以決定以問答的形式在這裡記錄一下,寫的時候儘量把原因說明白。
容量和 size 分別指什麼?
容量並不是指 HashMap 所能儲存的鍵值對數量,而是其內部的 table 陣列的大小,而 size 是指目前已儲存的鍵值對的數量。table 是一個 Entry 陣列。 table 的每一個節點都連著一個連結串列或者紅黑樹。
初始容量可以隨意設定嗎?
可以,但是 HashMap 內部會你設定的 initialCapacity 轉換為大於等於它的最小的2的n次方。比如 20 轉為 32,32 轉為 32等。如果不設定,則為預設值16。需要注意的是,在 Java 8的原始碼中,並沒有在構造方法直接新建陣列。而是先將處理後的容量值賦給 threshold,在第一次儲存鍵值對時再根據這個值建立陣列。
為什麼內部要將容量轉換為 2 的n次方?
這樣可以提高取餘的效率。為了防止連結串列過長,要保證鍵值對在陣列中儘可能均勻分佈,所以在計算出 key 的 hash 值之後,需要對陣列的容量進行取餘,餘數即為鍵值對在 table 中的 index。
對於計算機而言,二進位制位運算的效率高於取餘(%)操作。而如果容量是 2 的 n 次方的話,hash 值對其取餘就等同於 hash 值和容量值減1進行按位與(&)操作:
// capacity 為 2 的 n 次方的話,下面兩個操作結果相同
hash & (capacity -1)
等同於
hash % capacity
複製程式碼
那為什麼兩種操作等同呢?
我們以2進位制的思維想一下,如果一個數是 2 的 n 次方,那它的二進位制就是從右往左 n 位都為0,n+1 位為1。比如2的3次方就是 1000。這個數的倍數也滿足從右往左 n 位都為0,取餘的時候拋棄倍數,就等同於將 n+1 位及其往左的所有位歸0,剩下的 n 位就代表餘數。換句話說,一個數對2的 n 次方取餘,就是要取這個數二進位制的最低 n 位。
2 的 n 次方減1的結果就是 n 位1,進行與操作後就得到了最低 n 位。
如何將一個值轉換為大於等於它的最小的2的 n 次方?
我們先假設一個二進位制數 cap,cap 的二進位制有 a 位(不算前面高位的0),那麼,大於它的最小的2的次方就是2的 a 次方。2 的 a 次方減一的結果就是 n 位1,那我們只要將 cap 的全部 2 進位制位變為1,再加1就能得到結果。而為了防止 cap 本身就是2的 n 次方,我們在計算之前先將 cap 自減。
如何將二進位制位都變成1呢?下面是原始碼:
static final int tableSizeFor(int cap) {
int n = cap - 1; //這一步是為了避免 cap 剛好為2的 n 次方
n |= n >>> 1; //保證前2位是1
n |= n >>> 2; //保證前4位是1
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼
下面的描述中 n 的位數都不包括前面補位的0。
|= 這個符號不常見,a |= b 就是 a = a|b 的意思。程式碼中先將 n 無符號右移1位,由於n 的第1位肯定是1,移位後第2位是1,|
操作後前2位就保證是1了。第二步右移了2位再進行|
操作,保證了前4位是1,後面的計算類似,由於 n 最多有32位,所以一直操作到右移16為止。這樣就將 n 的所有2進位制位都變成了1,最後自增返回。
比如一個數 10010,完整過程如下:
//右移一位
00010010 -> 00001001
//進行或操作
00010010 |= 00001001 -> 00011011 //保證前面2位是1
//右移兩位
00011011 -> 00000110
//進行或操作
00011011 |= 00000110 -> 00011111 //保證了前面4位是1
//依此類推
...
複製程式碼
hash 值是如何計算的
hash 值並沒有直接返回 hashcode 的返回值,而是進行了一些處理。
前面提到過,hash 值算出來後需要進行取餘操作,影響取餘結果的是 hash 值的低 n 位。如果低 n 位是固定的或者集中在幾個值,那麼取餘的結果容易相同,導致 hash 碰撞的發生。由於 hashcode 函式可以被重寫,重寫者可能無意識地就寫了一個不合理的 hash 函式,導致上面這種情況發生。
為了避免這種情況,HashMap 將 hash 值先向右移位,再進行或操作,這樣就使高位的值和低位的值融合成一個新的值,保證取餘結果受每一個二進位制位的影響。Java 7和 Java 8的原理都一樣,但原始碼有細微出入,可能是因為 Java 經過統計發現移位一次就夠了吧。
//Java 7
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//Java 8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
擴容後元素如何進行移動
為了防止元素增多後,連結串列越來越長,HashMap 會在元素個數達到閾值後進行擴容,新的容量為舊容量的2倍。
容量變化後,每個元素用 hash 值取餘的結果也會隨之變化,需要在陣列中重新排列。以前同一條連結串列上的元素,擴容後可能存在不同的連結串列上。
在 Java 7 中,重新排列實現得簡單粗暴,直接用 hash 根據新容量算出下標,然後設定到新陣列中,即相當於將元素重新 put 了一次。但在 Java 8中,作者發現沒必要重新插入,因為重新計算後,新的下標只可能有兩種情況,要麼是原來的值,要麼是原來的值加舊容量。比如容量為16的陣列擴容到32,下標為1的元素重新計算後,下標只可能為1或17。
這個怎麼理解呢?重提一下之前的一句話,一個數對2的 n 次方取餘,就是要取這個數二進位制的最低 n 位。當容量為16時,取餘是取最後4位的值,而擴容到32後,取餘變成取最後5位的值。這裡增加的1位如果為0,那麼餘數就沒變,如果為1,那麼餘數就增加了16。如何取增加的這一位的值呢?直接和16進行與操作即可。16的二進位制是10000,與操作後如果結果為0,即表示高位為0,否則為1。
根據這個原理,我們只需要將原來的連結串列分成兩條新鏈放到對應的位置即可,下面是具體步驟:
- 遍歷舊陣列,如果元素的 next 為空,直接取餘後放到新陣列;
- 如果元素後面連了一個連結串列,那麼新建兩條連結串列,暫且成為 hi 鏈和 lo 鏈;
- 遍歷連結串列,計算每個元素的 hash 值和舊容量與操作的結果,結果為0則將其放入 lo 鏈末端,不為0放入 hi 鏈末端;
- 將兩條鏈的 head 放到新陣列中,其中 loHead 放到原來的位置,hiHead 放到原來的下標加上舊容量處;
- 如果是紅黑樹,進行和上面類似的操作,只是將兩條連結串列換成兩棵樹。
理解原理後看程式碼就很簡單了:
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) { //結果為0,表示下標沒變,放入 lo 鏈
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //結果為0,表示下標要加上舊容量,放入 hi 鏈
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //lo 鏈放在原來的下標處
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //hi 鏈放在原來的下標 加舊容量處
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
複製程式碼