如果懂了HashMap這兩點,面試就沒問題了

HashMap 是後端面試的常客,比如默認初始容量是多少?加載因子是多少?是線程非安全的嗎?put 操作過程複述下?get 操作複述下?在 jdk 1.7 和 1.8 實現上有什麼不同?等等一系列問題,可能這些問題你都能對答如流,說明對 HashMap 還是比較理解的,但最近我們團隊的同學做了一個技術分享,其中有幾點我挺有收穫的,我給大家分享下

我們每週五都會進行技術分享,大家輪流分享,其實這種機制挺好的,大家坐在一起深入討論一個知識點,進行思維的碰撞,多贏

拋出兩個問題,看你能否回答出來?

  1. 如何找到比設置的初始容量值大的最小的 2 的冪次方整數?

  2. HashMap 中對 key 做 hash 處理時,做了什麼特殊操作?爲什麼這麼做?

先自己思考下,再往下閱讀效果更佳哦!

下面的分析都是針對 jdk 1.8

分析

問題1:如何找到比設置的初始容量值大的最小的 2 的冪次方整數?

我們在用  HashMap 的時候,如果用默認構造器,就會建一個初始容量爲 16,加載因子爲 0.75 的 HashMap。這樣做有個缺點,就是在數據量比較大的時候,會進行頻繁的擴容操作,擴容會發生數據的移位,爲了避免擴容,提高性能,我們習慣預估下容量,然後通過帶容量的構造器創建,看下源碼

public HashMap(int initialCapacity, float loadFactor) {
    ...
    // 如果設置的初始容量大於最大容量就默認爲最大容量 2^30     
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    ...
    this.loadFactor = loadFactor;
    // tableSizeFor 方法主要就是計算比給定的初始容量值大的最小的 2 的冪次方整數
    this.threshold = tableSizeFor(initialCapacity);
}

通過源碼我們可知,容量最大值爲 2^30,也就是說 HashMap 的數組部分的長度的範圍爲[0,2^30],然後計算比初始容量大的最小的2的冪次方整數,其中 tableSizeFor 方法是重點,我們看下源碼

// Returns a power of two size for the given target capacity
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

這個方法設計的非常巧妙,因爲 HashMap 要保證容量是 2 的整數次冪,該方法實現的效果就是如果你輸入的 cap   本身就是偶數,那麼就返回 cap 本身,如果輸入的 cap 是奇數,返回的就是比 cap 大的最小的 2 的整數次冪

爲什麼容量要是 2 的整數次冪?

因爲獲取 key 在數組中對應的下標是通過 key 的哈希值與數組長度 -1 進行與運算,如:tab[i = (n - 1) & hash]

  1. n 爲 2 的整數次冪,這樣 n-1 後之前爲 1 的位後面全是 1,這樣就能保證 (n-1) & hash 後相應的位數既可能是 0 又可能是 1,這取決於 hash 的值,這樣能保證散列的均勻,同時與運算效率高

  2. 如果 n 不是 2 的整數次冪,會造成更多的 hash 衝突

該方法首先執行了  cap -1 操作,這樣做的好處是避免輸入的 cap 是偶數,最後計算的數是 cap 的 2 倍的情況,因爲設置的偶數 cap 已經滿足 HashMap 的要求了,沒有必要初始化一個 2 倍容量的 HashMap 了,看不明白不急後面有示例分析

前面我們已經介紹 HashMap 的最大容量爲 2^30,所以容量最大就是 30 bit 的整數,我們就用 30 位的一個數演示下算法中的移位取或操作,假設 n = 001xxx xxxxxxxx xxxxxxxx xxxxxxxx (x 代表該位上是 0 還是 1 我們不關心)

第一次右移 n |=  n >>> 1 ,該操作是用 n 本身 和 n 右移 1 位後的數進行或操作,這樣可以實現把 n 的最高位的 1 緊鄰的右邊一位也置爲 1

n       001xxx xxxxxxxx xxxxxxxx xxxxxxxx
>>> 1 0001xx xxxxxxxx xxxxxxxx xxxxxxxx
| 或操作 0011xx xxxxxxxx xxxxxxxx xxxxxxxx
結果就是把 n 的最高位爲 1 的緊鄰的右邊的 1 位也置爲了 1,這樣高位中有連續兩位都是 1

第二次右移 n |= n >>> 2

