那些年,我們又愛又恨的HashMap(二)-原始碼篇

Helay發表於2020-03-31

一、HashMap重要方法原始碼分析

1.put()方法,即新增元素方法

put()方法較為複雜的,也是面試中最常問的方法,實現步驟大致如下:

  • 計算出需要新增的元素的key的雜湊值;

  • 使用該雜湊值結合陣列長度採用位運算hash&length-1計算出索引值;

  • 如果該位置無資料,則直接插入;

  • 如果有資料,比較雜湊值是否相等:

    ​ 不相等:在此索引位置劃出一個節點儲存資料,此為拉鍊法;

    ​ 相等:發生雜湊衝突,使用連結串列或紅黑樹解決,呼叫equals()比較內容是否相同;

    ​ 相同:新值覆蓋舊值;

    ​ 不相同:劃出一個節點直接儲存;

  • 如果size大於threshold,進行擴容。

2.put()方法原始碼:
public V put(K key, V value) {
    //呼叫putVal()方法,putVal()呼叫hash(key)
    return putVal(hash(key), key, value, false, true);
}
複製程式碼
3.hash()方法原始碼:
static final int hash(Object key) {
    int h;
    /**
      * 如果key為null,返回0;
      * 如果key不為null,計算出key的雜湊值並賦給h,然後與h無符號右移16位後進行按位異或得到最終雜湊值
      */
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-----------------------------------------------------------------------------------------
(h = key.hashCode()) ^ (h >>> 16)的計算過程如下所示:
假設h = key.hashCode()計算出的h為 11111111 11111111 11110000 11101010;
無符號右移,無論是正數還是負數,高位都補0;
^(按位異或運算):相同二進位制數位上相同為0,不同為111111111 11111111 11110000 11101010  //h
^ 00000000 00000000 11111111 11111111  //h >>> 16:
--------------------------------------------------------
  11111111 11111111 00001111 00010101  //返回的雜湊值
複製程式碼

putVal()方法中使用到了上述hash函式計算的雜湊值。

4.putVal()方法原始碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //此處省略一千行,如果需要,請自行檢視jdk原始碼
    if ((p = tab[i = (n - 1) & hash]) == null)  //這裡使用到了上面計算得到的雜湊值
    //此處省略一千行,如果需要,請自行檢視jdk原始碼
}
-----------------------------------------------------------------------------------------
利用hash()方法返回的雜湊值計算索引,(n - 1) & hash]);
n為陣列長度,預設為16;
&(按位與運算):相同的二進位制數位都是1,結果為1,否則為000000000 00000000 00000000 00001111  //n - 1 = 15
& 11111111 11111111 00001111 00010101  //hash
--------------------------------------------------------
  00000000 00000000 00000000 00000101  //索引為5

複製程式碼

5.為什麼要這樣運算呢?又是無符號右移16位,又是異或,最後還要按位與,這樣不是很麻煩嗎?

這樣做是為了避免發生雜湊衝突。

如果陣列長度n很小,假設是16的話,那麼n-1=151111 ,這樣的值和雜湊值直接按位與運算,實際上只使用了雜湊值的後4位。如果當雜湊值的高位變化很大,低位變化很小,這樣就很容易造成雜湊衝突了,所以這裡把高低位都利用起來,從而解決了這個問題。

舉例說明這個問題:

key.hashCode()計算出的雜湊值與n - 1直接按位與運算:
  11111111 11111111 11110000 11101010  //h
& 00000000 00000000 00000000 00001111  //n - 1 = 15
--------------------------------------------------------
  00000000 00000000 00000000 00001010  //索引為10
  
再儲存一個key.hashCode()計算出的雜湊值,並且高16位變化很大
  11000111 10110011 11110000 11101010  //新儲存的雜湊值h,並且高16位變化很大
& 00000000 00000000 00000000 00001111  //n - 1 = 15
--------------------------------------------------------
  00000000 00000000 00000000 00001010  //索引仍為10

結論:直接按位與,並且雜湊值的高位變化大,低位變化小甚至不變時,容易出現索引值一樣的情況,進而造成雜湊衝突
複製程式碼

二、HashMap中的兩個重要方法原始碼分析

