HashMap 查漏補缺

尹述迪發表於2019-03-03

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。

根據這個原理,我們只需要將原來的連結串列分成兩條新鏈放到對應的位置即可,下面是具體步驟:

  1. 遍歷舊陣列,如果元素的 next 為空,直接取餘後放到新陣列;
  2. 如果元素後面連了一個連結串列,那麼新建兩條連結串列,暫且成為 hi 鏈和 lo 鏈;
  3. 遍歷連結串列,計算每個元素的 hash 值和舊容量與操作的結果,結果為0則將其放入 lo 鏈末端,不為0放入 hi 鏈末端;
  4. 將兩條鏈的 head 放到新陣列中,其中 loHead 放到原來的位置,hiHead 放到原來的下標加上舊容量處;
  5. 如果是紅黑樹,進行和上面類似的操作,只是將兩條連結串列換成兩棵樹。

理解原理後看程式碼就很簡單了:

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;
            }
        }
    }
}
複製程式碼

相關文章