金三銀四助力面試-手把手輕鬆讀懂HashMap源碼

前言

HashMap 對每個學習 Java 的人來講熟悉的不能再熟悉了,然而就是這麼一個熟悉的東西,真正深刻到源碼層面卻有許多值的學習和思考的地方,如今就讓咱們一塊兒來探索一下 HashMap 的源碼。算法

HashMap 源碼分析

HashMap 基於哈希表,且實現了 Map 接口的一種 key-value 鍵值對存儲數據結構,其中的 keyvalue 均容許 null 值,在 HashMap 中,不保證順序,線程也不安全。數組

HashMap 中的數據存儲

HashMap 中,每次 put 操做都會對 key 進行哈希運算,根據哈希運算的結果真後再通過位運算獲得一個指定範圍內的下標,這個下標就是當前 key 值存放的位置,既然是哈希運算,那麼確定就會有哈希衝突,對此在 jdk1.7 及其以前的版本,每一個下標存放的是一個鏈表,而在 jdk1.8 版本及其以後的版本,則對此進行了優化,當鏈表長度大於 8 時,會轉化爲紅黑樹存儲,當紅黑樹中元素從大於等於 8 下降爲 小於等於 6 時,又會從紅黑樹從新退化爲鏈表進行存儲。安全

下圖就是 jdk1.8 中一個 HashMap 的存儲結構示意圖(每個下標的位置稱之爲 ):數據結構

HashMap 中是經過數組 + 鏈表 + 紅黑樹來實現數據的存儲(jdk1.8),而在更早的版本中則僅僅採用了數組 + 鏈表的方式來進行存儲。源碼分析

爲何建議初始化 HashMap 時指定大小

HashMap 初始化的時候,咱們一般都會建議預估一下可能大小,而後在構造 HashMap 對象的時候指定容量,這是爲何呢?要回答這個問題就讓咱們看一下 HashMap 是如何初始化的。性能

下圖就是當咱們不指定任何參數時建立的 HashMap 對象:學習

負載因子

能夠看到有一個默認的 DEFAULT_LOAD_FACTOR(負載因子),這個值默認是 0.75。負載因子的做用就是當 HashMap 中使用的容量達到總容量大小的 0.75 時,就會進行自動擴容。而後從上面的源碼能夠看到,當咱們不指定大小時,HashMap 並不會初始化一個容量,那麼咱們就能夠大膽的猜想當咱們調用 put 方法時確定會有判斷當前 HashMap 是否被初始化,若是沒有初始化,就會先進行初始化。優化

HashMap 默認容量

put 方法中會判斷當前 HashMap 是否被初始化,若是沒有被初始化,則會調用 resize 方法進行初始化,resize 方法不只僅用於初始化,也用於擴容,其中初始化部分主要是下圖中紅框部分:線程

能夠看到,初始化 HashMap 時,主要是初始化了 2 個變量:一個是 newCap,表示當前 HashMap 中有多少個桶,數組的一個下標表示一個桶;一個是 newThr,主要是用於表示擴容的閾值,由於任什麼時候候咱們都不可能等到桶所有用完了纔去擴容,因此要設置一個閾值,達到閾值後就開始擴容,這個閾值就是總容量乘以負載因子獲得。code

上面咱們知道了默認的負載因子是 0.75,而默認的桶大小是 16,因此也就是當咱們初始化 HashMap 而不指定大小時,當桶使用了 12 時就會自動擴容(如何擴容咱們在後面分析)。擴容就會涉及到舊數據遷移,比較消耗性能,因此在能預估出 HashMap 須要存儲的總元素時,咱們建議是提早指定 HashMap 的容量大小,以免擴容操做。

PS:須要注意的是,通常咱們說 HashMap 中的容量都是指的有多少個桶,而每一個桶能放多少個元素取決於內存,因此並非說容量爲 16 就只能存放 16key 值。

HashMap 最大容量是多少

當咱們手動指定容量初始化 HashMap 時,老是會調用下面的方法進行初始化:

看到 453 行,當咱們指定的容量大於 MAXIMUM_CAPACITY 時,會被賦值爲 MAXIMUM_CAPACITY,而這個 MAXIMUM_CAPACITY 又是多少呢?

上圖中咱們看到,MAXIMUM_CAPACITY 是 2 的 30 次方,而 int 的範圍是 2 的 31 次方減 1,這豈不是把範圍給縮小了嗎?看上面的註釋能夠知道,這裏要保證 HashMap 的容量是 2 的 N 次冪,而 int 類型的最大正數範圍是達不到 2 的 31 次冪的,因此就取了2 的 30 次冪。