n       0011xx xxxxxxxx xxxxxxxx xxxxxxxx
>>> 2 000011 xxxxxxxx xxxxxxxx xxxxxxxx
| 或操作 001111 xxxxxxxx xxxxxxxx xxxxxxxx
結果就是 n 的高位中有連續 4 個 1

第三次右移 n |= n >>> 4

n       001111 xxxxxxxx xxxxxxxx xxxxxxxx
>>> 4 000000 1111xxxx xxxxxxxx xxxxxxxx
| 或操作 001111 1111xxxx xxxxxxxx xxxxxxxx
結果就是 n 的高位中有連續 8 個 1

第四次右移 n |= n >>> 8

n        001111 1111xxxx xxxxxxxx xxxxxxxx
>>> 8  000000 00001111 1111xxxx xxxxxxxx
| 或操作  001111 11111111 1111xxxx xxxxxxxx
結果就是 n 的高位中有連續 16 個 1

第五次右移 n | n >>> 16

n        001111 11111111 1111xxxx xxxxxxxx
>>> 16 000000 00000000 00001111 11111111
| 或操作  001111 11111111 11111111 11111111
結果就是 n 的高位1後面都置爲 1

最後會對 n 和最大容量做比較,如果 >= 2^30,就取最大容量,如果 < 2^30 ,就對 n 進行 +1 操作,因爲後面位數都爲1,所以 +1 就相當於找比這個數大的最小的 2的整數次冪

011111 11111111 11111111 11111111,這個值就是比給的值大的最小的 2 的整數次冪

下面我們用一個具體說演示下,比如 cap = 18

cap爲18

我們輸入的是 18,輸出的是 32,正好是比 18 大的最小的 2 整數次冪

如果 cap 本身就爲 2的整數次冪,輸出結果爲什麼?

cap爲16

通過演示可見,cap 本身就是 2 的整數次冪的輸出結果爲其本身

上面還遺留了個問題,就是先對 cap -1,我解釋說爲了避免輸出的是偶數,最後計算的結果爲 2*cap,浪費空間,看下面的演示

cap爲16未減一

通過演示,我們可以看出,輸入的是 16,最後計算的結果卻是 32,這就會浪費空間了,所以說算法很牛,先對 cap 做了減一操作

問題2:HashMap 中對 key 做 hash 處理時,做了什麼特殊操作?爲什麼這麼做?

首先我們知道 HashMap 在做 put 操作的時候,會先對 key 做 hash 操作,直接定位到源碼位置

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可以看到再對 key 做 hash 操作時,執行了 (h = key.hashCode()) ^ (h >>> 16)

原 hashCode 值: 10110101 01001100 10010101 11011111
右移 16 位後的值: 00000000 00000000 10110101 01001100 
異或後的值:      10110101 01001100 00100000 10010011

這個操作是把 key 的 hashCode 值與 hashCode 值右移 16 位做異或(不同爲 1,相同爲 0),這樣就是把哈希值的高位和低位一起混合計算,這樣就能使生成的 hash 值更離散

這裏需要我解釋下,通過前面的介紹,我們知道數組的容量範圍是 [0,2^30],這個數還是比較大的,平時使用的數組容量還是比較小的,比如默認的大小 16,假設三個不同的 key 生成的 hashCoe 值如下所示:

19305951     00000001 00100110 10010101 11011111

128357855   00000111 10100110 10010101 11011111

38367            00000000 00000000 10010101 11011111

他們三個有個共同點是低 16 位完全一樣,但高 16 位不同,當計算他們在數組中所在的下標時,通過 (n-1)&hash,這裏 n 是 16,n-1=15,15 的二進制表示爲

00000000 00000000 00000000 00001111

用 19305951、128357855、38367 都與 15 進行 & 運算,結果如下

hash衝突

通過計算後發現他們的結果一樣,也就是說他們會被放到同一個下標下的鏈表或紅黑樹中,顯然不符合我們的預期

所以對 hash 與其右移 16 位後的值進行異或操作,然後與 15 做與運算,看 hash 衝突情況

解決hash衝突

可見經過右移 16位後再進行異或操作,然後計算其對應的數組下標後,就被分到了不同的桶中,解決了哈希碰撞問題,思想就是把高位和低位混合進行計算,提高分散性

總結

其實 HashMap 還有很多值得研究的點,上面兩個點搞明白後,會感嘆作者寫代碼的能力真是牛,我們在工作中要借鑑這些思想,希望通過我的講解,你能掌握這兩個知識點,如果有不懂的可以留言或私聊我


推薦閱讀

公衆號@陳樹義,用最簡單的語言,分享我的技術見解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章