1.最難的putVal()原始碼分析:
transient Node<K,V>[] table;  //表示HashMap中的陣列主體
/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key(key的雜湊值)
 * @param key the key(key)
 * @param value the value to put(新增元素的值)
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
     /*
      1.transient Node<K,V>[] table;表示HashMap中的陣列主體;
      2.(tab = table) == null:將空的table賦值給tab,第一次是null,結果為true;
      3.(n = tab.length) == 0:表示將陣列的長度0賦值給n,然後判斷n是否等於0,結果為true;
      4.由於if使用雙或判斷,一邊為true就為true,所以執行程式碼 n = (tab = resize()).length; 
      5.n = (tab = resize()).length:呼叫resize方法對陣列進行擴容,並將擴容後的陣列長度賦值給n;
      6.執行完 n = (tab = resize()).length 後,陣列tab的每個桶(每個索引位置)都是null。
     */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
     /*
      1.i = (n - 1) & hash:計算出索引並賦值給i,即確定元素存放在哪個桶中;
      2.p = tab[i = (n - 1) & hash]:將該索引位置上的資料賦值給節點p;
      3.(p = tab[i = (n - 1) & hash]) == null:判斷索引位置上的內容是否為null;
      4.如果為null,則執行程式碼 tab[i] = newNode(hash, key, value, null);
     */ 
    if ((p = tab[i = (n - 1) & hash]) == null)
        //根據鍵值對建立新的節點,並將該節點存入桶中。
        tab[i] = newNode(hash, key, value, null);
    else {
        //執行else,說明tab[i]不等於null,表示這個位置已經有值了。
        Node<K,V> e; K k;
         /*
          1.p.hash == hash:p.hash表示已存在key的雜湊值,hash表示新新增資料key的雜湊值;
          2.(k = p.key) == key:將已存在資料的key的地址賦值給k,然後與新新增資料的key的地址進行比較
          3.(key != null && key.equals(k)))):執行到這裡說明兩個key的地址值不相等,那麼先判斷後				新增的key是否等於null,如果不等於null再呼叫equals方法判斷兩個key的內容是否相等
          */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //如果兩個key的雜湊值相等,並且value值也相等,則將舊的元素整體物件賦值給e,用e來記錄
            e = p;
         //雜湊值不相等或者key的地址不相等,則判斷p是否為紅黑樹結點
        else if (p instanceof TreeNode)
            //是紅黑樹幾點,則放入樹中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //否則說明是連結串列節點
        else {
            //是連結串列的話需要遍歷到結尾然後插入(尾插法);採用迴圈遍歷的方式,判斷連結串列中是否有重複的key
            for (int binCount = 0; ; ++binCount) {
                /*
                 1.e = p.next:獲取p的下一個元素賦值給e
                 2.(e = p.next) == null:判斷p.next是否等於null,等於null,說明p沒有下一個元素;那麼此時到達了連結串列的尾部,還沒有找到重複的key,則說明HashMap沒有包含該鍵,則將該鍵值                    對插入連結串列中。
                */
                if ((e = p.next) == null) {
                     /*
                      1.p.next = newNode(hash, key, value, null):建立一個新節點並插入到尾部;
                      2.這種新增方式也滿足連結串列資料結構的特點,每次向末尾新增新的元素。
                    */
                    p.next = newNode(hash, key, value, null);
                     /*
                      1.節點新增完成之後判斷此時節點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將連結串列轉換為紅黑樹;
                      2.binCount表示for迴圈的初始值,從0開始計數,記錄著遍歷節點的個數;值是0表示第1個節點,1表示第2個節點,以此類推,7就表示第8個節點,8個節點則儲存著9個元素;
                      3.TREEIFY_THRESHOLD-1 =8-1=7;此時如果binCount的值也是7,則轉換紅黑樹。
                    */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                         //轉換為紅黑樹
                        treeifyBin(tab, hash);
                    //轉化為紅黑樹就跳出迴圈
                    break;
                }
                /*
                 執行到這裡說明e = p.next不是null,不是最後一個元素,繼續判斷連結串列中結點的key值與插入的資料的key值是否相等
                */
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                	 //相等,跳出for迴圈,不用再繼續比較,直接執行下面的if (e != null)語句
                    break;
                p = e;
            }
        }
        /*
         為true表示在桶中找到key的雜湊值和key的地址值與插入資料相等的結點;也就是說找到了重複的鍵,所以這裡就是把該鍵的值變為新的值,並返回舊值,使用的是put方法的修改功能。
        */
        if (e != null) { // existing mapping for key(存在重複的鍵)
            //記錄e的value
            V oldValue = e.value;
            //如果onlyIfAbsent為false或者舊值為null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替換舊值
                e.value = value;
            //訪問後回撥
            afterNodeAccess(e);
            //返回舊值
            return oldValue;
        }
    }
    //記錄修改次數
    ++modCount;
    //判斷實際大小是否大於threshold閾值,如果大於則擴容
    if (++size > threshold)
        //擴容
        resize();
    // 插入後回撥
    afterNodeInsertion(evict);
    return null;
}
複製程式碼
2.將連結串列轉換為紅黑樹的treeifyBin()方法原始碼分析:

連結串列什麼時候轉化為紅黑樹?在putVal()方法中給出了答案:

//當連結串列長度大於閾值8時轉化為紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    //轉換為紅黑樹,tab表示陣列名,hash表示雜湊值
    treeifyBin(tab, hash);
複製程式碼

