週末我把HashMap原始碼又過了一遍

JaJian發表於2020-11-16

為什麼在Java面試中總是會問HashMap?

HashMap一直是Java面試官喜歡考察的題目,無論應聘者你處於哪個級別,在多輪的技術面試中似乎總有一次會被問到有關 HashMap 的問題。

為什麼在Java面試中一定會深入考察HashMap?因為 HashMap 它的設計結構和原理的特點,它既可以考初學者對 Java 集合的瞭解又可以深度的發現應聘者的資料結構功底。

圍繞著HashMap的問題,既可以問的很淺但是又可以深入的聊的很細,聊到資料結構,甚至計算機底層。

Java1.8的HashMap有什麼不一樣

我們知道在 Jdk1.7 和 Jdk1.8(及以後)的版本中 HashMap 的內部實現有很大區別,由於目前 Jdk1.8 是主流的一個版本,所以我們在這裡只對 Jdk1.8的版本中HashMap 做個講解。

Jdk1.8 相較於 Jdk1.7 其實主要是在兩個方面做了一些優化,使得資料的儲存和查詢效率有了很好的提升。

  • 儲存方面
    由原來的陣列+連結串列的儲存結構變更為陣列+連結串列+紅黑數的結構,從資料結構知識點中我們知道連結串列的特點是:定址(查詢)困難,插入和刪除容易。隨著儲存資料的增加,連結串列的長度會持續增長,查詢效率會越來越低,通過轉變成紅黑樹可以提升查詢的效率。

  • 定址優化
    原來的 Jdk1.7 中通過對 key 值Hash取模的方式定位 value 在陣列中的下表位置然後存入對應下標中的連結串列中,查詢的時候通過同樣的方式獲取資料。在Jdk1.8 中對這塊做了一個優化,減少雜湊碰撞率。

要了解 HashMap 我們只需要重點關注它的3個API即可,分別是 put,get和 resize。接下來我們跟蹤這3個方法的原始碼分別進行詳細分析。

put值做了些什麼

我們先來看下put方法做了些什麼,put方法的入參就兩個值:key和value,也就是我們經常使用的,其原始碼如下

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

可以看到具體實現不在這裡,裡面有個putVal的方法,所有邏輯處理都在putVal方法中。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    //tab: 即table陣列,n:陣列的長度,i: 雜湊取模後的下標,p: 陣列下標i儲存的連結串列或者紅黑樹首節點,         
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //table陣列為空或長度為0,則呼叫resize方法初始化陣列
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果雜湊取模後對應的陣列下標節點資料為空,則新建立節點,當前k-v為節點中第一條資料 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //雜湊取模後對應下標節點不為空時
    else {
        Node<K,V> e; K k;
        //如果當前的k-v與首節點雜湊值和key都相等,賦值p->e
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //當前節點為紅黑樹,按照紅黑樹的方式新增k-v值
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//到這一步,說明節點型別為連結串列型別,迴圈遍歷連結串列,這裡只是新增新的而不處理同一個元素value的更新
            for (int binCount = 0; ; ++binCount) {
                //節點為尾部節點,當前k-v作為新節點並新增到連結串列尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //當節點數>=8時,則連結串列轉紅黑樹(TREEIFY_THRESHOLD - 1 = 7,binCount從0開始)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //當前遍歷到的節點e的雜湊值和key與k-v的相等則退出迴圈,因為這裡只處理新增
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //當前節點e不為尾結點,將e->p,繼續遍歷    
                p = e;
            }
        }
        //處理更新操作,新值換舊值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            //onlyIfAbsent為false或者舊值為空時,賦新值value
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //空函式,可以由使用者根據需要覆蓋回撥
            afterNodeAccess(e);
            //返回舊值
            return oldValue;
        }
    }
    ++modCount;
    //如果當前map中包含的k-v鍵值數超過了閾值threshold則擴容
    if (++size > threshold)
        resize();
    //空函式,可以由使用者根據需要覆蓋回撥
    afterNodeInsertion(evict);
    return null;
}

閱讀完putVal的原始碼後,我們得到如下一些知識點:

  1. 新值新增到連結串列尾部,如果連結串列長度達到8的時候,連結串列會轉換為紅黑樹,優化了連結串列查詢慢的問題。後續在resize中可以知道長度降到6的時候,紅黑樹會轉為連結串列。
  2. map中k-v鍵值總數超過閾值(threshold)的時候會進行擴容,而 threshold的值是在resize裡面計算的,初始化值為(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)16*0.75=12。從putVal方法中可以看到共有兩次呼叫resize(),分別是初始化和擴容的時候。

putVal方法有5個入參,第一個入參似乎呼叫了一個hash方法傳參是key。我們先看下這個 hash 方法。

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

當key為空時直接返回0,這個我們能看懂。當key不為空時,那一串是啥?幹啥的?它將key的hashCode值和hashCode值右移16位後進行異或運算。為什麼要這樣運算呢?看著有點莫名其妙。

