HashMap解讀

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 = 262^6 (符合大於50並且是2的整數次冪 )
在這裏插入圖片描述
第二個知識點,回答開題的問題,爲什麼hash函數這麼設計?

  1. HashMap的hash函數是根據Key值計算的;
  2. 一定要儘可能降低hash碰撞,越分散越好;
  3. 算法一定要儘可能高效,因爲這是高頻操作;

再來看一下這段代碼:

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 值。如下圖所示,

hash函數 = 高16位 ^ 低16位
圖片來自 https://www.zhihu.com/question/20733617/answer/111577937

第三個知識點,相比1.7,JDK1.8做了哪些優化?

  1. 1.7 使用頭插法,1.8使用尾插法;
  2. 1.7 hash函數使用4次位運算+5次異或,1.8使用1次位運算+1次異或;
  3. 1.7 使用數組+鏈表的結構,1.8 使用數組 + 鏈表 +紅黑樹;
  4. 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. 先插入新節點再擴容(1.7是先判斷容量,不夠先擴容再插入);
  2. 先判斷是否爲紅黑樹,鏈表插入結束判斷是否是否應該轉爲紅黑樹;
  3. 紅黑樹轉爲鏈表的臨界值是6不是8,原因是如果長度經常在8附近,轉來轉去,浪費資源。
  4. 爲什麼紅黑樹的閾值是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採用簡單快捷的方式定位新位置: 直接放在原位置/ 原位置 + 舊容量

這個怎麼理解呢?看下面這張圖,
在這裏插入圖片描述
分二種情況:

  1. 比如現在 數組長度爲16,元素的hash值爲0101 , 0000 0101 & 0000 1111 = 0000 0101,
    擴容之後,因爲高位爲0,0000 0101 & 0001 1111 = 0000 0101,位置沒變,可以直接放到擴容後的原始位置。
  2. 數組長度爲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;
   }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章