咱們再回到前面的帶有參數的構造器,最後調用了一個 tableSizeFor 方法,這個方法的做用就是調整 HashMap 的容量大小:

這個方法若是你們不瞭解位運算,可能就會看不太明白這個究竟是作什麼操做,其實這個裏面就作了一件事,那就是把咱們傳進來的指定容量大小調整爲 2 的 N 次冪,因此在最開始要把咱們傳進去的容量減 1,就是爲了統一調整。

咱們來舉一個簡單的例子來解釋一下上面的方法,位運算就涉及到二進制,因此假如咱們傳進來的容量是一個 5,那麼轉化爲二進制就是 0000 0000 0000 0000 0000 0000 0000 0101,這時候咱們要保證這個數是 2 的 N 次冪,那麼最簡單的辦法就是把咱們當前二進制數的最左邊的 1 開始,一直到最右邊,全部的位都是 1,那麼獲得的結果就是獲得對應的 2 的 N 次冪減 1,好比咱們傳的 5 進來,要確保是 2 的 N 次冪,那麼確定是須要調整爲 2 的 3 次 冪,即:8,這時候我麼須要作的就是把後面的 3101 調整爲 111 ,就能夠獲得 2 的 3 次冪減 1,最後的總容量再加上 1 就能夠調整成爲 2 的 N 次冪。

仍是以 5 爲例,無符號右移 1 位,獲得 0000 0000 0000 0000 0000 0000 0000 0010,而後再與原值 0000 0000 0000 0000 0000 0000 0000 0101 執行 | 操做(只有兩邊同時爲 0,結果纔會爲 0),就能夠獲得結果 0000 0000 0000 0000 0000 0000 0000 0111,也就是把第二位變成了 1,這時候不論再右移多少位,右移多少次,結果都不會變,保證了後三位爲 1,然後面還要依次右移,是爲了確保當一個數的第 31 位爲 1 時,能夠保證除了最高位以外的 31 位所有爲 1

到這裏,你們應該就會有疑問了,爲何要如此大費周章的來保證 HashMap 的容量,即桶的個數爲 2 的 N 次冪呢?

爲何 HashMap 容量大小要爲 2 的 N 次冪

之因此要確保 HashMap 的容量爲 2 的 N 次冪的緣由其實很簡單,就是爲了儘量避免哈希分佈不均勻而致使每一個桶中分佈的數據不均勻,從而出現某些桶中元素過多,影響到查詢效率。

咱們繼續看一下 put 方法,下圖中紅框部分就是計算下標位置的算法,就是經過當前數組(HashMap 底層是採用了一個 Node 數組來存儲元素)的長度 - 1 再和 hash 值進行 & 運算獲得的:

& 運算的特色就是隻有兩個數都是 1 獲得的結果纔是 1,那麼假如 n-1 轉化爲二進制中含有大量的 0,如 1000,那麼這時候再和 hash 值去進行 & 運算,最多隻有 1 這個位置是有效的,其他位置所有是 0,至關於無效,這時候發生哈希碰撞的機率會大大提高。而假如換成一個 1111 再和 hash 值進行 & 運算,那麼這四位都有效參與了運算,大大下降了發生哈希碰撞的機率,這就是爲何最開始初始化的時候,會經過一系列的 | 運算來將其第一個 1 的位置以後全部元素所有修改成 1 的緣由。

談談 HashMap 中的哈希運算

上面談到了計算一個 key 值最終落在哪一個位置時用到了一個 hash 值,那麼這個 hash 值是如何獲得的呢?

下圖就是 HashMap 中計算 hash 值的方法:

咱們能夠看到這個計算方法很特別,它並不只僅只是簡單經過一個 hashCode 方法來獲得,而是還同時將 hashCode 獲得的結果無符號右移 16 位以後再進行異或運算,獲得最終結果。

這麼作的目的就是爲了將高 16 位也參與運算,進一步避免哈希碰撞的發生。由於在 HashMap 中容量老是 2 的 N 次冪,因此若是僅僅只有低 16 位參與運算,那麼有很大一部分狀況其低 16 位都是 1,因此將高 16 位也參與運算能夠必定程度避免哈希碰撞發生。然後面之因此會採用異或運算而不採用 &| 的緣由是若是採用 & 運算會使結果偏向 1,採用 | 運算會使結果偏向 0^ 運算會使得結果能更好的保留原有特性。

put 元素流程

put 方法前面的流程上面已經提到,若是 HashMap 沒有初始化,則會進行初始化,而後再判斷當前 key 值的位置是否有元素,若是沒有元素,則直接存進去,若是有元素,則會走下面這個分支:

這個 else 分支主要有 4 個邏輯:

  1. 判斷當前 key 和原有 key 是否相同,若是相同,直接返回。
  2. 若是當前 key 和原有 key 不相等,則判斷當前桶存儲的元素是不是 TreeNode 節點,若是是則表示當前是紅黑樹,則按照紅黑樹算法進行存儲。
  3. 若是當前 key 和原有 key 不相等,且當前桶存放的是一個鏈表,則依次遍歷每一個節點的 next 節點是否爲空,爲空則直接將當前元素放進鏈表,不爲空則先判斷兩個 key 是否相等,相等則直接返回,不相等則繼續判斷 next 節點,直到 key 相等,或者 next 節點爲空。
  4. 插入鏈表以後,若是當前鏈表長度大於 TREEIFY_THRESHOLD,默認是 8,則會將鏈表進行切換到紅黑樹存儲。

處理完以後,最後還有一個判斷就是判斷是否覆蓋舊元素,若是 e != null,則說明當前 key 值已經存在,則繼續判斷 onlyIfAbsent 變量,這個變量默認就是 false,表示覆蓋舊值,因此接下來會進行覆蓋操做,而後再把舊值返回。

擴容

HashMap 中存儲的數據量大於閾值(負載因子 * 當前桶數量)以後,會觸發擴容操做:

因此接下來讓咱們看看 resize 方法:

第一個紅框就是判斷當前容量是否已經達到了 MAXIMUM_CAPACITY,這個值前面提到了是 2 的 30 次冪,若是達到了這個值,就會將擴容閾值修改成 int 類型存儲的最大值,也就是不會再出發擴容了。

第二個紅框就是擴容,擴容的大小就是將舊容量左移 1 位,也就是擴大爲原來的 2 倍。固然,擴大以後的容量若是不知足第二個紅框中的條件,則會在第三個紅框中被設置。

擴容以後原有數據怎麼處理

擴容很簡單,關鍵是原有的數據應該如何處理呢?不看代碼,咱們能夠大體梳理出遷移數據的場景,沒有數據的場景不作考慮:

  1. 當前桶位置只有本身,也就是下面沒有其餘元素。
  2. 當前桶位置下面有元素,且是鏈表結構。
  3. 當前桶位置下面有元素,且是紅黑樹。

接下來讓咱們看看源碼中的 resize 方法中的數據遷移部分:

紅框部分比較好理解,首先就是看當前桶內元素是不是孤家寡人,若是是,直接從新計算下標而後賦值到新數據便可,若是是紅黑樹,則打散了重組,這部分暫且略過,最後一個 else 部分就是處理鏈表部分,接下來就讓咱們重點看一下鏈表的處理。

鏈表數據處理

鏈表的處理有一個核心思想:鏈表中元素的下標要麼保持不變,要麼在原先的基礎上在加上一個 oldCap 大小

鏈表的數據處理完整部分源碼以下圖所示:

關鍵條件就是 e.hash & oldCap,爲何這個結果等於 0 就表示元素的位置沒有發生改變呢?

在解釋這個問題以前,須要先回憶一下 tableSizeFor 方法,這個方法會將 n-1 調整爲相似 00001111 的數據結構,舉個例子:好比初始化容量爲 16,長度 n-1 就是 01111,而 n 就是 10000,因此若是 e.hash & oldCap ==0 就說明 hash 值的第 5 位是 010000 擴容以後獲得的就是 100000,對應的 n-1 就是 011111,和原先舊的 n-1 的差別之處就是第 5 位(第 6 位是 0 不影響計算結果),因此當 e.hash & oldCap==0 就說明第 5 位對結果也沒有影響,那麼就說明位置不會變,而若是 e.hash & oldCap !=0,就說明第 5 位數影響到告終果,而第 5 位若是計算結果爲 1,則獲得下標的位置剛好多出了一個 oldCap 的大小,即 16。其餘位置擴容也是一樣的道理,因此只要 e.hash & oldCap==0,說明下標位置不變,而若是不等於 0,則下標位置應該再加上一個 oldCap

最後的循環完節點以後,處理源碼以下所示:

同理的 32 就是 100000,這就說明了一件事,那就是隻須要關注最高位的 1 便可,由於只有這一位數和 e.hash 參與 & 運算時可能獲得 1

總結

本文主要分析了 HashMap 中是如何進行初始化,以及 put 方法是如何進行設置,同時也介紹了爲了防止哈希衝突,將 HashMap 的容量設置爲 2 的 N 次冪,最後介紹了 HashMap 中的擴容。

相關文章
相關標籤/搜索