Hashtable、Hashmap、ConcurrentHashMap

Hashtable和Hashmap理解:

HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:線程安全性,同步(synchronization),以及速度。

1.HashMap幾乎可以等價於Hashtable,除了HashMap是非synchronized的,並可以接受null(HashMap可以接受爲null的鍵值(key)和值(value),而Hashtable則不行)。

注(爲什麼Hashmap中鍵可以爲null,而Hsahtable卻不可以):

HashMap在put的時候會調用hash()方法來計算key的hashcode值,可以從hash算法中看出當key==null時返回的值爲0。因此key爲null時,hash算法返回值爲0,不會調用key的hashcode方法(利用的短路原則)。

當HashTable存入的value爲null時,拋出NullPointerException異常。如果value不爲null,而key爲空,在執行到int  hash = key.hashCode()時同樣會拋出NullPointerException異常,有一個專門判斷鍵是否爲空的if語句,如果爲空拋出異常。

2.HashMap是非synchronized,而Hashtable是synchronized,這意味着Hashtable是線程安全的,多個線程可以共享一個Hashtable;而如果沒有正確的同步的話,多個線程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的擴展性更好。

3另一個區別是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以當有其它線程改變了HashMap的結構(增加或者移除元素),將會拋出ConcurrentModificationException,但迭代器本身的remove()方法移除元素則不會拋出ConcurrentModificationException異常。但這並不是一個一定發生的行爲,要看JVM。這條同樣也是Enumeration和Iterator的區別。

4.由於Hashtable是線程安全的也是synchronized,所以在單線程環境下它比HashMap要慢。如果你不需要同步,只需要單一線程,那麼使用HashMap性能要好過Hashtable。

5.HashMap不能保證隨着時間的推移Map中的元素次序是不變的。

 JDK 1.8 以前 HashMap 的實現是 數組+鏈表,即使哈希函數取得再好,也很難達到元素百分百均勻分佈。當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當於一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間複雜度就是 O(n),完全失去了它的優勢。針對這種情況,JDK 1.8 中引入了 紅黑樹(查找時間複雜度爲 O(logn))來優化這個問題。引入TreeNode節點,當鏈表長度大於8時轉換爲紅黑樹。

HashMap :先說HashMap,HashMap是線程不安全的,在併發環境下,可能會形成環狀鏈表(擴容時可能造成,具體原因自行百度google或查看源碼分析),導致get操作時,cpu空轉,所以,在併發環境中使用HashMap是非常危險的。對象的key、value值均可爲null。

Java7 HashMap

HashMap 是最簡單的,一來我們非常熟悉,二來就是它不支持併發操作,所以源碼也非常簡單。

首先,我們用下面這張圖來介紹 HashMap 的結構。

1

這個僅僅是示意圖,因爲沒有考慮到數組要擴容的情況,具體的後面再說。

大方向上,HashMap 裏面是一個數組,然後數組中每個元素是一個單向鏈表。

上圖中,每個綠色的實體是嵌套類 Entry 的實例,Entry 包含四個屬性:key, value, hash 值和用於單向鏈表的 next。

capacity:當前數組容量,始終保持 2^n,可以擴容,擴容後數組大小爲當前的 2 倍。

loadFactor:負載因子,默認爲 0.75。

threshold:擴容的閾值,等於 capacity * loadFactor

Java8 HashMap

Java8 對 HashMap 進行了一些修改,最大的不同就是利用了紅黑樹,所以其由 數組+鏈表+紅黑樹 組成。

根據 Java7 HashMap 的介紹,我們知道,查找的時候,根據 hash 值我們能夠快速定位到數組的具體下標,但是之後的話,需要順着鏈表一個個比較下去才能找到我們需要的,時間複雜度取決於鏈表的長度,爲 O(n)。

爲了降低這部分的開銷,在 Java8 中,當鏈表中的元素超過了 8 個以後,會將鏈表轉換爲紅黑樹,在這些位置進行查找的時候可以降低時間複雜度爲 O(logN)。

來一張圖簡單示意一下吧:

2

注意,上圖是示意圖,主要是描述結構,不會達到這個狀態的,因爲這麼多數據的時候早就擴容了。

下面,我們還是用代碼來介紹吧,個人感覺,Java8 的源碼可讀性要差一些,不過精簡一些。

Java7 中使用 Entry 來代表每個 HashMap 中的數據節點,Java8 中使用 Node,基本沒有區別,都是 key,value,hash 和 next 這四個屬性,不過,Node 只能用於鏈表的情況,紅黑樹的情況需要使用 TreeNode。

我們根據數組元素中,第一個節點數據類型是 Node 還是 TreeNode 來判斷該位置下是鏈表還是紅黑樹的。

