Hashtable 漸漸被人們遺忘了,只有面試官還記得,感動

飛天小牛肉發表於2021-04-01

? 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步

? 本文已收錄於 「CS-Wiki」Gitee 官方推薦專案,現已累計 1.5k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習

? 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 600+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中


本來準備這篇文章一口氣寫完 HashtableConcurrentHashMap 的,後來發現 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) 方法來區分二義性。

但是!由於 HashtableConcurrentHashMap 是支援多執行緒的容器,在呼叫 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 了。這就與我們的假設的真實情況不符合了。

所以,出於併發安全性的考慮HashtableConcurrentHashMap 不允許 key 和 value 為 null。

6. Hashtable 和 HashMap 的不同點說完了嗎?

除了 Hashtable 不允許 null key 和 null value 而 HashMap 允許以外,它倆還有以下幾點不同:

1)初始化容量不同HashMap 的初始容量為 16,Hashtable 初始容量為 11。兩者的負載因子預設都是 0.75;

2)擴容機制不同:當現有容量大於總容量 * 負載因子時,HashMap 擴容規則為當前容量翻倍,Hashtable 擴容規則為當前容量翻倍 + 1;

3)迭代器不同:首先,HashtableHashMap 有一個相同的迭代器 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 的,比如 HashMapHashTable,官方文件是這樣解釋 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 a ConcurrentModificationException. 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 可以獲取配套教程,目前尚在更新中。

相關文章