其實這裡就是 Jdk1.8 對定址的優化,這樣做有什麼好處呢?

HashMap中是通過對 key 的雜湊取模後的值定位到陣列的下標位置的,但是hash(key) % length的運算效率很低。在數學中hash(key) & (n - 1)的結果是跟hash(key) % n取模結果是一樣的,但是與運算的效能要比hash對n取模要高很多。因此在原始碼中的tab[i = (n - 1) & hash]就是對陣列長度做雜湊取模運算。

但是這裡雜湊運算沒有直接用 key 的 hashCode 值,而是做了一個右移16位再異或的運算(h = key.hashCode()) ^ (h >>> 16),這樣做的目的又是什麼呢?

物件的 hashCode 是一個 int 型別的整數,假設 key 的 hashCode 值是 h=514287204853,將其轉為二進位制格式

兩者進行異或運算,得到雜湊值hash,注意觀察hash值的特點

h右移16位意味著將高16的值放在了低16位上,高16位補0,這樣處理後再與h進行異或運算得到一個運算後的hash值。

從結果中可以得知,運算後的hash值和原來的hashCode值相比,高16位還是原來h的高16位,而低16位則是原來的高16位和低16的異或後的新值。

將這樣的得到的hash值再與(n-1)進行與運算,n即為陣列的長度,初始值是16,每次擴容的時候,都是2的倍數進行擴容,所以n的值必不會很大。它的高16位基本都為0,只有低16位才會有值。下面以 n=16 為例講解。

由於 (n-1) 的高16位都為0,所以任何和它進行與運算的資料值,運算後的結果index的高16位都不會受影響必為0,只有低16位的結果會受影響。這樣高16位相當於沒什麼作用。

這樣會造成什麼問題呢?如果兩個物件的hashCode值低16位相同而高16位不同,那麼它們運算後的結果必相同,從而導致雜湊衝突,定位到了陣列的同一個下標。

而通過右移16位異或運算後,相當於是將高16位和低16位進行了融合,運算結果的低16位具有了h的高16位和低16位的聯合特徵。這樣可以降低雜湊衝突從而在一定程度上保證了資料的均勻分佈。

看完 putVal 的原始碼後,我們瞭解到了儲存結構和雜湊定址的優化,但是還存在著一些疑惑沒有解開

為什麼要連結串列和紅黑樹的轉換?

連結串列和紅黑樹的轉換是基於時間和空間的權衡,連結串列只有指向下一個節點的指標和資料值,而紅黑樹需要左右指標來分別指向左節點和右節點,TreeNodes 佔用空間是普通 Nodes 的兩倍,因此紅黑樹相較於連結串列需要更多的儲存空間,但是紅黑樹的查詢效率要優於連結串列。

當然這些優勢都是基於資料量的前提下的,只有當容器中的節點數量足夠多的時候才會轉紅黑樹。資料量小的時候兩者查詢效率不會相差很多,但是紅黑樹需要的儲存容量更多,因此需要設定一個轉換的閾值分別是8和6。

那為什麼閾值分別就是8和6呢?

這個HashMap的設計者在原始碼的註釋中給予說明了,其實很多的疑惑都可以從原始碼的閱讀中得到答案

/* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 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
* more: less than 1 in ten million
*/

當理想情況下,即雜湊值離散性很好、雜湊碰撞率很低的時候,資料是均勻分佈在容器的各連結串列中,不會出現資料比較集中的情況,這時候紅黑樹是沒必要的。但是現實中每個物件的雜湊演算法隨機性高,因此就可能導致不均勻的資料分佈。

之所以選擇8是從概率的角度提出的,理想情況下,在隨機雜湊碼演算法下容器中的節點遵循泊松分佈,在Map中一個連結串列長度達到8的概率微乎其微,可以看到8的時候概率是0.00000006,如果這種低概率的事都發生了說明連結串列的長度確實比較長了。至於為什麼不選擇同一個值作為閾值是為了緩衝,可以有效防止連結串列和紅黑樹的頻繁轉換。

如何get值

其實看懂了 putVal 再看 get 獲取值的時候就感覺很簡單了,首先看 get(Object key)

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

