網易面試:JDK1.8將HashMap 頭插法 改 尾插法,爲何?

文章很長,且持續更新,建議收藏起來,慢慢讀!瘋狂創客圈總目錄 博客園版 爲您奉上珍貴的學習資源 :

免費贈送 :《尼恩Java面試寶典》 持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
免費贈送 :《尼恩技術聖經+高併發系列PDF》 ,幫你 實現技術自由,完成職業升級, 薪酬猛漲!加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷1)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷2)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高併發核心編程(卷3)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領

免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取


JDK1.8將HashMap 頭插法 改 尾插法,爲何?

尼恩說在前面

HashMap的工作原理是目前java面試問的較爲常見的問題之一,在40歲老架構師 尼恩的讀者交流羣(50+)中,最近有小夥伴拿到了一線互聯網企業如得物、阿里、滴滴、極兔、有贊、shein 希音、百度、網易的面試資格,遇到很多很重要的面試題:

是否用過Hashmap,hashMap的解決hash碰撞的機制是什麼?

hashMap是如何擴容的?

hashMap的底層數據結構是什麼?

HashMap爲什麼將頭插法,改尾插法?

小夥伴 沒有回答好,導致面試掛了。這個是一個非常常見的面試題,考察的是hashmap的基本功。

如何才能回答得很漂亮,才能 讓面試官刮目相看、口水直流呢?這裏,尼恩給大家做一下系統化、體系化的梳理,讓面試官愛到 “不能自已、口水直流”,然後幫大家 實現 ”offer自由”

當然,這道面試題,以及參考答案,也會收入咱們的 《尼恩Java面試寶典》V175版本PDF集羣,供後面的小夥伴參考,提升大家的 3高 架構、設計、開發水平。

注:本文以 PDF 持續更新,最新尼恩 架構筆記、面試題 的PDF文件,請關注本公衆號【技術自由圈】獲取。

什麼是哈希表

HashMap是Java中的一種基於哈希表實現的,它允許我們使用鍵值對的形式來存儲和獲取數據。

從根本上來說,一個哈希表包含一個數組,但是元素訪問不是通過 index 編號的形式(比如 array[i]的形式),而是通過特殊的關鍵碼(也就是key)來訪問數組中的元素。

哈希表的主要思想是:

  • 存放Value的時候,通過一個哈希函數,通過 關鍵碼(key)進行哈希運算得到哈希值,然後得到 映射到(map到)的位置, 去存放值 ,
  • 讀取Value的時候,也是通過同一個哈希函數,通過 關鍵碼(key)進行哈希運算得到哈希值,然後得到 映射到(map到)的位置,從那個位置去讀取。

非常類似下面的字典圖,如果我們要找 “啊” 這個字,只要根據拼音 “a” 去查找拼音索引,

查找 “a” 在字典中的索引位置 “啊”,這個過程就是哈希函數的作用,用公式來表達就是:f(key),而這樣的函數所建立的表就是哈希表。
img

尼恩提示,哈希表這麼做的優勢:主要是爲了加快了查找key的速度。

在不存在key的衝突場景,時間複雜度爲 O(1),一下就命中。

比起數組和鏈表查找元素時需要遍歷整個集合的情況來說,哈希表明顯方便和效率的多。

硬幣的反面是:尋址容易,插入和刪除困難。

特點:尋址容易,插入和刪除困難。

HashMap主要依賴數組來存儲數據。 哈希表中的每個元素被稱爲“bucket” (桶)。當然,也有叫做槽位(slot)的,反正都是這麼個意思。

叫做槽位的例子,請參見尼恩這篇閱讀量超過2萬的硬核文章

得物面試:爲啥Redis用哈希槽,不用一致性哈希?

在 hashtable的 數組的每個位置(bucket)上,都可以存放一個元素(鍵值對),bucket的定位,通過key的hash函數值取模(具體算法依據hash函數去定)之後去獲得, 這樣,可以O(1)的時間複雜度快速定位到數組的某個位置,取出相應的值,這就是HashMap快速獲取數據的原理。

