HashMap解讀
之前我以爲我很懂HashMap,直到今天技術交流羣有個人說:爲什麼HashMap的hash函數要按照如下方式生成?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我記得之前是看過這段代碼?但是沒有深思,看到過了,知道了HashMap的hash函數是這麼設計的,沒深究過。就以這個問題開始HashMap的完整思考。
簡介
HashMap是平常使用的非常多的,內部結構是 數組+鏈表/紅黑樹 構成,很多時候都是多種數據結構組合。
我們先看一下HashMap的基本操作:
new HashMap(n);
第一個知識點,傳入n,構造的HashMap容量就是n嗎?
答案是:不一定。
因爲HashMap內部做了些處理:
public HashMap(int initialCapacity, float loadFactor) {
this.loadFactor = loadFactor; //負載因子 默認0.75
//設置容量
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor 這段代碼其實就做了一件事,例如,你初始化給了10,它會給你16,大於10的是2的k次冪。
以初始值50爲例,講一下實現原理:
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;
}
算法就是讓二進制不斷右移,與自己異或,把第一位爲1(最高位)後面全變爲1,111111 + 1 = 1000000 = (符合大於50並且是2的整數次冪 )
第二個知識點,回答開題的問題,爲什麼hash函數這麼設計?
- HashMap的hash函數是根據Key值計算的;
- 一定要儘可能降低hash碰撞,越分散越好;
- 算法一定要儘可能高效,因爲這是高頻操作;
再來看一下這段代碼:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這段代碼有個名字,叫擾動函數,大家想一下,如果hash函數直接使用key.hashCode()
作爲hash 值怎麼樣?
key.hashCode()
獲得的是key的hashcode(), 如果HashMap數組長度爲16,求對象在數組存儲位置 (n - 1) & hash
就相當於 0000 1111 & hash ,讓 hash 高位全部置爲0,只用到了 hash 的低位,因爲只用了低位,碰撞的機率就會比較大。
聰明的算法設計者兼顧性能和降低碰撞,就考慮用高16位和低16位結合起來異或形成hash 值。如下圖所示,
圖片來自 https://www.zhihu.com/question/20733617/answer/111577937
第三個知識點,相比1.7,JDK1.8做了哪些優化?
- 1.7 使用頭插法,1.8使用尾插法;
- 1.7 hash函數使用4次位運算+5次異或,1.8使用1次位運算+1次異或;
- 1.7 使用數組+鏈表的結構,1.8 使用數組 + 鏈表 +紅黑樹;
- 1.7 擴容需要對原始元素重新hash & (len -1), 1.8 計算元素新位置 = 原始位置 / 原始位置 + 舊容量;
下面開始解釋👆說的四條:
第一條:
1.8 之前都是使用頭插法,因爲作者認爲現在插入的數據是熱乎的,最有可能被立即使用到,所以用頭插法;
而爲什麼1.8用尾插法呢,如果是頭插法,在多線程環境下,會出現這樣一個問題:A線程在插入節點B,B線程也在插入,遇到容量不夠開始擴容,重新hash,放置元素,採用頭插法,後遍歷到的B節點放入了頭部,這樣形成了環,如下圖所示:
第二條:
1.7的hash 函數如下,可以和上面的對比看:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
四次無符號右移 五次異或
第三條:
畫了一張插入流程圖如下:
注意4個點:
- 先插入新節點再擴容(1.7是先判斷容量,不夠先擴容再插入);
- 先判斷是否爲紅黑樹,鏈表插入結束判斷是否是否應該轉爲紅黑樹;
- 紅黑樹轉爲鏈表的臨界值是6不是8,原因是如果長度經常在8附近,轉來轉去,浪費資源。
- 爲什麼紅黑樹的閾值是8,因爲合理的hash函數,發生碰撞鏈表長度爲8的概率作者計算爲千萬分之後。
// 作者給的hash衝突鏈表長度分別爲以下值得概率
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
第四條:
1.7 擴容後會採用 hash & (len -1)重新計算所有數組元素的位置,但是1.8採用簡單快捷的方式定位新位置: 直接放在原位置/ 原位置 + 舊容量
這個怎麼理解呢?看下面這張圖,
分二種情況:
- 比如現在 數組長度爲16,元素的hash值爲0101 ,
0000 0101 & 0000 1111 = 0000 0101
,
擴容之後,因爲高位爲0,0000 0101 & 0001 1111 = 0000 0101
,位置沒變,可以直接放到擴容後的原始位置。 - 數組長度爲16,原始的hash值爲 0001 0101,
0001 0101 & 0000 1111 = 0101
, 擴容到32之後,0001 0101 & 0001 1111 = 0001 0101
, 比原來的位置大16。
有意思吧! 好好品,越品越有意思!
截取了一段擴容代碼
final Node<K,V>[] resize(){
//***
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}