Java進階:HashMap底層原理(通俗易懂篇)

救苦救难韩天尊發表於2024-07-05

1.底層結構

Java 7及之前版本

在Java 7及之前的版本中,HashMap的底層資料結構主要是陣列加連結串列。具體實現如下:

  1. 陣列:HashMap的核心是一個Entry陣列(Entry<K,V>[] table),這個陣列的大小總是2的冪。每個陣列元素是一個單一的Entry節點,或者是一個連結串列的頭節點。

  1. 連結串列:當兩個或更多的鍵經過雜湊運算後對映到陣列的同一個索引上時,就會形成連結串列。Entry節點包含了鍵值對以及指向下一個Entry的引用,以此來解決雜湊衝突。

Java 8及之後版本

從Java 8開始,HashMap的底層結構除了陣列加連結串列之外,還引入了紅黑樹,以最佳化在連結串列過長時的查詢效能。結構變為陣列加連結串列加紅黑樹

  1. 陣列:同樣是一個Entry陣列(Java 8中稱為Node),大小仍然是2的冪,用於快速定位。
  2. 連結串列:在雜湊衝突時,鍵值對仍會以連結串列形式連結在一起。但與Java 7不同的是,Java 8對連結串列的處理增加了轉換為紅黑樹的條件。
  3. 紅黑樹:當一個桶中的連結串列長度超過8且HashMap的容量大於64時(不大於64會先對陣列進行擴容resize()),連結串列會被轉換成紅黑樹。這種轉換提高了在大量雜湊衝突情況下的查詢效率,因為紅黑樹的查詢時間複雜度為O(log n),相較於連結串列的O(n)有顯著提升。

2.資料插入

在JDK1.7的時候,採用的是頭插法

在JDK1.8改成了尾插法,這是因為頭插法在多執行緒環境下擴容時可能會產生迴圈連結串列問題

執行緒不安全

無論是JDK1.7還是1.8都是執行緒不安全的,下圖是1.8中的put方法

tab是就是HashMap的陣列table,第一個if判斷時做了賦值。框起來的部分表示如果沒有hash衝突就直接在陣列上插入元素,但是如果兩個執行緒hash一樣且都進入到了這個if下,執行緒1先執行的插入資料,執行緒2會覆蓋1插入的資料。

那麼什麼是迴圈連結串列問題呢?這就不得不介紹一下HashMap的擴容機制了。

3.擴容機制

首先講幾個HashMap的屬性

  • table:陣列,存放連結串列或紅黑樹的頭節點
  • Node:節點,屬性有hash、key、value、next(下一個節點)
  • size:元素個數,即節點node個數,非陣列長度
  • Capacity:當前陣列長度
  • loadFactor:載入因子,預設為0.75f,簡稱loadFactor
  • threshold:擴容門檻,值為capacity*loadFactor,size達到這個門檻就擴容

當size大於threshold時,就會進行擴容resize()

擴容分為兩步

  1. 建立一個新的陣列,長度為原陣列的兩倍
  2. 遍歷所有Node節點,重新計算index值(Java8首先會重新計算hash值),放到新陣列裡,存在hash衝突的就放到連結串列或紅黑樹

為什麼要重新計算index值,直接把張三這條連結串列複製到新的陣列中不行嗎?

答案是不行的,因為index規則是根據陣列長度來的,如圖

所以index 的計算公式是這樣的:

  • index = HashCode(key) & (Length - 1)

4.迴圈連結串列問題

迴圈連結串列問題理解起來比較麻煩,如果理解不了就知道JDK1.7HashMap擴容的時候有這麼回事就行。但是可能是我自己太笨了萬一大家一看就懂了呢

我們來看一下Java1.7擴容的原始碼

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                //重新計算元素在陣列中的索引
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

重點在於transfer方法,作用是重新計算index值然後將舊陣列中的資料遷移到新陣列

迴圈連結串列的產生:

原因:

假設我們有兩個Thread都在執行resize方法,Thread1第一步剛執行完第23行Entry<K,V> next = e.next;就卡住了,這時Thread2執行完了resize方法。

過程:

  1. Thread1第一次執行完Entry<K,V> next = e.next後,e=張三,next=李四,也就是說第二步執行李四的插入
  2. Thread2執行完resize後,李四的next變成了張三
  3. 此時又回到Thread1,第二次執行Entry<K,V> next = e.next後,e=李四,next=張三,也就是說又要執行張三的插入,迴圈連結串列產生!

由此我們知道了迴圈連結串列產生的關鍵在於頭部插入元素A時,元素A的next元素B的next是元素A本身,所以Java8採用了尾插法,避免了迴圈連結串列。

求贊!求關注!!以後會更新更多有用的內容!!!꒰⑅•ᴗ•⑅꒱

保佑大家程式碼永無bug ╰(´︶`)╯

相關文章