HashMap源碼解讀——逐句分析resize方法的實現


一、前言

  最近在閱讀HashMap的源碼,已經將代碼基本過了一遍,對它的實現已經有了一個較為全面的認識。今天就來分享一下HashMap中比較重要的一個方法——resize方法。我將對resize方法的源代碼進行逐句的分析。

  若想要看懂這個方法的源代碼,首先得對HashMap的底層結構和實現有一個清晰的認識,若不清楚的,可以看看我之前寫的一篇博客,這篇博客對HashMap的底層結構和實現進行了一個比較清晰和全面的講解,同時博客的最底下附上了兩篇阿里架構師對HashMap的分析,寫的非常好,很有參考價值:


二、解析

 2.1 resize方法的作用

  沒有閱讀過HashMap源碼的人可能並不知道它有一個叫resize的方法,因為這不是一個public方法,這個方法並沒有加上訪問修飾符,也就是說,這個方法HashMap所在的包下使用。很多人應該都知道,HashMap的基本實現是數組+鏈表(從JDK1.8開始已經變成了數組+鏈表+紅黑樹),而這個方法的作用也很簡單:

  1. 當數組並未初始化時,對數組進行初始化;
  2. 若數組已經初始化,則對數組進行擴容,也就是創建一個兩倍大小的新數組,並將原來的元素放入新數組中;

 2.2 resize方法中用到的變量

  HashMap中定義了很多的成員變量,而很多都在resize方法中有用到,所以為了看懂這個方法,首先需要了解這些變量的含義:

  • table:用來存儲數據的數組,即數組+鏈表結構的數組部分;
  • threshold:閾值,表示當前允許存入的元素數量,當元素數量超過這個值時,將進行擴容;
  • MAXIMUM_CAPACITYHashMap允許的最大容量,值為1<<30,也就是2^30
  • DEFAULT_INITIAL_CAPACITYHashMap的默認初始容量,值為16
  • loadFactor:負載因子,表示HashMap中的元素數量可以到達總容量的百分之多少,默認是75%,也就是說,默認情況下,當元素數量達到總容量的75%時,將進行擴容;
  • DEFAULT_LOAD_FACTOR:負載因子的默認值,也就是0.75

 2.3 resize方法源碼解讀

  下面就來看看resize方法的源碼吧,我用注釋的方式,對每一句代碼進行了解讀:

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final HashMap.Node<K,V>[] resize() {
    HashMap.Node<K,V>[] oldTab = table;
    // 記錄Map當前的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 記錄Map允許存儲的元素數量,即閾值(容量*負載因子)
    int oldThr = threshold;
    // 聲明兩個變量,用來記錄新的容量和閾值
    int newCap, newThr = 0;

    // 若當前容量不為0,表示存儲數據的數組已經被初始化過
    if (oldCap > 0) {
        // 判斷當前容量是否超過了允許的最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 若超過最大容量,表示無法再進行擴容
            // 則更新當前的閾值為int的最大值,並返回舊數組
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 將舊容量*2得到新容量,若新容量未超過最大值,並且舊容量大於默認初始容量(16),
        // 才則將舊閾值*2得到新閾值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }

    // 若不滿足上面的oldCap > 0,表示數組還未初始化,
    // 若當前閾值不為0,就將數組的新容量記錄為當前的閾值;
    // 為什么這里的oldThr在未初始化數組的時候就有值呢?
    // 這是因為HashMap有兩個帶參構造器,可以指定初始容量,
    // 若你調用了這兩個可以指定初始容量的構造器,
    // 這兩個構造器就會將閾值記錄為第一個大於等於你指定容量,且滿足2^n的數(可以看看這兩個構造器)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 若上面的條件都不滿足,表示你是調用默認構造器創建的HashMap,且還沒有初始化table數組
    else {               // zero initial threshold signifies using defaults
        // 則將新容量更新為默認初始容量(10)
        // 閾值即為(容量*負載因子)
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 經過上面的步驟后,newCap一定有值,但是若運行的是上面的第二個分支時,newThr還是0
    // 所以若當前newThr還是0,則計算出它的值(容量*負載因子)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

    // 將計算出的新閾值更新到成員變量threshold上
    threshold = newThr;

    // 創建一個記錄新數組用來存HashMap中的元素
    // 若數組不是第一次初始化,則這里就是創建了一個兩倍大小的新數組
    @SuppressWarnings({"rawtypes","unchecked"})
    HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
    // 將新數組的引用賦值給成員變量table
    table = newTab;

    // 開始將原來的數據加入到新數組中
    if (oldTab != null) {
        // 遍歷原數組
        for (int j = 0; j < oldCap; ++j) {
            HashMap.Node<K,V> e;
            // 若原數組的j位置有節點存在,才進一步操作
            if ((e = oldTab[j]) != null) {
                // 清除舊數組對節點的引用
                oldTab[j] = null;
                // 若table數組的j位置只有一個節點,則直接將這個節點放入新數組
                // 使用 & 替代 % 計算出余數,即下標
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 若第一個節點是一個數節點,表示原數組這個位置的鏈表已經被轉為了紅黑樹
                // 則調用紅黑樹的方法將節點加入到新數組中
                else if (e instanceof HashMap.TreeNode)
                    ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                // 上面兩種情況都不滿足,表示這個位置是一條不止一個節點的鏈表
                // 以下操作相對復雜,所以單獨拿出來講解
                else { // preserve order
                    HashMap.Node<K,V> loHead = null, loTail = null;
                    HashMap.Node<K,V> hiHead = null, hiTail = null;
                    HashMap.Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        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;
}

  上面的代碼中,最后一部分比較難理解,所以我將在下面單獨拿出來講解。


 2.4 resize方法中的鏈表拆分

  resize方法中的最后一部分,是將原數組中的一條鏈表的節點,放入到擴容后的新數組中,而這一部分相對來說比較難理解。首先我們得知道是怎么實現的,然后再來逐句分析代碼。

  首先,我們得知道一個結論,那就是:原數組中一條鏈表上的所有節點,若將它們加入到擴容后的新數組中,它們最多將會分布在新數組中的兩條鏈表上

  在HashMap中,使用按位與運算替代了取模運算來計算下標,因為num % 2^n == num & (2^n - 1),而HashMap的容量一定是2^n,所以可以使用這條定理(這里我假設大家已經了解了HashMap的容量機制,若不了解的,可以先看看我最上面給出的那篇博客)。我們看下面這張圖,左邊是擴容前的數組+鏈表,右邊是擴容后的數組+鏈表,鏈表矩形中的數字表示節點的hash值。左邊數組的容量為2^3==8,只包含一條四個節點的鏈表,右邊數組的容量為2^4 == 16,左邊鏈表上的節點重新存儲后,變成了右邊兩條鏈表。正對應了我們上面說的結論。

  那這個結論是怎么來的呢?我們先說左邊第一個節點,它的hash值是2,轉換成二進制就是0010,而容量為2^3 == 8,通過num % 2^n == num & (2^n - 1)這個公式,我們知道2與容量8的余數是2 & (8 - 1) == 0010 & 0111 == 0010任何數與0111做與運算(&),實際上就是取這個數二進制的最后三位。而擴容之后,容量變成了2^4 == 16,這時候,取模就是與16-1 == 15做與運算了,而15的二進制是1111,我們發現,1111與之前的0111唯一的區別就是第四位也變成了1(以下說的第幾位都是從右往左)。而2 & 15 == 0010 & 1111 == 0010,和0010 & 0111 結果是一樣的。為什么?因為0010的第四位是0,所以從0111變成1111,並不會對計算結果造成影響,因為0和任何數做與運算,結果都是0。所以擴容后,2這個節點,還是放在數字下標為2的位置。我們在來看看剩下的三個數:

hash值為10,轉換成二進制1010,1010的第四位為1,所以 1010 & 0111 != 1010 & 1111

hash值為18,轉換成二進制10010,10010的第四位為0,所以 10010 & 0111 == 10010 & 1111
    
hash值為26,轉換成二進制11010,11010的第四位為1,所以 11010 & 0111 != 11010 & 1111

  所以擴容后,余數是否發生改變,實際上只取決於多出來的那一位而已,那一位只有兩種結果:0或者1,所以這些節點的新下標最終也只有兩種結果。而多出來的那一位是哪一位呢?8轉換成二進制是1000,而從8擴容到16,取余的數從0111變成了1111,多出的這個1剛好在第四位,也就是1000中,唯一一個1所在的位置;16的二進制是10000,擴容成32后,取余的數從1111變成11111,在第五位多出了一個1,正好是10000的1所在的位置。所以我們可以知道,擴容后,節點的下標是否需要發生改變,取決於舊容量的二進制中,1那一位。所以容量為8,擴容后,若節點的二進制hash值的第四位為0,則節點在新數組中的下標不變;若為1,節點的下標改變,而且改變的大小正好是+8,因為多出了最高位的1,例如1010 & 0111 = 0010,而1010 & 1111 = 1010,結果相差1000,也就是舊容量的大小8;所以若下標要發生改變,改變的大小將正好是舊數組的容量。

  我們如何判斷hash值多出來的那一位是0還是1呢,很簡單,只要用hash值與舊容量做與運算,結果不為0表示多出的這一位是1,否則就是0。比如說,容量為8(二進制1000),擴容后多出來的是第四位,於是讓hash值與1000做與運算,若hash值的第四位是1,與1000做與運算后結果就是1000,若第四位是0,與1000做與運算后就是0。好,下面我們來看看代碼吧:

// 創建兩個頭尾節點,表示兩條鏈表
// 因為舊鏈表上的元素放入新數組中,最多將變成兩條鏈表
// 一條下標不變的鏈表,一條下標+oldCap
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;

// 循環遍歷原鏈表上的每一個節點
do {
    // 記錄當前節點的下一個節點
    next = e.next;
    // 注意:e.hash & oldCap這一步就是前面說的判斷多出的這一位是否為1
    // 若與原容量做與運算,結果為0,表示將這個節點放入到新數組中,下標不變
    if ((e.hash & oldCap) == 0) {
        // 若這是不變鏈表的第一個節點,用loHead記錄
        if (loTail == null)
            loHead = e;
        // 否則,將它加入下標不變鏈表的尾部
        else
            loTail.next = e;
        // 更新尾部指針指向新加入的節點
        loTail = e;
    }
    // 若與原容量做與運算,結果為1,表示將這個節點放入到新數組中,下標將改變
    else {
        // 若這是改變下標鏈表的第一個節點,用hiHead記錄
        if (hiTail == null)
            hiHead = e;
        // 否則,將它加入改變下標鏈表的尾部
        else
            hiTail.next = e;
        // 更新尾部指針指向新加入的節點
        hiTail = e;
    }
} while ((e = next) != null);

// 所有節點遍歷完后,判斷下標不變的鏈表是否有節點在其中
if (loTail != null) {
    // 將這條鏈表的最后一個節點的next指向null
    loTail.next = null;
    // 同時將其放入新數組的相同位置
    newTab[j] = loHead;
}
// 另一條鏈表與上同理
if (hiTail != null) {
    hiTail.next = null;
    // 這條鏈表放入的位置要在原來的基礎上加上oldCap
    newTab[j + oldCap] = hiHead;
}

三、總結

  resize的邏輯並不算太難,可能只有鏈表拆分這一部分比較難理解。為了能盡可能地說清楚,我描述的可能有點啰嗦了,希望對看到的人能夠有所幫助吧。


四、參考

https://blog.csdn.net/weixin_41565013/article/details/93190786


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM