HashMap擴容流程

今天在和同時討論HashMap的時候,提到了擴容和衝哈希的事情,然後我發現大家都是一種半懂不懂的狀態。於是回去做了一番功課,寫下這篇文章。

HashMap的擴容,又被很多人叫rehash、重哈希,我本人是很反對這個叫法的,事實上HashMap擴容的時候,Node中存儲的Key的hash值並沒有發生變化,只是Node的位置發生了變化。

首先說爲什麼需要擴容?這個問題好像有點簡單,不就是因爲容量滿了就需要庫容了嗎?這種思想對於鏈表來說是沒有問題的,但是對於HashMap來說,並不是因爲這個原因,而是HashMap認爲性能不夠好時。

原因我簡單說下,當然關於哈希表我就不再過多說了,還不懂的同學趕緊百度。

爲什麼擴容?

我們知道理論上哈希表的讀時間複雜度是O(1),但是沒有一種哈希方法能保證絕對的哈希均勻,爲了解決哈希衝突又往往採用鏈地址法解決,那這樣時間複雜度愈發偏離O(1)了,此時進行擴容,其實是讓哈希表分散的更均勻,解決性能不夠好的問題。

關於JDK1.8中的HashMap結構,在這裏我就不多說了,哈希表+鏈表,長度達到一定時鏈表轉換成紅黑樹(這時候是不是得有面試官出來問,長度多長才轉紅黑樹啊?),爲了讓小夥伴們看的更直觀,我這裏偷一張圖上來:
在這裏插入圖片描述

什麼時候擴容?

那麼什麼時候需要擴容?答案也很簡單:1.初始化後放入元素時 2.達到閾值時

  1. 創建對象以後,HashMap並不是立即初始化table,而是在第一次放入元素時,纔會初始化table,這很HashMap節省內存得一種機制,而table的初始化其實是resize方法實現的。

  2. 達到閾值時,這個就比較有意思,所謂閾值,就是HashMapthreshold這個屬性,閾值的計算方式很簡單,基本上就是capacity(table容量) * loadFactor(負載因子),這裏我覺得capacity應該稱爲理論容量,是因爲正常情況下達到閾值就擴容了,達到閾值時HashMap認爲哈希衝突的次數會不能接受,因此需要擴容。

因爲這裏我是以JDK1.8源碼作爲樣本分析的,如果我沒記錯的話,JDK1.7中還存在rehash方法,但是JDK1.8中已經改名叫resize方法了,那我們就不管JDK1.7中是如何實現擴容,直接上JDK1.8源碼。

如何擴容?

先來看擴容函數的前半部分:

final Node<K,V>[] resize() {
    // 擴容前原本的table
    Node<K,V>[] oldTab = table;
    // 這裏進行判斷,區分尚未初始化的情況 
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) { // 非初始化情況
        /*
         * 當原本的capacity已經超過最大值以後
         * HashMap選擇不再擴容,然後threshold置爲最大值
         */
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /*
         * 這種是常見的的擴容情況,table容量會擴大兩倍
         * 同時HashMap的閾值也跟着擴大兩倍
         */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 2倍閾值
    }
    else if (oldThr > 0) // 指定大小n的初始化情況下,table容量取>n的最小2倍數
        newCap = oldThr;
    else {               // 不指定初始化大小,table容量取默認值16
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) { 
        // 指定大小初始化情況下,閾值 = table容量 * 負載因子(默認0.75)
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 最後確定閾值
    threshold = newThr;
    
    待續......
}

resize()方法的前半部分主要是對於新閾值和新容量的確定,這裏有三種情況:

  1. 初始化已完成的正常擴容邏輯:table容量和閾值都擴大2倍
  2. 指定大小的初始化邏輯:算出大於等於指定初始化容量的最小2的倍數,作爲table容量
  3. 未指定大小的初始化邏輯:默認table容量16,閾值爲16 * 0.75 = 12

關於第二種邏輯,簡單來說就是,指定初始化值爲3,那麼table容量就是4,如果指定初始化大小是10,那麼table容量就是16,如果是2的次冪,就直接作爲table容量。

再看resize方法的後半段:


    final Node<K,V>[] resize() {
    	......接上文
    	
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) { 
            	// 遍歷原本的table
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null; // 爲了GC
                    if (e.next == null)
                        // 如果table上沒有鏈表的情況下,直接轉移到對應位置
                        // 轉移到的位置就是get方法中取的下標位置,tab[(n - 1) & hash]
                        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;
                            // 如果entry的哈希值高位爲0,會被拆分到lo鏈表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                            // 如果entry的哈希值高位爲1,會被拆分到hi鏈表                            
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            // 高位是0,因此lo鏈表不需要移位,
                            // hash & (newCap-1)和hash & (oldCap-1)獲得下標位置一樣
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            // 高位是1,hi鏈表移位到 j+oldCap位置,
                            //  j + oldCap相當於高位補1,直接移到這個位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

這裏爲了能說的更通俗易懂一些,我舉個簡單的例子:

首先假設原本有幾個key,他們的的hash值爲:

key hash值 下標 hash & (length-1)
key1 00000101 00000101 = 5
key2 00010101 00000101 = 5
key3 00100101 00000101 = 5
key4 00110101 00000101 = 5

而假設原本table容量 oldCap = 16;

他們在table中存儲的下標位置都是:

hash & (table.length-1) = 0101 & 0111

那麼這幾個key都會存儲在table[5]位置,如下圖 :
在這裏插入圖片描述
當進行擴容時,容量擴大一倍也就是newCap = 32,此時table.length -1 = 31

hash & (table.length - 1) 就會出現兩種情況:

  1. key1 和 key3 的下標還是5 (0000 0101)
  2. key2 和 key4 的下標變爲了 21 (0001 0101)
key hash值 下標 hash & (length-1)
key1 00000101 00000101 = 5
key2 00010101 00001101 = 21
key3 00100101 00000101 = 5
key4 00110101 00001101 = 21

到這應該能明白,resize方法裏的lo列表和hi列表是什麼意思了,其實就是看key高一位的哈希值是1還是0,來決定是放到哪個隊列裏。 移位後的HashMap如下圖:

在這裏插入圖片描述

這裏HashMap非常精妙的實現了擴容,沒有重新計算對象的哈希值,甚至連下標的重新計算也只需要進行一位相與的計算(hash高位 & newCap-1 )。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章