HashMap
對每個學習 Java
的人來講熟悉的不能再熟悉了,然而就是這麼一個熟悉的東西,真正深刻到源碼層面卻有許多值的學習和思考的地方,如今就讓咱們一塊兒來探索一下 HashMap
的源碼。算法
HashMap
基於哈希表,且實現了 Map
接口的一種 key-value
鍵值對存儲數據結構,其中的 key
和 value
均容許 null
值,在 HashMap
中,不保證順序,線程也不安全。數組
在 HashMap
中,每次 put
操做都會對 key
進行哈希運算,根據哈希運算的結果真後再通過位運算獲得一個指定範圍內的下標,這個下標就是當前 key
值存放的位置,既然是哈希運算,那麼確定就會有哈希衝突,對此在 jdk1.7
及其以前的版本,每一個下標存放的是一個鏈表,而在 jdk1.8
版本及其以後的版本,則對此進行了優化,當鏈表長度大於 8
時,會轉化爲紅黑樹存儲,當紅黑樹中元素從大於等於 8
下降爲 小於等於 6
時,又會從紅黑樹從新退化爲鏈表進行存儲。安全
下圖就是 jdk1.8
中一個 HashMap
的存儲結構示意圖(每個下標的位置稱之爲 桶
):數據結構
在 HashMap
中是經過數組 + 鏈表 + 紅黑樹來實現數據的存儲(jdk1.8
),而在更早的版本中則僅僅採用了數組 + 鏈表的方式來進行存儲。源碼分析
HashMap
初始化的時候,咱們一般都會建議預估一下可能大小,而後在構造 HashMap
對象的時候指定容量,這是爲何呢?要回答這個問題就讓咱們看一下 HashMap
是如何初始化的。性能
下圖就是當咱們不指定任何參數時建立的 HashMap
對象:學習
能夠看到有一個默認的 DEFAULT_LOAD_FACTOR
(負載因子),這個值默認是 0.75
。負載因子的做用就是當 HashMap
中使用的容量達到總容量大小的 0.75
時,就會進行自動擴容。而後從上面的源碼能夠看到,當咱們不指定大小時,HashMap
並不會初始化一個容量,那麼咱們就能夠大膽的猜想當咱們調用 put
方法時確定會有判斷當前 HashMap
是否被初始化,若是沒有初始化,就會先進行初始化。優化
put
方法中會判斷當前 HashMap
是否被初始化,若是沒有被初始化,則會調用 resize
方法進行初始化,resize
方法不只僅用於初始化,也用於擴容,其中初始化部分主要是下圖中紅框部分:線程
能夠看到,初始化 HashMap
時,主要是初始化了 2
個變量:一個是 newCap
,表示當前 HashMap
中有多少個桶,數組的一個下標表示一個桶;一個是 newThr
,主要是用於表示擴容的閾值,由於任什麼時候候咱們都不可能等到桶所有用完了纔去擴容,因此要設置一個閾值,達到閾值後就開始擴容,這個閾值就是總容量乘以負載因子獲得。code
上面咱們知道了默認的負載因子是 0.75
,而默認的桶大小是 16
,因此也就是當咱們初始化 HashMap
而不指定大小時,當桶使用了 12
時就會自動擴容(如何擴容咱們在後面分析)。擴容就會涉及到舊數據遷移,比較消耗性能,因此在能預估出 HashMap
須要存儲的總元素時,咱們建議是提早指定 HashMap
的容量大小,以免擴容操做。
PS:須要注意的是,通常咱們說 HashMap
中的容量都是指的有多少個桶,而每一個桶能放多少個元素取決於內存,因此並非說容量爲 16
就只能存放 16
個 key
值。
當咱們手動指定容量初始化 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
,這時候我麼須要作的就是把後面的 3
位 101
調整爲 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 次冪的緣由其實很簡單,就是爲了儘量避免哈希分佈不均勻而致使每一個桶中分佈的數據不均勻,從而出現某些桶中元素過多,影響到查詢效率。
咱們繼續看一下 put
方法,下圖中紅框部分就是計算下標位置的算法,就是經過當前數組(HashMap
底層是採用了一個 Node
數組來存儲元素)的長度 - 1
再和 hash
值進行 &
運算獲得的:
&
運算的特色就是隻有兩個數都是 1
獲得的結果纔是 1
,那麼假如 n-1
轉化爲二進制中含有大量的 0
,如 1000
,那麼這時候再和 hash
值去進行 &
運算,最多隻有 1
這個位置是有效的,其他位置所有是 0
,至關於無效,這時候發生哈希碰撞的機率會大大提高。而假如換成一個 1111
再和 hash
值進行 &
運算,那麼這四位都有效參與了運算,大大下降了發生哈希碰撞的機率,這就是爲何最開始初始化的時候,會經過一系列的 |
運算來將其第一個 1
的位置以後全部元素所有修改成 1
的緣由。
上面談到了計算一個 key
值最終落在哪一個位置時用到了一個 hash
值,那麼這個 hash
值是如何獲得的呢?
下圖就是 HashMap
中計算 hash
值的方法:
咱們能夠看到這個計算方法很特別,它並不只僅只是簡單經過一個 hashCode
方法來獲得,而是還同時將 hashCode
獲得的結果無符號右移 16
位以後再進行異或運算,獲得最終結果。
這麼作的目的就是爲了將高 16
位也參與運算,進一步避免哈希碰撞的發生。由於在 HashMap
中容量老是 2 的 N 次冪,因此若是僅僅只有低 16
位參與運算,那麼有很大一部分狀況其低 16
位都是 1
,因此將高 16
位也參與運算能夠必定程度避免哈希碰撞發生。然後面之因此會採用異或運算而不採用 &
和 |
的緣由是若是採用 &
運算會使結果偏向 1
,採用 |
運算會使結果偏向 0
,^
運算會使得結果能更好的保留原有特性。
put
方法前面的流程上面已經提到,若是 HashMap
沒有初始化,則會進行初始化,而後再判斷當前 key
值的位置是否有元素,若是沒有元素,則直接存進去,若是有元素,則會走下面這個分支:
這個 else
分支主要有 4
個邏輯:
key
和原有 key
是否相同,若是相同,直接返回。key
和原有 key
不相等,則判斷當前桶存儲的元素是不是 TreeNode
節點,若是是則表示當前是紅黑樹,則按照紅黑樹算法進行存儲。key
和原有 key
不相等,且當前桶存放的是一個鏈表,則依次遍歷每一個節點的 next
節點是否爲空,爲空則直接將當前元素放進鏈表,不爲空則先判斷兩個 key
是否相等,相等則直接返回,不相等則繼續判斷 next
節點,直到 key
相等,或者 next
節點爲空。TREEIFY_THRESHOLD
,默認是 8
,則會將鏈表進行切換到紅黑樹存儲。處理完以後,最後還有一個判斷就是判斷是否覆蓋舊元素,若是 e != null
,則說明當前 key
值已經存在,則繼續判斷 onlyIfAbsent
變量,這個變量默認就是 false
,表示覆蓋舊值,因此接下來會進行覆蓋操做,而後再把舊值返回。
當 HashMap
中存儲的數據量大於閾值(負載因子 * 當前桶數量)以後,會觸發擴容操做:
因此接下來讓咱們看看 resize
方法:
第一個紅框就是判斷當前容量是否已經達到了 MAXIMUM_CAPACITY
,這個值前面提到了是 2 的 30 次冪,若是達到了這個值,就會將擴容閾值修改成 int
類型存儲的最大值,也就是不會再出發擴容了。
第二個紅框就是擴容,擴容的大小就是將舊容量左移 1
位,也就是擴大爲原來的 2
倍。固然,擴大以後的容量若是不知足第二個紅框中的條件,則會在第三個紅框中被設置。
擴容很簡單,關鍵是原有的數據應該如何處理呢?不看代碼,咱們能夠大體梳理出遷移數據的場景,沒有數據的場景不作考慮:
接下來讓咱們看看源碼中的 resize
方法中的數據遷移部分:
紅框部分比較好理解,首先就是看當前桶內元素是不是孤家寡人,若是是,直接從新計算下標而後賦值到新數據便可,若是是紅黑樹,則打散了重組,這部分暫且略過,最後一個 else
部分就是處理鏈表部分,接下來就讓咱們重點看一下鏈表的處理。
鏈表的處理有一個核心思想:鏈表中元素的下標要麼保持不變,要麼在原先的基礎上在加上一個 oldCap
大小。
鏈表的數據處理完整部分源碼以下圖所示:
關鍵條件就是 e.hash & oldCap
,爲何這個結果等於 0
就表示元素的位置沒有發生改變呢?
在解釋這個問題以前,須要先回憶一下 tableSizeFor
方法,這個方法會將 n-1
調整爲相似 00001111
的數據結構,舉個例子:好比初始化容量爲 16
,長度 n-1
就是 01111
,而 n
就是 10000
,因此若是 e.hash & oldCap ==0
就說明 hash
值的第 5
位是 0
,10000
擴容以後獲得的就是 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
中的擴容。