看完這篇還不懂HashMap的hash原理,那我要哭了~
Warning:這是《Java 程序員進階之路》專欄的第 55 篇,我們來分析一下 HashMap 的 hash 方法的原理。
本文 GitHub 上已同步,有 GitHub 賬號的小夥伴,記得看完後給二哥安排一波 star 呀!衝一波 GitHub 的 trending 榜單,求求各位了。
GitHub 地址:https://github.com/itwanger/toBeBetterJavaer
在線閱讀地址:https://itwanger.gitee.io/tobebetterjavaer
來看一下 hash 方法的源碼(JDK 8 中的 HashMap):
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這段代碼究竟是用來幹嘛的呢?
我們都知道,key.hashCode()
是用來獲取鍵位的哈希值的,理論上,哈希值是一個 int 類型,範圍從-2147483648到2147483648。前後加起來大概40億的映射空間,只要哈希值映射得比較均勻鬆散,一般是不會出現哈希碰撞的。
但問題是一個40億長度的數組,內存是放不下的。HashMap擴容之前的數組初始大小隻有16,所以這個哈希值是不能直接拿來用的,用之前要對對數組的長度做取模運算,用得到的餘數來訪問數組下標。
取模運算有兩處。
取模運算(“Modulo Operation”)和取餘運算(“Remainder Operation ”)兩個概念有重疊的部分但又不完全一致。主要的區別在於對負整數進行除法運算時操作不同。取模主要是用於計算機術語中,取餘則更多是數學概念。
一處是往 HashMap 中 put 的時候(putVal
方法中):
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
}
一處是從 HashMap 中 get 的時候(getNode
方法中):
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {}
}
其中的 (n - 1) & hash
正是取模運算,就是把哈希值和(數組長度-1)做了一個“與”運算。
可能大家在疑惑:取模運算難道不該用 %
嗎?爲什麼要用 &
呢?
這是因爲 &
運算比 %
更加高效,並且當 b 爲 2 的 n 次方時,存在下面這樣一個公式。
a % b = a & (b-1)
用 2n 替換下 b 就是:
a % 2n = a & (2n-1)
我們來驗證一下,假如 a = 14,b = 8,也就是 23,n=3。
14%8,14 的二進制爲1110,8的二進制1000,8-1 = 7的二進制爲0111,1110&0111=0110,也就是 0*
20+1*
21+1*
22+0*
23=0+2+4+0=6,14%8 剛好也等於 6。
這也正好解釋了爲什麼HashMap的數組長度要取2的整次方。
因爲(數組長度-1)正好相當於一個“低位掩碼”——這個掩碼的低位最好全是 1,這樣 & 操作纔有意義,否則結果就肯定是 0,那麼 & 操作就沒有意義了。
a&b 操作的結果是:a、b中對應位同時爲1,則對應結果位爲1,否則爲 0
2的整次冪剛好是偶數,偶數-1 是奇數,奇數的二進制最後一位是 1,保證了 hash &(length-1) 的最後一位可能爲 0,也可能爲 1(這取決於 h 的值),即 & 運算後的結果可能爲偶數,也可能爲奇數,這樣便可以保證哈希值的均勻性。
& 操作的結果就是將哈希值的高位全部歸零,只保留低位值,用來做數組下標訪問。
假設某哈希值爲 10100101 11000100 00100101
,用它來做取模運算,我們來看一下結果。HashMap 的初始長度爲 16(內部是數組),16-1=15,二進制是 00000000 00000000 00001111
(高位用 0 來補齊):
10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101
因爲 15 的高位全部是 0,所以 & 運算後的高位結果肯定是 0,只剩下 4 個低位 0101
,也就是十進制的 5,也就是將哈希值爲 10100101 11000100 00100101
的鍵放在數組的第 5 位。
明白了取模運算後,我們再來看 put 方法的源碼:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
以及 get 方法的源碼:
public V get(Object key) {
HashMap.Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
它們在調用 putVal 和 getNode 之前,都會先調用 hash 方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
那爲什麼取模運算之前要調用 hash 方法呢?
看下面這個圖。
某哈希值爲 11111111 11111111 11110000 1110 1010
,將它右移 16 位(h >>> 16),剛好是 00000000 00000000 11111111 11111111
,再進行異或操作(h ^ (h >>> 16)),結果是 11111111 11111111 00001111 00010101
異或(
^
)運算是基於二進制的位運算,採用符號XOR或者^
來表示,運算規則是:如果是同值取0、異值取1
由於混合了原來哈希值的高位和低位,所以低位的隨機性加大了(摻雜了部分高位的特徵,高位的信息也得到了保留)。
結果再與數組長度-1(00000000 00000000 00000000 00001111
)做取模運算,得到的下標就是 00000000 00000000 00000000 00000101
,也就是 5。
還記得之前我們假設的某哈希值 10100101 11000100 00100101
嗎?在沒有調用 hash 方法之前,與 15 做取模運算後的結果也是 5,我們不妨來看看調用 hash 之後的取模運算結果是多少。
某哈希值 00000000 10100101 11000100 00100101
(補齊 32 位),將它右移 16 位(h >>> 16),剛好是 00000000 00000000 00000000 10100101
,再進行異或操作(h ^ (h >>> 16)),結果是 00000000 10100101 00111011 10000000
結果再與數組長度-1(00000000 00000000 00000000 00001111
)做取模運算,得到的下標就是 00000000 00000000 00000000 00000000
,也就是 0。
綜上所述,hash 方法是用來做哈希值優化的,把哈希值右移16位,也就正好是自己長度的一半,之後與原哈希值做異或運算,這樣就混合了原哈希值中的高位和低位,增大了隨機性。
說白了,hash 方法就是爲了增加隨機性,讓數據元素更加均衡的分佈,減少碰撞。