什麼是hash衝突(/hash碰撞)

哈希表 通過key的hash函數值取模(具體算法依據hash函數去定)之後去獲得 bucket 槽位索引,不同的key值,可能會出現同一個 bucket 槽位,這就是 哈希衝突。

哈希衝突問題,用公式表達就是:

key1 ≠  key2  , f(key1) = f(key2)

以上面的字典圖爲例,那如果字典中有兩個字的拼音相同 (比如),就是 哈希衝突。

一般來說,哈希衝突是無法避免的,如果要完全避免的話,那麼就只能一個key對應一個bucket 槽位索引,也就是一個字就有一個索引 (就是兩個索引),這樣一來,需要大量的內存空間,內存空間就會增大,甚至內存溢出。

那麼,有什麼哈希衝突的解決辦法呢?

常見的哈希衝突解決辦法有兩種:

  • 開放地址法
  • 鏈地址法。

關於 開放地址法, 鏈地址法的詳細介紹,請參考 《尼恩Java面試寶典 》,裏邊非常細緻,這裏不做贅述。

在這裏插入圖片描述

哈希表1.7/哈希表1.8 採用鏈地址法,解決hash碰撞

採用鏈地址法解決hash碰撞的極端情況

哈希表的特性決定了其高效的性能,大多數情況下查找元素的時間複雜度可以達到O(1), 時間主要花在計算hash值上,

然而也有一些極端的情況,最壞的就是hash值全都映射在同一個地址上,這樣哈希表就會退化成鏈表,例如下面的圖片:
img

當hash表變成圖2的情況時,查找元素的時間複雜度會變爲O(n),效率瞬間低下,

所以,設計一個好的哈希表尤其重要,如HashMap在jdk1.8後引入的紅黑樹結構就很好的解決了這種情況。

JDK1.7 中頭插法

採用鏈地址法後,衝突數據使用鏈表管理。 但是數據插入鏈表的時候,有兩種方式:

  • 頭插
  • 尾插

在 JDK1.7 中HashMap採用的是頭插法,就是在鏈表的頭部插入,新插入的 slot槽位數據保存在鏈表的頭部。

比如插入同一個 槽位的三個 key A B C 之後, 示意圖如下。

在這裏插入圖片描述

在 JDK1.7 中HashMap採用的是頭插法,大致的源碼如下:

//newTable表示新創建的擴容後的數組
//rehash表示元素是否需要重新計算哈希值
void transfer(Entry[] newTable, boolean rehash) {
    
    //記錄新數組的容量
    int newCapacity = newTable.length;
    
    //遍歷原數組的桶位置
    for (Entry<K,V> e : table) {
        
        //如果桶位置不爲空,則遍歷鏈表的元素
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;
            
            //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;
            
            //遍歷原數組鏈表的下一個元素
            e = next;
        }
    }
}

JDK1.7的底層數據結構

JDK1.7的底層數據結構 包括一個 槽位數組 table, 每個桶中的元素都需要一個單獨的Entry, 用於存儲衝突鏈表的頭。

/**
 * An empty table instance to share when the table is not inflated.
 */
static final Entry<?,?>[] EMPTY_TABLE = {};

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    ...
}
在这里插入图片描述

JDK1.7之前(1.8之前)使用頭插法的好處

使用頭插法的好處,40歲老架構師尼恩,給大家總結出如下幾點:

  • 效率高

    • 擴容的時候,插入在頭部,效率高一些,時間複雜度爲O(1)
    • 但如果插入尾部,都要遍歷到最後一個節點,時間複雜度爲O(N)
  • 滿足時間局部性原理

    根據時間局部性原理,最近插入的最有可能被使用

JDK1.7頭插法導致的在擴容場景導致惡性死循環的問題

來看看hashmap的擴容。

回顧一下hashmap的內部結構。HashMap底層存儲的數據結構如下:
在JDK1.7及前

  • 數組
  • 鏈表