treeifyBin()方法原始碼分析:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
     /*
      1.如果當前陣列為空或者陣列的長度小於64(MIN_TREEIFY_CAPACITY = 64),則進行去擴容,而不是將連結串列變為紅黑樹;
      2.原因:如果陣列很小就轉換紅黑樹,遍歷效率要低一些(紅黑樹結構複雜);這時進行擴容,重新計算雜湊值,將資料重新分配到陣列主體中,連結串列長度有可能就變短了,這樣做相對來說效率高一些。
      */
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        //擴容方法
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        /*
         1.執行到這裡說明雜湊表中的陣列長度大於64,開始由連結串列轉化為紅黑樹;
         2.e = tab[index = (n - 1) & hash]表示將陣列中的元素取出賦值給e,e是雜湊表中指定位置桶裡的連結串列節點,從第一個開始;
         3.這裡hd表示紅黑樹的頭結點,tl表示紅黑樹的尾結點,預設都為null。
         */
        TreeNode<K,V> hd = null, tl = null;
        do {
             //新建立一個樹節點,內容和當前連結串列節點e一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                //將新創鍵的樹節點p賦值給紅黑樹的頭結點
                hd = p;
            else {
                /*
                  1.p.prev = tl:將上一個節點p賦值給現在的p的前一個節點;
                  2.tl.next = p:將現在節點p作為樹的尾結點的下一個節點。
                    */
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
            /*
              e = e.next:將當前節點的下一個節點賦值給e,如果下一個節點不等於null,則回到上面繼續取出連結串列中節點轉換為紅黑樹
            */
        } while ((e = e.next) != null);
        /*
          讓桶中的第一個元素即陣列中的元素指向新建的紅黑樹的節點,以後這個桶裡的元素就是紅黑樹而不是連結串列了,至此,連結串列轉化紅黑樹完成
          */
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
複製程式碼

上述操作一共做了如下三件事:

1.根據雜湊表中元素個數確定是擴容還是樹形化;

2.如果是樹形化遍歷桶中的元素,建立相同個數的樹形節點,複製內容,建立起聯絡;

3.然後讓桶中的第一個元素指向新建立的樹根節點,替換桶的連結串列內容為樹形化內容。

三、HashMapresize()擴容方法

1.首先,什麼時候開始擴容?

HashMap中的元素個數超過n(陣列長度)*loadFactor(負載因子)時,就會進行陣列擴容。n的預設值為16loadFactor的預設值是0.75,那麼當HashMap中的元素個數超過16×0.75=12(邊界值threshold)時,就把陣列的大小擴大為原來的2倍,即32,然後重新計算每個元素在陣列中的位置,而這是一個非常耗效能的操作,所以如果我們已經知道HashMap中元素的個數,那麼使用HashMap的有參構造指定初始化大小是一個不錯的選擇。

說明:

HashMap中的一個連結串列長度大於8時,但陣列長度沒有達到64,那麼HashMap會先擴容解決,如果已經達到了64,那麼這個連結串列會變為紅黑樹,節點型別由Node變成TreeNode型別。當然,如果移除元素使紅黑樹的節點個數小於6時,也會再把紅黑樹轉換為連結串列。

2.擴容的實質是什麼?

說白了,擴容就是一個rehash的過程,即重新計算HashMap中元素的位置並分配到擴容後的HashMap中。

在JDK1.8之後,HashMap對resize()方法進行了優化,使用到了非常巧妙的rehash方式進行索引位置的計算。

下面分析一下這個rehash方式怎麼巧妙?

我們知道,HashMap在擴容的時候,總是擴大為原來的兩倍,這樣的話,與原始HashMap相比,擴容後計算的索引只是比原來的索引多了一個bit位(二進位制位);

所以:擴容後的索引要麼為原來的索引,要麼變為原索引+舊容量。

如果沒有看明白這句話,請看下面的例子:

例如我們將HashMap由原來的16擴充套件為32,變化前後索引的計算過程如下所示:
索引計算公式:index=(n-1) & hash;按位與運算:相同二進位制位都為1,結果為1,否則為0
hash1(key1): 11111111 11111111 00001111 00000101;
hash2(key2): 11111111 11111111 00001111 00010101;
原HashMap容量n=16,   二進位制表示為: 00000000 00000000 00000000 00010000;
擴容後HashMap容量n=32,二進位制表示為: 00000000 00000000 00000000 00100000;
-----------------------------------------------------------------------------------------
原HashMap的key1索引:
  00000000 00000000 00000000 00001111  //n-1=16-1=15
& 11111111 11111111 00001111 00000101  //hash1(key1)
------------------------------------------------------
  00000000 00000000 00000000 00000101  //索引為5
原HashMap的key2索引:
  00000000 00000000 00000000 00001111  //n-1=16-1=15
& 11111111 11111111 00001111 00010101  //hash2(key2)
------------------------------------------------------
  00000000 00000000 00000000 00000101  //索引為5
結果:key1和可以key2的索引都為5;
-----------------------------------------------------------------------------------------
擴容後的HashMap的key1索引:
  00000000 00000000 00000000 00011111  //n-1=32-1=31
& 11111111 11111111 00001111 00000101  //hash1(key1)
------------------------------------------------------
  00000000 00000000 00000000 00000101  //索引為5
原HashMap的key2索引:
  00000000 00000000 00000000 00011111  //n-1=32-1=31
& 11111111 11111111 00001111 00010101  //hash2(key2)
------------------------------------------------------
  00000000 00000000 00000000 00010101  //索引為5+16
結果:key1的索引為5;key2的索引為 16+5,即為原索引+舊容量
複製程式碼

由上面的推理過程可以得出這樣的結論:

元素在重新計算雜湊值後,因為n變為2倍,那麼n-1的標記範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

那些年,我們又愛又恨的HashMap(二)-原始碼篇

即紅色所示的高位為0,還是原來的索引位置;為1,索引變為原索引+舊容量。

因此,在擴容HashMap時,不需要重新計算雜湊值,只需要看原來的雜湊值新增的那個bit是1還是0就可以了,是0的話索引不變,是1的話索引變成“原索引+oldCap(原位置+舊容量)”,具體可以看下面16擴容32的示意圖:

那些年,我們又愛又恨的HashMap(二)-原始碼篇

正是因為這樣巧妙的rehash方式,省去了重新計算雜湊值的時間,而且由於新增的1bit是0還是1可以認為是隨機的,在resize的過程中保證了rehash之後每個桶上的節點數一定小於等於原來桶上的節點數,保證了rehash之後不會出現更嚴重的雜湊衝突,均勻地把之前衝突的節點分散到新的桶中了。

3.擴容方法resize()原始碼分析:

看完了上面的擴容原理,再來看原始碼會容易些。

不要看這個方法長,覺得難就看不下去了,靜下心,好好分析完,對自己的技術肯定有提升,我們開始吧!

final Node<K,V>[] resize() {
    //得到當前陣列
    Node<K,V>[] oldTab = table;
    //如果當前陣列為null返回0,否則返回當前陣列長度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //當前邊界值,預設是12(16*0.75)
    int oldThr = threshold;
    int newCap, newThr = 0;
    //如果舊陣列長度大於0,則開始計算擴容後的大小
    if (oldCap > 0) {
        //如果舊陣列長度大於最大值,就不再擴容,就只好隨你碰撞去吧!
        if (oldCap >= MAXIMUM_CAPACITY) {
            //修改邊界值為Integer資料型別的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /*
         沒超過最大值,就擴充為原來的2倍;
         1.(newCap = oldCap << 1) < MAXIMUM_CAPACITY:擴大到2倍之後容量是否小於最大容量
         2.oldCap >= DEFAULT_INITIAL_CAPACITY:舊陣列長度是否大於等於陣列初始化長度16
        */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //舊邊界值左移一位,相當於擴大一倍
            newThr = oldThr << 1; // double threshold
    }
    //舊邊界值大於0則直接賦值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //計算新的resize最大上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    //新的邊界值原來預設是12,擴大一倍變為24
    threshold = newThr;
    //建立新的雜湊表
    @SuppressWarnings({"rawtypes","unchecked"})
        //newCap是擴容後的陣列長度32
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
     //判斷舊陣列是否等於空
    if (oldTab != null) {
        //遍歷舊的雜湊表中的桶,重新計算桶裡元素的新位置
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                //將舊陣列中的資料都置為null,便於GC回收
                oldTab[j] = null;
                //判斷陣列是否有下一個引用
                if (e.next == null)
                    //沒有下一個引用,說明不是連結串列,當前桶上只有一個鍵值對,直接插入
                    newTab[e.hash & (newCap - 1)] = e;
                 //判斷是否是紅黑樹
                else if (e instanceof TreeNode)
                    //說明是紅黑樹,則呼叫相關方法把樹分開
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                    //不是紅黑樹,則採用連結串列處理衝突
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                     //通過上述講解的原理來計算節點的新位置
                    do {
                        //原索引
                        next = e.next;
                        //如果為true,則e這個節點在擴容後還是原索引位置,說明高位為0
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //原索引+舊容量,說明高位為1
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //將擴容後的原索引放到桶裡
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //將擴容後的 原索引+舊容量 放到桶裡
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

複製程式碼

四、總結

HashMap的底層原始碼算是JDK原始碼中設計最複雜同樣也最優秀的原始碼了,如果能研究、理解了HashMap的原始碼,相信JDK的其他原始碼對你來說也不是什麼問題了。

都看到這裡了,給個贊再走吧!哈哈哈!!!

相關文章