HashTable : HashTable和HashMap的實現原理幾乎一樣,差別無非是1.HashTable不允許key和value爲null;HashTable是線程安全的。但是HashTable線程安全的策略實現代價卻太大了,簡單粗暴,get/put所有相關操作都是synchronized的,這相當於給整個哈希表加了一把大鎖,多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當於將所有的操作串行化,在競爭激烈的併發場景中性能就會非常差。
ConcurrentHashMap:ConcurrentHashMap採用了非常精妙的”分段鎖”策略,ConcurrentHashMap的主幹是個Segment數組。final Segment<K,V>[] segments;
Segment繼承了ReentrantLock,所以它就是一種可重入鎖(ReentrantLock)。在ConcurrentHashMap,一個Segment就是一個子哈希表,Segment裏維護了一個HashEntry數組,併發環境下,對於不同Segment的數據進行操作是不用考慮鎖競爭的。(就按默認的ConcurrentLeve爲16來講,理論上就允許16個線程併發執行,有木有很酷)。put加鎖。get方法無需加鎖,由於其中涉及到的共享變量都使用volatile修飾,volatile可以保證內存可見性,所以不會讀取到過期數據。

Java7 ConcurrentHashMap

ConcurrentHashMap 和 HashMap 思路是差不多的,但是因爲它支持併發操作,所以要複雜一些。

整個 ConcurrentHashMap 由一個個 Segment 組成,Segment 代表”部分“或”一段“的意思,所以很多地方都會將其描述爲分段鎖。注意,行文中,我很多地方用了“槽”來代表一個 segment。

簡單理解就是,ConcurrentHashMap 是一個 Segment 數組,Segment 通過繼承 ReentrantLock 來進行加鎖,所以每次需要加鎖的操作鎖住的是一個 segment,這樣只要保證每個 Segment 是線程安全的,也就實現了全局的線程安全。

3

concurrencyLevel:並行級別、併發數、Segment 數,怎麼翻譯不重要,理解它。默認是 16,也就是說 ConcurrentHashMap 有 16 個 Segments,所以理論上,這個時候,最多可以同時支持 16 個線程併發寫,只要它們的操作分別分佈在不同的 Segment 上。這個值可以在初始化的時候設置爲其他值,但是一旦初始化以後,它是不可以擴容的。

再具體到每個 Segment 內部,其實每個 Segment 很像之前介紹的 HashMap,不過它要保證線程安全,所以處理起來要麻煩些。

併發問題分析

現在我們已經說完了 put 過程和 get 過程,我們可以看到 get 過程中是沒有加鎖的,那自然我們就需要去考慮併發問題。

添加節點的操作 put 和刪除節點的操作 remove 都是要加 segment 上的獨佔鎖的,所以它們之間自然不會有問題,我們需要考慮的問題就是 get 的時候在同一個 segment 中發生了 put 或 remove 操作。

  1. put 操作的線程安全性。
    • 初始化槽,這個我們之前就說過了,使用了 CAS 來初始化 Segment 中的數組。
    • 添加節點到鏈表的操作是插入到表頭的,所以,如果這個時候 get 操作在鏈表遍歷的過程已經到了中間,是不會影響的。當然,另一個併發問題就是 get 操作在 put 之後,需要保證剛剛插入表頭的節點被讀取,這個依賴於 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
    • 擴容。擴容是新創建了數組,然後進行遷移數據,最後面將 newTable 設置給屬性 table。所以,如果 get 操作此時也在進行,那麼也沒關係,如果 get 先行,那麼就是在舊的 table 上做查詢操作;而 put 先行,那麼 put 操作的可見性保證就是 table 使用了 volatile 關鍵字。
  2. remove 操作的線程安全性。

    remove 操作我們沒有分析源碼,所以這裏說的讀者感興趣的話還是需要到源碼中去求實一下的。

    get 操作需要遍歷鏈表,但是 remove 操作會”破壞”鏈表。

    如果 remove 破壞的節點 get 操作已經過去了,那麼這裏不存在任何問題。

    如果 remove 先破壞了一個節點,分兩種情況考慮。 1、如果此節點是頭結點,那麼需要將頭結點的 next 設置爲數組該位置的元素,table 雖然使用了 volatile 修飾,但是 volatile 並不能提供數組內部操作的可見性保證,所以源碼中使用了 UNSAFE 來操作數組,請看方法 setEntryAt。2、如果要刪除的節點不是頭結點,它會將要刪除節點的後繼節點接到前驅節點中,這裏的併發保證就是 next 屬性是 volatile 的。

  3. Java8 ConcurrentHashMap

    Java7 中實現的 ConcurrentHashMap 說實話還是比較複雜的,Java8 對 ConcurrentHashMap 進行了比較大的改動。建議讀者可以參考 Java8 中 HashMap 相對於 Java7 HashMap 的改動,對於 ConcurrentHashMap,Java8 也引入了紅黑樹。

    說實話,Java8 ConcurrentHashMap 源碼真心不簡單,最難的在於擴容,數據遷移操作不容易看懂。

    我們先用一個示意圖來描述下其結構:

    4

    結構上和 Java8 的 HashMap 基本上一樣,不過它要保證線程安全性,所以在源碼上確實要複雜一些。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章