面試這麼撩準拿offer,HashMap深度學習,擾動函式、負載因子、擴容拆分,原理和實踐驗證,讓懂了就是真的懂!

小傅哥發表於2020-08-10


作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

得益於Doug Lea老爺子的操刀,讓HashMap成為使用和麵試最頻繁的API,沒辦法設計的太優秀了!

HashMap 最早出現在 JDK 1.2中,底層基於雜湊演算法實現。HashMap 允許 null 鍵和 null 值,在計算哈鍵的雜湊值時,null 鍵雜湊值為 0。HashMap 並不保證鍵值對的順序,這意味著在進行某些操作後,鍵值對的順序可能會發生變化。另外,需要注意的是,HashMap 是非執行緒安全類,在多執行緒環境下可能會存在問題。

HashMap 最早在JDK 1.2中就出現了,底層是基於雜湊演算法實現,隨著幾代的優化更新到目前為止它的原始碼部分已經比較複雜,涉及的知識點也非常多,在JDK 1.8中包括;1、雜湊表實現2、擾動函式3、初始化容量4、負載因子5、擴容元素拆分6、連結串列樹化7、紅黑樹8、插入9、查詢10、刪除11、遍歷12、分段鎖等等,因涉及的知識點較多所以需要分開講解,本章節我們會先把目光放在前五項上,也就是關於資料結構的使用上。

資料結構相關往往與數學離不開,學習過程中建議下載相應原始碼進行實驗驗證,可能這個過程有點燒腦,但學會後不用死記硬背就可以理解這部分知識。

二、資源下載

本章節涉及的原始碼和資源在工程,interview-04中,包括;

  1. 10萬單詞測試資料,在doc資料夾
  2. 擾動函式excel展現,在dock資料夾
  3. 測試原始碼部分在interview-04工程中

可以通過關注公眾號:bugstack蟲洞棧,回覆下載進行獲取{回覆下載後開啟獲得的連結,找到編號ID:19}

三、原始碼分析

1. 寫一個最簡單的HashMap

學習HashMap前,最好的方式是先了解這是一種怎麼樣的資料結構來存放資料。而HashMap經過多個版本的迭代後,乍一看程式碼還是很複雜的。就像你原來只穿個褲衩,現在還有秋褲和風衣。所以我們先來看看最根本的HashMap是什麼樣,也就是隻穿褲衩是什麼效果,之後再去分析它的原始碼。

問題: 假設我們有一組7個字串,需要存放到陣列中,但要求在獲取每個元素的時候時間複雜度是O(1)。也就是說你不能通過迴圈遍歷的方式進行獲取,而是要定位到陣列ID直接獲取相應的元素。

方案: 如果說我們需要通過ID從陣列中獲取元素,那麼就需要把每個字串都計算出一個在陣列中的位置ID。字串獲取ID你能想到什麼方式? 一個字串最直接的獲取跟數字相關的資訊就是HashCode,可HashCode的取值範圍太大了[-2147483648, 2147483647],不可能直接使用。那麼就需要使用HashCode與陣列長度做與運算,得到一個可以在陣列中出現的位置。如果說有兩個元素得到同樣的ID,那麼這個陣列ID下就存放兩個字串。

以上呢其實就是我們要把字串雜湊到陣列中的一個基本思路,接下來我們就把這個思路用程式碼實現出來。

1.1 程式碼實現

// 初始化一組字串
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("小傅哥");
list.add("e4we");
list.add("alpo");
list.add("yhjk");
list.add("plop");

// 定義要存放的陣列
String[] tab = new String[8];

// 迴圈存放
for (String key : list) {
    int idx = key.hashCode() & (tab.length - 1);  // 計算索引位置
    System.out.println(String.format("key值=%s Idx=%d", key, idx));
    if (null == tab[idx]) {
        tab[idx] = key;
        continue;
    }
    tab[idx] = tab[idx] + "->" + key;
}
// 輸出測試結果
System.out.println(JSON.toJSONString(tab));

這段程式碼整體看起來也是非常簡單,並沒有什麼複雜度,主要包括以下內容;

  1. 初始化一組字串集合,這裡初始化了7個。
  2. 定義一個陣列用於存放字串,注意這裡的長度是8,也就是2的倍數。這樣的陣列長度才會出現一個 0111 除高位以外都是1的特徵,也是為了雜湊。
  3. 接下來就是迴圈存放資料,計算出每個字串在陣列中的位置。key.hashCode() & (tab.length - 1)
  4. 在字串存放到陣列的過程,如果遇到相同的元素,進行連線操作模擬連結串列的過程
  5. 最後輸出存放結果。

測試結果

