🎓 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步
🎁 本文已收錄於 「CS-Wiki」Gitee 官方推薦項目,現已累計 1.5k+ star,致力打造完善的后端知識體系,在技術的路上少走彎路,歡迎各位小伙伴前來交流學習
🍉 如果各位小伙伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 600+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文檔和配套教程。公眾號后台回復 Echo 可以獲取配套教程,目前尚在更新中
HashMap、HashTable、ConcurrentHashMap 這一套感覺今年面試都不怎么問了,場景題越來越多,求職的門檻越來越高,這種常見的面試題問出來大概率就是要送波分了。
1. 講講 HashMap 的底層結構和原理
HashMap 就是以 Key-Value 的方式進行數據存儲的一種數據結構嘛,在我們平常開發中非常常用,它在 JDK 1.7 和 JDK 1.8 中底層數據結構是有些不一樣的。總體來說,JDK 1.7 中 HashMap 的底層數據結構是數組 + 鏈表,使用 Entry
類存儲 Key 和 Value;JDK 1.8 中 HashMap 的底層數據結構是數組 + 鏈表/紅黑樹,使用 Node
類存儲 Key 和 Value。當然,這里的 Entry
和 Node
並沒有什么不同,我們來看看 Node
類的源碼:
// HashMap 1.8 內部使用這個數組存儲所有鍵值對
transient Node<K,V>[] table;
每一個節點都會保存自身的 hash、key 和 value、以及下個節點。咱先畫個簡略的 HashMap 示意圖:
因為 HashMap 本身所有的位置都為 null 嘛,所以在插入元素的時候即 put
操作時,會根據 key 的 hash 去計算出一個 index 值,也就是這個元素將要插入的位置。
舉個例子:比如 put("小牛肉",20)
,我插入了一個 key 為 "小牛肉" value 為 20 的元素,這個時候我們會通過哈希函數計算出這個元素將要插入的位置,假設計算出來的結果是 2:
我們剛剛還提到了鏈表,Node 類里面也確實定義了一個 next
屬性,那么為啥需要鏈表呢?
首先,數組的長度是有限的對吧,在有限的數組上使用哈希,那么哈希沖突是不可避免的,很有可能兩個元素計算得出的 index 是相同的,那么如何解決哈希沖突呢?拉鏈法。也就是把 hash 后值相同的元素放在同一條鏈表上。比如說:
當然這里還有一個問題,那就是當 Hash 沖突嚴重時,在數組上形成的鏈表會變的越來越長,由於鏈表不支持索引,要想在鏈表中找一個元素就需要遍歷一遍鏈表,那顯然效率是比較低的。為此,JDK 1.8 引入了紅黑樹,當鏈表的長度大於 8 的時候就會轉換為紅黑樹,不過,在轉換之前,會先去查看 table 數組的長度是否大於 64,如果數組的長度小於 64,那么 HashMap 會優先選擇對數組進行擴容 resize
,而不是把鏈表轉換成紅黑樹。
看下 JDK 1.8 下 HashMap 的完整示意圖,應該畫的比較清晰了:
2. 新的 Entry/Node 節點在插入鏈表的時候,是怎么插入的?
在 JDK 1.7 的時候,采用的是頭插法,看下圖:
不過 JDK 1.8 改成了尾插法,這是為什么呢?因為 JDK 1.7 中采用的頭插法在多線程環境下可能會造成循環鏈表問題。
首先,我們之前提到,數組容量是有限的,如果數據多次插入並到達一定的數量就會進行數組擴容,也就是resize
方法。什么時候會進行 resize
呢?與兩個因素有關:
1)Capacity
:HashMap 當前最大容量/長度
2)LoadFactor
:負載因子,默認值0.75f
如果當前存入的數據數量大於 Capacity * LoadFactor 的時候,就會進行數組擴容 resize
。就比如當前的 HashMap 的最大容量大小為 100,當你存進第 76 個的時候,判斷發現需要進行 resize了,那就進行擴容。當然,HashMap 的擴容不是簡單的擴大點容量這么簡單的。
擴容 resize
分為兩步:
1)擴容:創建一個新的 Entry/Node 空數組,長度是原數組的 2 倍
2)ReHash:遍歷原 Entry/Node 數組,把所有的 Entry/Node 節點重新 Hash 到新數組
為什么要 ReHash 呢?直接復制到新數組不行嗎?
顯然是不行的,因為數組的長度改變以后,Hash 的規則也隨之改變。index 的計算公式是這樣的:
- index = HashCode(key) & (Length - 1)
比如說數組原來的長度(Length)是 4,Hash 出來的值是 2 ,然后數組長度翻倍了變成 16,顯然 Hash 出來的值也就會變了。畫個圖解釋下:
OK,說完擴容機制我們言歸正傳,為啥 JDK 1.7 使用頭插法,JDK 1.8 之后改成尾插法了呢?
我們來看 1.7 的 resize 方法:
newTable 就是擴容后的新數組,transfer
方法是 resize
的核心,它的的功能就是 ReHash,然后將原數組中的數據遷移到新數據。我們先來把 transfer 代碼簡化一下,方便下文的理解:
先來看看單線程情況下,正常的 resize 的過程。假設我們原來的數組容量為 2,記錄數為 3,分別為:[3,A]、[7,B]、[5,C],並且這三個 Entry 節點都落到了第二個桶里面,新數組容量會被擴容到 4。
下面的圖畫的可能會有點不嚴謹,不過能夠方便大家理解其中意思就好
OK,那現在如果我們有兩個線程 Thread1 和 Thread2,假設線程 Thread1 執行到了 transfer 方法的 Entry next = e.next
這一句,然后時間片用完了被掛起了。隨后線程 Thread2 順利執行並完成 resize 方法。於是我們有下面這個樣子:
注意,Thread1 的 e 指向了 [3,A],next 指向了 [7,B],而在線程 Thread2 進行 ReHash后,e 和 next 指向了線程 Thread2 重組后的鏈表。我們可以看到鏈表的順序被反轉了。
OK,這個時候線程 Thread1 被重新調度執行,先是執行 newTalbe[i] = e
,i 就是 ReHash 后的 index 值:
然后執行 e = next
,導致了 e 指向了 [7,B],而下一次循環執行到 next = e.next
時導致了 next 指向了 [3,A]
然后,線程 Thread1 繼續執行。把舊數組的 [7,B] 摘下來,放到 newTable[i] 的第一個,然后把 e 和 next 往下順移:
OK,Thread1 再進入下一步循環,執行到 e.next = newTable[i]
,導致 [3,A].next 指向了 [7,B],循環鏈表出現!!!
由於 JDK 1.7 中 HashMap 使用頭插會改變鏈表上元素的的順序,在舊數組向新數組轉移元素的過程中修改了鏈表中節點的引用關系,因此 JDK 1.8 改成了尾插法,在擴容時會保持鏈表元素原本的順序,避免了鏈表成環的問題。
3. HashMap 的默認初始數組長度是多少?為什么是這么多?
默認數組長度是 16,其實只要是 2 的次冪都行,至於為啥是 16 呢,我覺得應該是個經驗值問題,Java 作者是覺得 16 這個長度最為常用。
那為什么數組長度得是 2 的次冪呢?
首先,一般來說,我們常用的 Hash 函數是這樣的:index = HashCode(key) % Length,但是因為位運算的效率比較高嘛,所以 HashMap 就相應的改成了這樣:index = HashCode(key) & (Length - 1)。
那么為了保證根據上述公式計算出來的 index 值是分布均勻的,我們就必須保證 Length 是 2 的次冪。
解釋一下:2 的次冪,也就是 2 的 n 次方,它的二進制表示就是 1 后面跟着 n 個 0,那么 2 的 n 次方 - 1 的二進制表示就是 n 個 1。而對於 & 操作來說,任何數與 1 做 & 操作的結果都是這個數本身。也就是說,index 的結果等同於 HashCode(key) 后 n 位的值,只要 HashCode 本身是分布均勻的,那么我們這個 Hash 算法的結果就是均勻的。
4. 以 HashMap 為例,解釋一下為什么重寫 equals 方法的時候還需要重寫 hashCode 方法呢?
既然講到 equals
了,那就先順便回顧下運算符 ==
的吧,它存在兩種使用情況:
- 對於基本數據類型來說, == 比較的是值是否相同;
- 對於引用數據類型來說, == 比較的是內存地址是否相同。
equals()
也存在兩種使用情況:
- 情況 1:沒有重寫 equals() 方法。則通過 equals() 比較該類的兩個對象時,等價於通過
==
比較這兩個對象(比較的是地址)。 - 情況 2:重寫 equals() 方法。一般來說,我們都會重寫 equals() 方法來判斷兩個對象的內容是否相等,比如 String 類就是這樣做的。當然,你也可以不這樣做。
另外,我們還需要明白,如果我們不重寫 hashCode(),那么任何對象的 hashCode() 值都不會相等。
OK,回到問題,為什么重寫 equals 方法的時候還需要重寫 hashCode 方法呢?
以 HashMap 為例,HashMap 是通過 hashCode(key) 去計算尋找 index 的,如果多個 key 哈希得到的 index 一樣就會形成鏈表,那么如何在這個具有相同 hashCode 的對象鏈表上找到某個對象呢?
那就是通過重寫的 equals
比較兩個對象的值。
總體來說,HashMap 中get(key)
一個元素的過程是這樣的,先比較 key 的 hashcode() 是否相等,若相等再通過 equals() 比較其值,若 equals() 相等則認為他們是相等的。若 equals() 不相等則認為他們不相等。
如果只重寫 equals 沒有重寫 hashCode(),就會導致相同的對象卻擁有不同的 hashCode,也就是說在判斷的第一步 HashMap 就會認為這兩個對象是不相等的,那顯然這是錯誤的。
5. HashMap 線程不安全的表現有哪些?
關於 JDK 1.7 中 HashMap 的線程不安全,上面已經說過了,就是會出現環形鏈表。雖然 JDK 1.8 采用尾插法避免了環形鏈表的問題,但是它仍然是線程不安全的,我們來看看 JDK 1.8 中 HashMap 的 put
方法:
注意上圖我圈出來的代碼,如果沒有發生 Hash 沖突就會直接插入元素。
假設線程 1 和線程 2 同時進行 put 操作,恰好這兩條不同的數據的 hash 值是一樣的,並且該位置數據為null,這樣,線程 1 和線程 2 都會進入這段代碼進行插入元素。假設線程 1 進入后還沒有開始進行元素插入就被掛起,而線程 2 正常執行,並且正常插入數據,隨后線程 1 得到 CPU 調度進行元素插入,這樣,線程 2 插入的數據就被覆蓋了。
總結一下 HashMap 在 JDK 1.7 和 JDK 1.8 中為什么不安全:
- JDK 1.7:由於采用頭插法改變了鏈表上元素的的順序,並發環境下擴容可能導致循環鏈表的問題
- JDK 1.8:由於 put 操作並沒有上鎖,並發環境下可能發生某個線程插入的數據被覆蓋的問題
6. 如何保證 HashMap 線程安全?
這個問題留到下篇文章再做講解,這里先籠統概括下,主要有三種方式:
1)使用 java.util.Collections 類的 synchronizedMap
方法包裝一下 HashMap,得到線程安全的 HashMap,其原理就是對所有的修改操作都加上 synchronized。方法如下:
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
2)使用線程安全的 HashTable
類代替,該類在對數據操作的時候都會上鎖,也就是加上 synchronized
3)使用線程安全的 ConcurrentHashMap
類代替,該類在 JDK 1.7 和 JDK 1.8 的底層原理有所不同,JDK 1.7 采用數組 + 鏈表存儲數據,使用分段鎖 Segment 保證線程安全;JDK 1.8 采用數組 + 鏈表/紅黑樹存儲數據,使用 CAS + synchronized 保證線程安全。
🎉 關注公眾號 | 飛天小牛肉,即時獲取更新
- 博主東南大學碩士在讀,利用課余時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(數據結構 + 算法 + 計算機網絡 + 數據庫 + 操作系統 + Linux)、Java 基礎和面試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支持哦,和小牛肉一起成長 😃
- 並推薦個人維護的開源教程類項目: CS-Wiki(Gitee 推薦項目,現已累計 1.5k+ star), 致力打造完善的后端知識體系,在技術的路上少走彎路,歡迎各位小伙伴前來交流學習 ~ 😊
- 如果各位小伙伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 600+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文檔和配套教程。公眾號后台回復 Echo 可以獲取配套教程,目前尚在更新中。