在JDK1.8後

  • 數組
  • 鏈表 -當鏈表的長度臨近於8時,轉爲紅黑樹
  • 紅黑樹

一般情況下,當元素數量超過閾值時便會觸發擴容。每次擴容的容量都是之前容量的 2 倍。

HashMap 初始容量默認爲16。如果寫了初始容量,如果寫的爲11,他其實初始化的並不是11,而是取2n ,取與11最相近的那個值,必須大於等於11,所以爲16。

但是HashMap 的容量是有上限的,必須小於 1<<30,即 1073741824。如果容量超出了 1<<30,即 1073741824這個數,則不再增長,且閾值會被設置爲 Integer.MAX_VALUE。

JDK7 中的擴容機制

  • 空參構造函數:以默認容量、默認負載因子、默認閾值初始化數組。內部數組是空數組。
  • 有參構造函數:根據參數確定容量、負載因子、閾值等。第一次 put 時會初始化數組,其容量變爲不小於指定容量的 2 的冪數,然後根據負載因子確定閾值。
  • 如果不是第一次擴容,則 新容量=舊容量 x 2 ,新閾值=新容量 x 負載因子

JDK8 的擴容機制

  • 空參構造函數:實例化的 HashMap 默認內部數組是 null,即沒有實例化。第一次調用 put 方法時,則會開始第一次初始化擴容,長度爲 16。

  • 有參構造函數:用於指定容量。會根據指定的正整數找到不小於指定容量的 2 的冪數,將這個數設置賦值給閾值(threshold)。第一次調用 put 方法時,會將閾值賦值給容量,然後讓 閾值 = 容量 x 負載因子

  • 如果不是第一次擴容,則容量變爲原來的 2 倍,閾值也變爲原來的 2 倍。(容量和閾值都變爲原來的 2 倍時,負載因子還是不變)。

此外還有幾個細節需要注意:

  • 首次 put 時,先會觸發擴容(算是初始化),然後存入數據,然後判斷是否需要擴容;
  • 不是首次 put,則不再初始化,直接存入數據,然後判斷是否需要擴容;

JDK1.7中(JDK1.8之前)HashMap觸發擴容機制時,會創建新的Entry[ ]數組,將舊的Entry數據進行復制.

這就是頭插法數據。當某個entry上具有鏈式結構時,採用頭插方式進行數據遷移,即將舊鏈表數據從頭部遍歷,每次取到的數據,插入到重新散列到的slot槽位的新鏈表的頭部。

JDK1.7中HashMap的插入方法採用的是頭插法,即新插入的元素會插入到鏈表的頭部。

這樣會產生以下問題:

  1. 破壞了鏈表元素的插入順序,鏈表的順序被反轉:由於頭插法是將新插入的元素插入到鏈表的頭部,這樣就導致鏈表的順序與元素插入的順序相反,不利於一些需要按照插入順序遍歷的場景。
  2. 容易引起鏈表環形問題:是因爲多個線程併發擴容時,因爲一個線程先期完成了擴容,將原 Map 的鏈表重新散列到自己的表中,並且鏈表變成了倒序,後一個線程再擴容時,又進行二次散列,再次將倒序鏈表變爲正序鏈表。這個過程中會造成鏈表尾部丟失,形成環形鏈表,從而開始死循環、甚至CPU 100%的噩夢。

現象1,鏈表的順序被反轉:

頭插法擴容之後, 假設原來的元素重新hash還在同一個槽位(這是假設,大概率不是統一槽位),同一個鏈表上的元素順序與元素插入的順序變了,變反了。

在這裏插入圖片描述

當然,下面這個死循環,纔是致命的問題,會導致CPU 100%, 程序直接廢了。

這個是兩個大的問題:

  • 導致的死循環
  • CPU 100%, 程序直接廢了

頭插法,在擴容時導致的死循環

由於頭插法需要修改鏈表頭, JDK1.7 頭插法,在擴容時導致的死循環

先擴容的代碼

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //創建一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //將Old Hash Table上的數據遷移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

遷移的代碼

//newTable表示新創建的擴容後的數組
//rehash表示元素是否需要重新計算哈希值
void transfer(Entry[] newTable, boolean rehash) {
    
    //記錄新數組的容量
    int newCapacity = newTable.length;
    
    //遍歷原數組的桶位置
    for (Entry<K,V> e : table) {
        
        //如果桶位置不爲空,則遍歷鏈表的元素
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;
            
            //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;
            
            //遍歷原數組鏈表的下一個元素
            e = next;
        }
    }
}

JDK1.7鏈表頭插法擴容step1

假設兩個線程thread 1、thread2 進行重新,並且,都執行到了transfer 方法的 if (rehash) {...} 之前,

此時,thread 1、thread2 都確定了e和next,如下圖所示:

在這裏插入圖片描述

JDK1.7鏈表頭插法擴容step2

假設,此時thread 2線程的時間片沒了,被操作系統掛起來了

只有thread 2 線程可以向下執行,一個人把活兒幹完了,得到可擴容後的 大致結果,如下圖:

在這裏插入圖片描述

注意,這個的元素次序已經倒過來了。 如果step1 是正序的話,這裏是倒序了。

JDK1.7鏈表頭插法擴容step3

thread 2線程的時間片又有了,繼續執行

   //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;
            
            //遍歷原數組鏈表的下一個元素
            e = next;

頭插方式執行,這個時候,危險就悄悄來了

我們把圖畫細緻一點, 聚焦到 這個虛擬的 鏈表,如下圖:

在這裏插入圖片描述

咱們的線程,開始執行三面的三句核心的語句:

           
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;
            
            //遍歷原數組鏈表的下一個元素
            e = next;

先調整 e.next指針,指向 鏈表頭部的newTable[i] 也就是 A,如下圖

在這裏插入圖片描述

實際上,這個時候,鏈表已經變成了 死循環鏈表了。 鏈表的 尾部節點已經丟失,形成了環形鏈表。

在這裏插入圖片描述

這裏提示一下,尼恩爲啥寫這個文章呢,發現網上很多小夥寫博客,解釋尾插法的時候, 沒有解釋清楚。

看了好幾篇文章,沒有一篇講清楚了的, 而且那些文章的點擊量還很高。

而且網上抄來抄去的,畫很多很複雜的圖,來解釋一個不明確的、不清晰的答案。

40歲老架構師尼恩,給大家來一個簡單明瞭的答案: 就是因爲 倒序+ 正序 這種亂序的插入,導致了 尾部的丟失,從而形成了環形鏈表。

在這裏插入圖片描述

尼恩的宗旨,就是用深厚的內功,把複雜知識簡單化,幫助大家成爲技術高手。

OK,咱們繼續正文。

形成了環形鏈表之後, 由於後面的risize 是用null != e 作爲條件 去終止內部循環的,大家思考一下,這個循環還有終止的可能嗎?

來看看代碼,具體如下:

  //遍歷原數組的桶位置
    for (Entry<K,V> e : table) {
        
        //如果桶位置不爲空,則遍歷鏈表的元素
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;
           
           //...省略 插入頭部
           
           //遍歷原數組鏈表的下一個元素
            e = next;
        }
    }

上面的鏈表, 唯一的一個next =null的元素 C, 他的next值,也就不爲空了,

那麼 這個risize的循環,從此,也就永遠出不來了。

另外,如果這個時候,來一個線程去get 元素,如果沒有找到對應的key,也會死循環。

不行,咱們走着瞧,

咱們的擴容step3 還沒有結束,第三步結束之後:

在這裏插入圖片描述

這一步,C處理完了,到了頭部,

但是下一個要處理的,是之前歷史資產B, 而不是 A。


        
        //如果桶位置不爲空,則遍歷鏈表的元素 
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;   //step3  e=C  next=B
            
            //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];  // c.next=a,空指針丟失, 循環噩夢開始
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;   //c到頭部
            
            //遍歷原數組鏈表的下一個元素
            e = next;  //e=b
        }