key值=jlkk Idx=2
key值=lopi Idx=4
key值=小傅哥 Idx=7
key值=e4we Idx=5
key值=alpo Idx=2
key值=yhjk Idx=0
key值=plop Idx=5
測試結果:["yhjk",null,"jlkk->alpo",null,"lopi","e4we->plop",null,"小傅哥"]
  • 在測試結果首先是計算出每個元素在陣列的Idx,也有出現重複的位置。
  • 最後是測試結果的輸出,1、3、6,位置是空的,2、5,位置有兩個元素被連結起來e4we->plop
  • 這就達到了我們一個最基本的要求,將串元素雜湊存放到陣列中,最後通過字串元素的索引ID進行獲取對應字串。這樣是HashMap的一個最基本原理,有了這個基礎後面就會更容易理解HashMap的原始碼實現。

1.2 Hash雜湊示意圖

如果上面的測試結果不能在你的頭腦中很好的建立出一個資料結構,那麼可以看以下這張雜湊示意圖,方便理解;

bugstack.cn Hash雜湊示意圖

  • 這張圖就是上面程式碼實現的全過程,將每一個字串元素通過Hash計算索引位置,存放到陣列中。
  • 黃色的索引ID是沒有元素存放、綠色的索引ID存放了一個元素、紅色的索引ID存放了兩個元素。

1.3 這個簡單的HashMap有哪些問題

以上我們實現了一個簡單的HashMap,或者說還算不上HashMap,只能算做一個雜湊資料存放的雛形。但這樣的一個資料結構放在實際使用中,會有哪些問題呢?

  1. 這裡所有的元素存放都需要獲取一個索引位置,而如果元素的位置不夠雜湊碰撞嚴重,那麼就失去了雜湊表存放的意義,沒有達到預期的效能。
  2. 在獲取索引ID的計算公式中,需要陣列長度是2的倍數,那麼怎麼進行初始化這個陣列大小。
  3. 陣列越小碰撞的越大,陣列越大碰撞的越小,時間與空間如何取捨。
  4. 目前存放7個元素,已經有兩個位置都存放了2個字串,那麼連結串列越來越長怎麼優化。
  5. 隨著元素的不斷新增,陣列長度不足擴容時,怎麼把原有的元素,拆分到新的位置上去。

以上這些問題可以歸納為;擾動函式初始化容量負載因子擴容方法以及連結串列和紅黑樹轉換的使用等。接下來我們會逐個問題進行分析。

2. 擾動函式

在HashMap存放元素時候有這樣一段程式碼來處理雜湊值,這是java 8的雜湊值擾動函式,用於優化雜湊效果;

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

2.1 為什麼使用擾動函式

理論上來說字串的hashCode是一個int型別值,那可以直接作為陣列下標了,且不會出現碰撞。但是這個hashCode的取值範圍是[-2147483648, 2147483647],有將近40億的長度,誰也不能把陣列初始化的這麼大,記憶體也是放不下的。

我們預設初始化的Map大小是16個長度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以獲取的Hash值並不能直接作為下標使用,需要與陣列長度進行取模運算得到一個下標值,也就是我們上面做的雜湊列子。

那麼,hashMap原始碼這裡不只是直接獲取雜湊值,還進行了一次擾動計算,(h = key.hashCode()) ^ (h >>> 16)。把雜湊值右移16位,也就正好是自己長度的一半,之後與原雜湊值做異或運算,這樣就混合了原雜湊值中的高位和低位,增大了隨機性。計算方式如下圖;

bugstack.cn 擾動函式

  • 說白了,使用擾動函式就是為了增加隨機性,讓資料元素更加均衡的雜湊,減少碰撞。

2.2 實驗驗證擾動函式

從上面的分析可以看出,擾動函式使用了雜湊值的高半區和低半區做異或,混合原始雜湊碼的高位和低位,以此來加大低位區的隨機性。

但看不到實驗資料的話,這終究是一段理論,具體這段雜湊值真的被增加了隨機性沒有,並不知道。所以這裡我們要做一個實驗,這個實驗是這樣做;

  1. 選取10萬個單詞詞庫
  2. 定義128位長度的陣列格子
  3. 分別計算在擾動和不擾動下,10萬單詞的下標分配到128個格子的數量
  4. 統計各個格子數量,生成波動曲線。如果擾動函式下的波動曲線相對更平穩,那麼證明擾動函式有效果。
2.2.1 擾動程式碼測試

擾動函式對比方法

public class Disturb {

    public static int disturbHashIdx(String key, int size) {
        return (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16));
    }

    public static int hashIdx(String key, int size) {
        return (size - 1) & key.hashCode();
    }

}
  • disturbHashIdx 擾動函式下,下標值計算
  • hashIdx 非擾動函式下,下標值計算

