? 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步
? 本文已收錄於 「CS-Wiki」Gitee 官方推薦專案,現已累計 1.5k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習
? 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 600+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中
本來準備這篇文章一口氣寫完 Hashtable
和 ConcurrentHashMap
的,後來發現 Hashtable
就已經很多了,考慮各位的閱讀體驗,所以 ConcurrentHashMap
就放在下篇文章吧。
OK,繼續上篇文章 HashMap 這套八股,不得背個十來遍? 最後提出的問題來講:
1. 如何保證 HashMap 執行緒安全?
一般有三種方式來代替原生的執行緒不安全的 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 保證執行緒安全。
不過前兩者的執行緒併發度並不高,容易發生大規模阻塞,所以一般使用的都是 ConcurrentHashMap
,他的效能和效率明顯高於前兩者。
2. synchronizedMap 具體是怎麼實現執行緒安全的?
這個問題應該很容易被大家漏掉吧,面經中也確實不常出現,也沒啥好問的。不過為了保證知識的完整性,這裡還是解釋一下吧。
一般我們會這樣使用 synchronizedMap
方法來建立一個執行緒安全的 Map:
Map m = Collections.synchronizedMap(new HashMap(...));
Collections
中的這個靜態方法 synchronizedMap
其實是建立了一個內部類的物件,這個內部類就是 SynchronizedMap
。在其內部維護了一個普通的 Map 物件以及互斥鎖 mutex,如下圖所示:
可以看到 SynchronizedMap
有兩個建構函式,如果你傳入了互斥鎖 mutex 引數,就使用我們自己傳入的互斥鎖。如果沒有傳入,則將互斥鎖賦值為 this,也就是將呼叫了該建構函式的物件作為互斥鎖,即我們上面所說的 Map。
建立出 SynchronizedMap
物件之後,通過原始碼可以看到對於這個物件的所有操作全部都是上了悲觀鎖 synchronized
的:
由於多個執行緒都共享同一把互斥鎖,導致同一時刻只能有一個執行緒進行讀寫操作,而其他執行緒只能等待,所以雖然它支援高併發,但是併發度太低,多執行緒情況下效能比較低下。
而且,大多數情況下,業務場景都是讀多寫少,多個執行緒之間的讀操作本身其實並不衝突,所以SynchronizedMap
極大的限制了讀的效能。
所以多執行緒併發場景我們很少使用 SynchronizedMap
。
3. 那 Hashtable 呢?
和 SynchronizedMap
一樣,Hashtable
也是非常粗暴的給每個方法都加上了悲觀鎖 synchronized
,我們隨便找幾個方法看看:
4. 除了這個之外 Hashtable 和 HashMap 還有什麼不同之處嗎?
Hashtable
是不允許 key 或 value 為 null 的,HashMap
的 key 和 value 都可以為 null !!!
先解釋一下 Hashtable
不支援 null key 和 null value 的原理:
如果我們 put 了一個 value 為 null 進入 Map,Hashtable
會直接拋空指標異常:
2)如果我們 put 了一個 key 為 null 進入 Map,當程式執行到下圖框出來的那行程式碼時就會丟擲空指標異常,因為 key 為 null,我們拿了一個 null 值去呼叫方法:
OK,講完了 Hashtable,再來解釋一下 HashMap
支援 null key 和 null value 的原理:
1)HashMap
相比 Hashtable
做了一個特殊的處理,如果我們 put 進來的 key 是 null,HashMap
在計算這個 key 的 hash 值時,會直接返回 0:
也就是說 HashMap
中 key為 null 的鍵值對的 hash 為 0。因此一個 HashMap
物件中只會儲存一個 key 為 null 的鍵值對,因為它們的 hash 值都相同。
2)如果我們 put 進來的 value 是 null,由於 HashMap
的 put 方法不會對 value 是否為 null 進行校驗,因此一個 HashMap
物件可以儲存多個 value 為 null 的鍵值對:
不過,這裡有個小坑需要注意,我們來看看 HashMap
的 get 方法:
如果 Map 中沒有查詢到這個 key 的鍵值對,那麼 get 方法就會返回 null 物件。但是我們上面剛剛說了,HashMap
裡面可以存在多個 value 為 null 的鍵值對,也就是說,通過 get(key) 方法返回的結果為 null 有兩種可能:
HashMap
中不存在這個 key 對應的鍵值對HashMap
中這個 key 對應的 value 為 null
因此,一般來說我們不能使用 get 方法來判斷 HashMap
中是否存在某個 key,而應該使用 containsKey
方法。
5. 那到底為什麼 Hashtable 不允許 key 和 value 為 null 呢?為什麼這麼設計呢?
不止是 Hashtable
不允許 key 為 null 或者 value 為 null,ConcurrentHashMap
也是不允許的。作為支援併發的容器,如果它們像 HashMap
一樣,允許 null key 和 null value 的話,在多執行緒環境下會出現問題。
假設它們允許 null key 和 null value,我們來看看會出現什麼問題:當你通過 get(key) 獲取到對應的 value 時,如果返回的結果是 null 時,你無法判斷這個 key 是否真的存在。為此,我們需要呼叫 containsKey 方法來判斷這個 key 到底是 value = null 還是它根本就不存在,如果 containsKey 方法返回的結果是 true,OK,那我們就可以呼叫 map.get(key) 獲取 value。
上面這段邏輯對於單執行緒的 HashMap
當然沒有任何問題。在單執行緒中,當我們得到的 value 是 null 的時候,可以用 map.containsKey(key) 方法來區分二義性。
但是!由於 Hashtable
和 ConcurrentHashMap
是支援多執行緒的容器,在呼叫 map.get(key) 的這個時候 map 物件可能已經不同了。
我們假設此時某個執行緒 A 呼叫了 map.get(key) 方法,它返回為 value = null 的真實情況就是因為這個 key 不能存在。當然,執行緒 A 還是會按部就班的繼續用 map.containsKey(key),我們期望的結果是返回 false。
但是,線上程 A 呼叫 map.get(key) 方法之後,map.containsKey 方法之前,另一個執行緒 B 執行了 map.put(key,null) 的操作。那麼執行緒 A 呼叫的 map.containsKey 方法返回的就是 true 了。這就與我們的假設的真實情況不符合了。
所以,出於併發安全性的考慮,Hashtable
和 ConcurrentHashMap
不允許 key 和 value 為 null。
6. Hashtable 和 HashMap 的不同點說完了嗎?
除了 Hashtable
不允許 null key 和 null value 而 HashMap
允許以外,它倆還有以下幾點不同:
1)初始化容量不同:HashMap
的初始容量為 16,Hashtable
初始容量為 11。兩者的負載因子預設都是 0.75;
2)擴容機制不同:當現有容量大於總容量 * 負載因子時,HashMap
擴容規則為當前容量翻倍,Hashtable
擴容規則為當前容量翻倍 + 1;
3)迭代器不同:首先,Hashtable
和 HashMap
有一個相同的迭代器 Iterator,用法:
Iterator iterator = map.keySet().iterator();
HashMap
的 Iterator 是 快速失敗 fail-fast 的,那自然 Hashtable
的 Iterator 也是 fail-fast 的。Hashtable
是 fail-fast 機制這點很明確,JDK 1.8 的官方文件就是這麼寫的:
但是!!!Hashtable
還有另外一個迭代器 Enumeration,這個迭代器是 失敗安全 fail-safe 的。網路上很多部落格提到 Hashtable
就說它是 fail-safe 的,這是不正確的、是存在歧義的!
7. 介紹下 fail-safe 和 fail-fast 機制
fail-safe 和 fail-fast 是一種思想,一種機制,屬於系統設計範疇,並非 Java 集合所特有,各位如果熟悉 Dubbo 的話,一定記得 Dubbo 的叢集容錯策略中也有這倆。
當然,這兩種機制在 Java 集合和 Dubbo 中的具體表現肯定是不一樣的,本文我們就只說在 Java 集合中,這兩種機制的具體表現。
1)快速失敗 fail-fast:一種快速發現系統故障的機制。一旦發生異常,立即停止當前的操作,並上報給上層的系統來處理這些故障。
舉一個最簡單的 fail-fast 的例子:
這樣做的好處就是可以預先識別出一些錯誤情況,一方面可以避免執行復雜的其他程式碼,另外一方面,這種異常情況被識別之後也可以針對性的做一些單獨處理。
java.util 包下的集合類都是 fail-fast 的,比如 HashMap
和 HashTable
,官方文件是這樣解釋 fail-fast 的:
The iterators returned by all of this class's "collection view methods" are fail-fast: if the map is structurally modified at any time after the iterator is created, in any way except through the iterator's own
remove
method, the iterator will throw aConcurrentModificationException
. Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.
大體意思就是說當 Iterator 這個迭代器被建立後,除了迭代器本身的方法 remove 可以改變集合的結構外,其他的因素如若改變了集合的結構,都將會丟擲 ConcurrentModificationException
異常。
所謂結構上的改變,集合中元素的插入和刪除就是結構上的改變,但是對集合中修改某個元素並不是結構上的改變。我們以 Hashtable
來演示下 fail-fast 機制丟擲異常的例項:
分析下這段程式碼:第一次迴圈遍歷的時候,我們刪除了集合 key = "a" 的元素,集合的結構被改變了,所以第二次遍歷迭代器的時候,就會丟擲異常。
另外,這裡多提一嘴,使用 for-each 增強迴圈也會丟擲異常,for-each 本質上依賴了 Iterator。
OK,我們接著往下看官方文件:
Note that the fail-fast behavior of an iterator cannot be guaranteed as it is, generally speaking, impossible to make any hard guarantees in the presence of unsynchronized concurrent modification. Fail-fast iterators throw
ConcurrentModificationException
on a best-effort basis. Therefore, it would be wrong to write a program that depended on this exception for its correctness: the fail-fast behavior of iterators should be used only to detect bugs.
意思就是說:迭代器的 fail-fast 行為是不一定能夠得到 100% 得到保證的。但是 fail-fast 迭代器會做出最大的努力來丟擲 ConcurrentModificationException
。因此,程式設計師編寫依賴於此異常的程式的做法是不正確的。迭代器的 fail-fast 行為應該僅用於檢測程式中的 Bug。
2)失敗安全 fail-safe:在故障發生之後會維持系統繼續執行。
顧名思義,和 fail-fast 恰恰相反,當我們對集合的結構做出改變的時候,fail-safe 機制不會丟擲異常。
java.util.concurrent 包下的容器都是 fail-safe 的,比如 ConcurrentHashMap
,可以在多執行緒下併發使用,併發修改。同時也可以在 for-each 增強迴圈中進行 add/remove。
不過有個例外,那就是 java.util.Hashtable
,上面我們說到 Hashtable
還有另外一個迭代器 Enumeration,這個迭代器是 fail-safe 的。
HashTable
中有一個 keys 方法可以返回 Enumeration 迭代器:
至於為什麼 fail-safe 不會丟擲異常呢,這是因為,當集合的結構被改變的時候,fail-safe 機制會複製一份原集合的資料,然後在複製的那份資料上進行遍歷。因此,雖然 fail-safe 不會丟擲異常,但存在以下缺點:
- 不能保證遍歷的是最新內容。也就是說迭代器遍歷的是開始遍歷那一刻拿到的集合拷貝,在遍歷期間原集合發生的修改迭代器是不知道的;
- 複製時需要額外的空間和時間上的開銷。
8. 講講 fail-fast 的原理是什麼
從原始碼我們可以發現,迭代器在執行 next() 等方法的時候,都會呼叫 checkForComodification
這個方法,檢視 modCount 和 expectedModCount 是否相等,如果不相等則丟擲異常終止遍歷,如果相等就返回遍歷。
expectedModcount 這個值在物件被建立的時候就被賦予了一個固定的值即 modCount,也就是說 expectedModcount 是不變的,但是 modCount 在我們對集合的元素的個數做出改變(刪除、插入)的時候會被改變(修改操作不會)。那如果在迭代器下次遍歷元素的時候,發現 modCount 這個值發生了改變,那麼走到這個判斷語句時就會丟擲異常。
? 關注公眾號 | 飛天小牛肉,即時獲取更新
- 博主東南大學碩士在讀,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 基礎和麵試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支援哦,和小牛肉一起成長 ?
- 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.5k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
- 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 600+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。