JDK1.7鏈表頭插法擴容step4

下面正式開始無限插入循環的噩夢。

第三步之後的第4步, e變成了B, 進入下一輪循環後, next=C

執行過程大致如下

        
        //如果桶位置不爲空,則遍歷鏈表的元素 
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;   //step4  e=B  next=C
            
            //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];  // B.next=B,自己指向自己, 已經徹底短鏈了,徹底失控了
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] = e;   //B還是到頭部
            
            //遍歷原數組鏈表的下一個元素
            e = next;  //e=C
        }

執行之後的結果如下,已經徹底失控了:

在這裏插入圖片描述

JDK1.7鏈表頭插法擴容step5

下面正式開始無限插入循環的噩夢。

第三步之後的第5步, e變成了C, 進入下一輪循環後, next=A

執行過程大致如下

        
        //如果桶位置不爲空,則遍歷鏈表的元素 
        while(null != e) {
            
            //next表示原數組鏈表的下一個節點
            Entry<K,V> next = e.next;   //step4  e=C  next=A
            
            //確定元素在新數組的索引位置
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            
            //頭插法,新元素始終指向新數組對應桶位置的第一個元素
            e.next = newTable[i];  // C.next=B,
            
            //新插入的元素成爲桶位置的第一個元素
            newTable[i] =e;   //C到了頭部
            
            //遍歷原數組鏈表的下一個元素
            e = next;  //e=A
        }

執行之後的結果如下,已經徹底失控了:

在這裏插入圖片描述

其實已經沒有推演下去的意義了,自從丟失空指針null之後, 這個已經是循環鏈表了。

循環永遠出不來了。

另外,如果來一個,來一個線程去get 元素,如果沒有找到對應的key,也會死循環。

如何破解環形鏈表

40歲老架構師尼恩,前面給大家來一個簡單明瞭的答案: 就是因爲 倒序+ 正序 這種亂序的插入,導致了 尾部的丟失,從而形成了環形鏈表。

這就像 負負得正 一樣的。 第一次頭插是倒序, 第二次 頭插是 正序, 第三次頭插是倒序, 第四次 頭插是 正序。 而只要 倒序+ 正序 一組合,就會丟掉尾部的 空指針。

如何破解, 很簡單, 保證插入的次序一致就OK了。

如果保證每一次插入的次序一致呢? 採用 尾插法。 新的Entry都插入到尾部, 並且新的尾部的 Entry.next 爲空,這樣做有兩個結果:

  • 永遠是正序
  • 永遠有尾部

結論是,不會產生環形鏈表。 當然也破解了頭插法導致的 死循環和CPU 100%的問題。

那麼,使用微插法的不足是啥?40歲老架構師尼恩,給大家總結出如下幾點:

  • 擴容的效率低些

    擴容的時候,插入在尾部,效率高一些,時間複雜度爲O(N)

  • 不滿足時間局部性原理

    根據時間局部性原理,最近插入的最有可能被使用,這時候已經插入到尾部去了,要找到尾部才能找得到。

那麼 JDK1.8的底層,用的就是尾插。

JDK1.8的底層數據結構

每個桶中的元素使用Node保存,使用TreeNode支持紅黑樹,並且Node中的hash屬性使用final修飾,一旦確定將不可變。

首先來看看,一個JDK 1.8版本ConcurrentHashMap(HashMap的併發版本)實例的內部結構,示例如圖7-16所示。

一個JDK 1.8 版本ConcurrentHashMap實例的內部結構

圖7-16 一個JDK 1.8 版本ConcurrentHashMap實例的內部結構

以上的內容,來自 尼恩的 《Java 高併發核心編程 卷2 加強版》,尼恩的高併發三部曲,很多小夥伴反饋說:相見恨晚,愛不釋手。

關於table 數組的解釋:

table 數組在第一次往HashMap中put元素的時候初始化,如果HashMap初始化的時候沒有指定容量,那麼初始化table的時候會使用默認的DEFAULT_INITIAL_CAPACITY參數,也就是16,作爲table初始化時的長度。