單元測試

// 10萬單詞已經初始化到words中
@Test
public void test_disturb() {
    Map<Integer, Integer> map = new HashMap<>(16);
    for (String word : words) {
        // 使用擾動函式
        int idx = Disturb.disturbHashIdx(word, 128);
        // 不使用擾動函式
        // int idx = Disturb.hashIdx(word, 128);
        if (map.containsKey(idx)) {
            Integer integer = map.get(idx);
            map.put(idx, ++integer);
        } else {
            map.put(idx, 1);
        }
    }
    System.out.println(map.values());
}

以上分別統計兩種函式下的下標值分配,最終將統計結果放到excel中生成圖表。

2.2.2 擾動函式雜湊圖表

以上的兩張圖,分別是沒有使用擾動函式和使用擾動函式的,下標分配。實驗資料;

  1. 10萬個不重複的單詞
  2. 128個格子,相當於128長度的陣列

未使用擾動函式

bugstack.cn 未使用擾動函式

使用擾動函式

bugstack.cn 使用擾動函式

  • 從這兩種的對比圖可以看出來,在使用了擾動函式後,資料分配的更加均勻了。
  • 資料分配均勻,也就是雜湊的效果更好,減少了hash的碰撞,讓資料存放和獲取的效率更佳。

3. 初始化容量和負載因子

接下來我們討論下一個問題,從我們模仿HashMap的例子中以及HashMap預設的初始化大小裡,都可以知道,雜湊陣列需要一個2的倍數的長度,因為只有2的倍數在減1的時候,才會出現01111這樣的值。

那麼這裡就有一個問題,我們在初始化HashMap的時候,如果傳一個17個的值new HashMap<>(17);,它會怎麼處理呢?

3.1 尋找2的倍數最小值

在HashMap的初始化中,有這樣一段方法;

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
  • 閥值threshold,通過方法tableSizeFor進行計算,是根據初始化來計算的。
  • 這個方法也就是要尋找比初始值大的,最小的那個2進位制數值。比如傳了17,我應該找到的是32。

計算閥值大小的方法;

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;
}
  • MAXIMUM_CAPACITY = 1 << 30,這個是臨界範圍,也就是最大的Map集合。
  • 乍一看可能有點暈?怎麼都在向右移位1、2、4、8、16,這主要是為了把二進位制的各個位置都填上1,當二進位制的各個位置都是1以後,就是一個標準的2的倍數減1了,最後把結果加1再返回即可。

那這裡我們把17這樣一個初始化計算閥值的過程,用圖展示出來,方便理解;

bugstack.cn 計算閥值

3.2 負載因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

負載因子是做什麼的?

負載因子,可以理解成一輛車可承重重量超過某個閥值時,把貨放到新的車上。

那麼在HashMap中,負載因子決定了資料量多少了以後進行擴容。這裡要提到上面做的HashMap例子,我們準備了7個元素,但是最後還有3個位置空餘,2個位置存放了2個元素。 所以可能即使你資料比陣列容量大時也是不一定能正正好好的把陣列佔滿的,而是在某些小標位置出現了大量的碰撞,只能在同一個位置用連結串列存放,那麼這樣就失去了Map陣列的效能。

所以,要選擇一個合理的大小下進行擴容,預設值0.75就是說當閥值容量佔了3/4s時趕緊擴容,減少Hash碰撞。

同時0.75是一個預設構造值,在建立HashMap也可以調整,比如你希望用更多的空間換取時間,可以把負載因子調的更小一些,減少碰撞。

4. 擴容元素拆分

為什麼擴容,因為陣列長度不足了。那擴容最直接的問題,就是需要把元素拆分到新的陣列中。拆分元素的過程中,原jdk1.7中會需要重新計算雜湊值,但是到jdk1.8中已經進行優化,不在需要重新計算,提升了拆分的效能,設計的還是非常巧妙的。

4.1 測試資料