這裡不是具體實現,獲取的邏輯在 getNode 方法中,這裡同樣的會呼叫 hash(key)方法,找到 key 對應的陣列下標位置。

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //陣列不為空且陣列長度大於0且定位到的下標位置節點不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //如果當前key儲存在首節點則直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果不是首節點則繼續遍歷
        if ((e = first.next) != null) {
            //如果是紅黑樹結構,則按照紅黑樹的方式獲取值
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //按照連結串列的方式獲取值
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

怎麼resize擴容的

整個 hashMap 就擴容的這塊相對來說是最複雜的了,涉及到資料的遷移和重新定址,程式碼量也比較多,需要點耐心。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果容量已經達到最大值了,這時候也無法擴容了,所以就將閾值也調到最大,然後返回原陣列
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //將容量擴大兩倍,同時閾值也擴大兩倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {//oldCap=0或oldThr=0時,即初始化時的設定               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @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) {
            Node<K,V> e;
            //如果舊的hash桶陣列在j結點處不為空,複製給e
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;//將舊的hash桶陣列在j結點處設定為空,方便gc
                //如果e後面沒有Node結點,意味著當前資料下標處只有一條資料
                if (e.next == null)
                    //將e根據新陣列長度做雜湊取模運算放到新的陣列對應下標中
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //如果e是紅黑樹的型別,那麼按照紅黑樹方式遷移資料,split裡面涉及到紅黑樹轉連結串列
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                    //定義兩個新連結串列lower,higher
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        //將Node結點的next賦值給next
                        next = e.next;
                        //如果結點e的hash值與原陣列的長度作與運算為0,則將它放到新連結串列lower中
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;//將e結點賦值給loHead
                            else
                                loTail.next = e;//否則將e賦值給loTail.next
                            loTail = e;//然後將e複製給loTail
                        }
                        //如果結點e的hash值與原陣列的長度作與運算不為0,則將它放到新連結串列higher中
                        else {
                            if (hiTail == null)
                                hiHead = e;//將e賦值給hiHead
                            else
                                hiTail.next = e;//如果hiTail不為空,將e複製給hiTail.next
                            hiTail = e;//將e複製個hiTail
                        }
                    } while ((e = next) != null);//直到e為空結束迴圈,即連結串列尾部
                    if (loTail != null) {
                        loTail.next = null;//將loTail.next設定為空
                        newTab[j] = loHead;//將loHead賦值給新的hash桶陣列[j]處
                    }
                    if (hiTail != null) {
                        hiTail.next = null;//將hiTail.next賦值為空
                        newTab[j + oldCap] = hiHead;//將hiHead賦值給新的陣列[j+原陣列長度]
                    }
                }
            }
        }
    }
    return newTab;
}

跟著讀完一遍 resize 的程式碼後,可以看到程式碼的前一部分是擴容的程式碼,擴容的邏輯是新陣列的長度是原陣列的2倍,但也不是無限擴容,直到長度超過了最大容量值MAXIMUM_CAPACITY = 1 << 30停止,這時候也不設定閾值了直接指定閾值為threshold = Integer.MAX_VALUE

後一部分為資料遷移的邏輯,通過for迴圈遍歷原陣列,將原陣列的資料遷移到新容器中。分為3種情況處理

  1. 如果原陣列下標處只有一個節點,則將該節點通過對新陣列的長度雜湊運算hash&(newCap - 1)定位到新的下標位置。
  2. 如果原陣列下標處的節點是紅黑樹結構,則呼叫split()方法進行資料遷移,如果資料節點少於6的話,裡面會將紅黑樹轉連結串列。
  3. 如果原陣列下標處的節點是連結串列,則按照連結串列的方式進行資料遷移。

遷移後的資料位置會變化嗎?

紅黑樹和連結串列的資料遷移不是規規矩矩的按照原容器的樣式進行遷移的,它這裡定義了兩個新的節點,連結串列的時候是 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null;,而紅黑樹的時候是TreeNode<K,V> loHead = null, loTail = null;TreeNode<K,V> hiHead = null, hiTail = null;,其中 lo 應是 lower 的縮寫,hi 應是 higher 的縮寫。這樣做的原因按照原始碼中的話就是because we are using power-of-two expansion

所以原陣列某個下標處的節點鏈中的資料遷移的時候會被拆分成兩部分,這裡以連結串列為例來說明,它會將節點的hash和原陣列長度做個與運算(e.hash & oldCap),如果結果為0,則放到連結串列 lower 中,否則放到連結串列higher中。

連結串列 lower 存放的下標在新陣列中不變,即原來是oldTab[4],則新陣列中是newTab[4]。連結串列 higher 會在原下標的基礎上加上原陣列的長度,即原來是oldTab[4],則新陣列中是newTab[4+ oldCap]

最後再說一點,你知道為什麼要擴容嗎?

其實很簡單的原因,這得從陣列的資料結構說起了,我們常說陣列的查詢快,這種說法是基於下標定址來說的,由於陣列中的元素在記憶體中是連續儲存的,當我們定義好陣列的長度後這個陣列就固定了不能再改變它,計算機會在記憶體中分配一整塊連續的空間給它,由於是連續的所以我們知道a[0]的地址,通過加n就知道a[n]的地址了。由於這個特性所以如果原陣列滿了,那麼必須在記憶體中開闢一個新的陣列然後將資料從原陣列中遷移過來。

結束了

HashMap 的原始碼和重點的知識點我們都已經過了一遍,可以看到一個簡單的集合容器內部包含了設計者的豐富思想和技術能力。我們閱讀原始碼既能幫助我們瞭解這個知識點並更好的使用它,又可以從中學習到設計思想以便我們工作中可以借鑑使用,可見閱讀原始碼的重要性。

相關文章