如果HashMap初始化的時候指定了容量,HashMap會把這個容量修改爲2的倍數,然後創建對應長度的table。因爲table在HashMap擴容的時候,長度會翻倍。所以table的長度肯定是2的倍數。

請注意:如果要往HashMap中放1000個元素,又不想讓HashMap不停的擴容,最好一開始就把容量設爲2048,設爲1024不行,因爲元素添加到769的時候還是會擴容。

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // key的hash值
    final K key; // 節點的key,類型和定義HashMap時的key相同
    V value; // 節點的value,類型和定義HashMap時的value相同
    Node<K,V> next; // 該節點的下一節點
    ...
}

/**
 * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
 * extends Node) so can be used as extension of either regular or
 * linked node.
 */
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    ...
} 


/**
 * Returns a power of two size for the given target capacity.
 */    
static final int tableSizeFor(int cap) {
     int n = cap - 1;
     n |= n >>> 1;
     n |= n >>> 2;
     n |= n >>> 4;
     n |= n >>> 8;
     n |= n >>> 16;
     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}


在这里插入图片描述

底層基礎數據結構:數組 + 鏈表(哈希表或散列表),在1.8中爲了提升元素獲取速度,引進了紅黑樹,用以解決衝突時性能問題。

當數組鏈表變得過長時,HashMap會將鏈表轉爲紅黑樹,以此來提高元素的查找、插入和刪除操作的性能。

理解 JDK1.8的HashMap擴容原理

擴容(resize)就是重新計算容量,進行擴大數組容量,以便裝入更多的元素。

向hashMap不停的添加元素,當hashMap無法裝載新的元素,對象將需要擴大數組容量,以便裝入更多的元素。

擴容臨界值計算公式:threadshold = loadFactory * capacity。loadFactory 負載因子的默認值是0.75,capacity容量大小默認是16。

也就是說,第1次擴容的動作會在元素個數達到12的時候觸發,擴容的大小是原來的2倍。

HashMap的最大容量是Integer.MAX_VALUE也就是2的31次方減1。

注意:以下擴容原理講解基於JDK1.8

理解 JDK1.8的HashMap的 擴容

創建一個新的的Entry空數組,長度是原數組的2倍。

Node<K,V> loHead = null,  loTail = null;   //低位鏈表的頭尾結點
Node<K,V> hiHead = null, hiTail = null;    //高位鏈表的頭尾結點
Node<K,V> next;  //next指針 指向下一個元素

理解 JDK1.8的HashMap的遷移

遍歷原Entry數組,把所有的Entry重新Hash(遷移)到新數組。