@Test
public void test_hashMap() {
    List<String> list = new ArrayList<>();
    list.add("jlkk");
    list.add("lopi");
    list.add("jmdw");
    list.add("e4we");
    list.add("io98");
    list.add("nmhg");
    list.add("vfg6");
    list.add("gfrt");
    list.add("alpo");
    list.add("vfbh");
    list.add("bnhj");
    list.add("zuio");
    list.add("iu8e");
    list.add("yhjk");
    list.add("plop");
    list.add("dd0p");
    for (String key : list) {
        int hash = key.hashCode() ^ (key.hashCode() >>> 16);
        System.out.println("字串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \tBit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " \t\tIdx(32):" + ((
        System.out.println(Integer.toBinaryString(key.hashCode()) +" "+ Integer.toBinaryString(hash) + " " + Integer.toBinaryString((32 - 1) & hash));
    }
}

測試結果

字串:jlkk 	Idx(16):3 	Bit值:1100011101001000010011 - 10000 		Idx(32):19
1100011101001000100010 1100011101001000010011 10011
字串:lopi 	Idx(16):14 	Bit值:1100101100011010001110 - 0 		Idx(32):14
1100101100011010111100 1100101100011010001110 1110
字串:jmdw 	Idx(16):7 	Bit值:1100011101010100100111 - 0 		Idx(32):7
1100011101010100010110 1100011101010100100111 111
字串:e4we 	Idx(16):3 	Bit值:1011101011101101010011 - 10000 		Idx(32):19
1011101011101101111101 1011101011101101010011 10011
字串:io98 	Idx(16):4 	Bit值:1100010110001011110100 - 10000 		Idx(32):20
1100010110001011000101 1100010110001011110100 10100
字串:nmhg 	Idx(16):13 	Bit值:1100111010011011001101 - 0 		Idx(32):13
1100111010011011111110 1100111010011011001101 1101
字串:vfg6 	Idx(16):8 	Bit值:1101110010111101101000 - 0 		Idx(32):8
1101110010111101011111 1101110010111101101000 1000
字串:gfrt 	Idx(16):1 	Bit值:1100000101111101010001 - 10000 		Idx(32):17
1100000101111101100001 1100000101111101010001 10001
字串:alpo 	Idx(16):7 	Bit值:1011011011101101000111 - 0 		Idx(32):7
1011011011101101101010 1011011011101101000111 111
字串:vfbh 	Idx(16):1 	Bit值:1101110010111011000001 - 0 		Idx(32):1
1101110010111011110110 1101110010111011000001 1
字串:bnhj 	Idx(16):0 	Bit值:1011100011011001100000 - 0 		Idx(32):0
1011100011011001001110 1011100011011001100000 0
字串:zuio 	Idx(16):8 	Bit值:1110010011100110011000 - 10000 		Idx(32):24
1110010011100110100001 1110010011100110011000 11000
字串:iu8e 	Idx(16):8 	Bit值:1100010111100101101000 - 0 		Idx(32):8
1100010111100101011001 1100010111100101101000 1000
字串:yhjk 	Idx(16):8 	Bit值:1110001001010010101000 - 0 		Idx(32):8
1110001001010010010000 1110001001010010101000 1000
字串:plop 	Idx(16):9 	Bit值:1101001000110011101001 - 0 		Idx(32):9
1101001000110011011101 1101001000110011101001 1001
字串:dd0p 	Idx(16):14 	Bit值:1011101111001011101110 - 0 		Idx(32):14
1011101111001011000000 1011101111001011101110 1110
  • 這裡我們隨機使用一些字串計算他們分別在16位長度和32位長度陣列下的索引分配情況,看哪些資料被重新路由到了新的地址。
  • 同時,這裡還可以觀察?出一個非常重要的資訊,原雜湊值與擴容新增出來的長度16,進行&運算,如果值等於0,則下標位置不變。如果不為0,那麼新的位置則是原來位置上加16。{這個地方需要好好理解下,並看實驗資料}
  • 這樣一來,就不需要在重新計算每一個陣列中元素的雜湊值了。

4.2 資料遷移

bugstack.cn 資料遷移

  • 這張圖就是原16位長度陣列元素,像32位陣列長度中轉移的過程。
  • 其中黃色區域元素zuio因計算結果 hash & oldCap 不為1,則被遷移到下標位置24。
  • 同時還是用重新計算雜湊值的方式驗證了,確實分配到24的位置,因為這是在二進位制計算中補1的過程,所以可以通過上面簡化的方式確定雜湊值的位置。

四、總結

  • 如果你能堅持看完這部分內容,並按照文中的例子進行相應的實驗驗證,那麼一定可以學會本章節涉及這五項知識點;1、雜湊表實現2、擾動函式3、初始化容量4、負載因子5、擴容元素拆分
  • 對我個人來說以前也知道這部分知識,但是沒有驗證過,只知道概念如此,正好藉著寫面試手冊專欄,加深學習,用資料驗證理論,讓知識點可以更加深入的理解。
  • 這一章節完事,下一章節繼續進行HashMap的其他知識點挖掘,讓懂了就是真的懂了。好了,寫到這裡了,感謝大家的閱讀。如果某處沒有描述清楚,或者有不理解的點,歡迎與我討論交流。

五、推薦閱讀

相關文章