由於擴容之後,數組長度變大,hash的規則也會隨之改變,所以需要重新hash。

  • 擴容前,臨界監測: 這裏將其設置爲長度爲 8(用8舉例主要是爲了畫圖 ,hashMap默認容量是16),擴容臨界點 8 * 0.75 = 6

  • 數組擴容:長度達到 臨界點後開始擴容,擴容後開始遷移。

  • 擴容後,遷移數據:重新計算元素的hashCode,並存儲到相應位置。

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) {
            //如果舊容量大於等於了最大的容量 2^30
            if (oldCap >= MAXIMUM_CAPACITY) {
               //將臨界值設置爲Integer.MAX_VALUE
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //擴容2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 新閾值設置2倍
        }
        else if (oldThr > 0) // HashMap(int initialCapacity, float loadFactor)調用
            newCap = oldThr;
        else {               // 第一次put操作的時候,因爲jdk1.8hashMap先添加元素再擴容
        //構造函數將jdk1.7的擴容移動到這
                    newCap = DEFAULT_INITIAL_CAPACITY; //默認容量 16
                    //臨界值 16 *0.75 =12
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 
        }
        if (newThr == 0) {//如果新閾值爲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;
                if ((e = oldTab[j]) != null) { 
                    oldTab[j] = null; //gc處理
                    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 { // preserve order
                        Node<K,V> loHead = null, loTail = null; //低位鏈表
                        Node<K,V> hiHead = null, hiTail = null;  //高位鏈表
                        Node<K,V> next;
                        do {
                            next = e.next; //指向下個元素結點,做爲while循環的條件
                            if ((e.hash & oldCap) == 0) { //判斷是否爲低位鏈表
                                if (loTail == null)  //鏈表沒有元素,則將該元素作爲頭結點
                                    loHead = e;
                                else
                                    loTail.next = e; //加在鏈表的下方
                                loTail = e;
                            }
                            else { {//不爲0,元素位置在擴容後數組中的位置發生了改變,新的下
                                //標位置是(原下標位置+原數組長)
                                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;
    }


JDK1.8的HashMap的總結

  1. 擴容就是將舊錶的數據遷移到新表。

  2. 遷移過去的值需要重新計算hashCode,也就是他的存儲位置。

  3. 關於位置可以這樣理解:比如舊錶的長度8、新表長度16
    舊錶位置4有6個數據,假如前三個hashCode是一樣的,後面的三個hashCode是一樣的遷移的時候;就需要計算這6個值的存儲位置。

  4. 如何計算位置?採用低位鏈表和高位鏈表;

    如果位置4下面的數據e.hash & oldCap等於0,那麼它對應的就是低位鏈表,也就是數據位置不變,

    e.hash & oldCap不等於0就要重寫計算他的位置,也就是j + oldCap(4+8);這個12,就是高位鏈表位置(新數組12位置)。

JDK1.8鏈表尾插法性能優化

前面講到 , 尾插法在擴容場景進行數據遷移的時候時間複雜度爲O(N),頭插法在擴容場景進行數據遷移的時候時間複雜度爲 O(1)。 但是這個僅僅是理論。

JDK1.8鏈表除了採用爲尾插之外,做了很多性能優化,比如:使用MurmurHash算法提高哈希算法的效率,減少了某些鏈表長度過長的情況減少遍歷的次數等等。

總之針對JDK1.7中死循環問題,將HashMap的插入方法改爲了尾插法,即新插入的元素會插入到鏈表的尾部,這樣可以解決很多問題並且有以下優點:

  1. 避免鏈表環形問題:尾插法是將新插入的元素插入到鏈表的尾部,不需要修改鏈表頭,因此可以避免在併發環境下多個線程修改鏈表頭導致的鏈表環形問題。
  2. 提高哈希算法的效率:Java8使用的是MurmurHash算法,該算法具有良好的隨機性和分佈性,能夠有效地降低哈希衝突的概率,從而提高HashMap的性能。
  3. 提高查詢效率:尾插法使得鏈表元素的插入順序與元素插入的順序一致,從而方便了元素的查找和遍歷操作,提高了HashMap的查詢效率。
  4. 提高鏈表長度的平衡:尾插法可以使得鏈表長度比較平衡,減少了某些鏈表長度過長的情況,從而提高了HashMap的性能。

頭插法和尾插法的性能對比

我們可以通過一個簡單的例子來說明Java8中HashMap插入方法的改變對性能的影響。

假設有一個HashMap,包含10000個元素,現在需要將一個新元素插入到其中。

爲了測試插入操作的性能,我們分別使用Java8之前的版本和Java8及以後的版本實現插入操作,並記錄每次插入的時間。

具體代碼實現如下:

HashMap<Integer, String> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
    map.put(i, "value" + i);
}
long startTime = System.currentTimeMillis();
map.put(10000, "new value");
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");

運行多次後,我們可以得到平均插入時間的數據。

假設運行10次,得到的數據如下:

Java8之前的版本 Java8及以後的版本
4ms 3ms
3ms 2ms
4ms 3ms
4ms 2ms
3ms 3ms
4ms 2ms
3ms 3ms
4ms 2ms
3ms 3ms
4ms 2ms
平均值:3.6ms 平均值:2.6ms

從上表可以看出,Java8及以後的版本插入操作的平均時間要比Java8之前的版本快,差距在1ms左右,

這是由於Java8將HashMap的插入方法改爲了尾插法,避免了鏈表環形問題的發生,同時優化了哈希算法和查詢效率,從而提高了HashMap的性能。

尾插法和頭插法總結

在併發編程中使用HashMap可能會導致死循環,而使用線程安全的HashTable效率又低下。

HashMap 之所以在併發下的擴容造成死循環,是因爲多個線程併發進行時,因爲一個線程先期完成了擴容,將原 Map 的鏈表重新散列到自己的表中,並且鏈表變成了倒序,後一個線程再擴容時,又進行自己的散列,再次將倒序鏈表變爲正序鏈表。

於是形成了一個環形鏈表,兩種場景造成死循環:

  • 在擴容場景後面的元素移動過程中,造成死循環。

  • 由於環形鏈表的存在,在後面 get 表中不存在的元素時,也造成死循環。

在Java1.5中,併發編程大師Doug Lea給我們帶來了concurrent包,而該包中提供的ConcurrentHashMap是線程安全並且高效的HashMap。 ConcurrentHashMap使用鎖保證了擴容的獨佔性,於是在多線程併發處理下,解決了HashMap在擴容到時候造成鏈表形成環形結構的問題。

所以,如果存在併發擴容場景,需要使用 ConcurrentHashMap。 關於ConcurrentHashMap也有大量的面試難題和真題,具體請參見尼恩的 5000頁+《尼恩Java面試寶典》。

說在最後

HashMap相關的面試題,是非常常見的面試題。

以上的內容,如果大家能對答如流,如數家珍,基本上 面試官會被你 震驚到、吸引到。最終,讓面試官愛到 “不能自已、口水直流”。offer, 也就來了。

在面試之前,建議大家系統化的刷一波 5000頁《尼恩Java面試寶典》V174,在刷題過程中,如果有啥問題,大家可以來 找 40歲老架構師尼恩交流。

另外,如果沒有面試機會,可以找尼恩來幫扶、領路。

尼恩已經指導了大量的就業困難的小夥伴上岸,前段時間,幫助一個40歲+就業困難小夥伴拿到了一個年薪100W的offer,小夥伴實現了 逆天改命 。

技術自由的實現路徑:

實現你的 架構自由:

喫透8圖1模板,人人可以做架構

10Wqps評論中臺,如何架構?B站是這麼做的!!!

阿里二面:千萬級、億級數據,如何性能優化? 教科書級 答案來了

峯值21WQps、億級DAU,小遊戲《羊了個羊》是怎麼架構的?

100億級訂單怎麼調度,來一個大廠的極品方案

2個大廠 100億級 超大流量 紅包 架構方案

… 更多架構文章,正在添加中

實現你的 響應式 自由:

響應式聖經:10W字,實現Spring響應式編程自由

這是老版本 《Flux、Mono、Reactor 實戰(史上最全)

實現你的 spring cloud 自由:

Spring cloud Alibaba 學習聖經》 PDF

分庫分表 Sharding-JDBC 底層原理、核心實戰(史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之間混亂關係(史上最全)

實現你的 linux 自由:

Linux命令大全:2W多字,一次實現Linux自由

實現你的 網絡 自由:

TCP協議詳解 (史上最全)

網絡三張表:ARP表, MAC表, 路由表,實現你的網絡自由!!

實現你的 分佈式鎖 自由:

Redis分佈式鎖(圖解 - 秒懂 - 史上最全)

Zookeeper 分佈式鎖 - 圖解 - 秒懂

實現你的 王者組件 自由:

隊列之王: Disruptor 原理、架構、源碼 一文穿透

緩存之王:Caffeine 源碼、架構、原理(史上最全,10W字 超級長文)

緩存之王:Caffeine 的使用(史上最全)

Java Agent 探針、字節碼增強 ByteBuddy(史上最全)

實現你的 面試題 自由:

4800頁《尼恩Java面試寶典 》 40個專題

免費獲取11個技術聖經PDF:

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