HashMap與ConcurrentHashMap、HashTable


(1)HashMap的線程不安全原因一:死循環

原因在於HashMap在多線程情況下,執行resize()進行擴容時容易造成死循環。 
擴容思路為它要創建一個大小為原來兩倍的數組,保證新的容量仍為2的N次方,從而保證上述尋址方式仍然適用。擴容后將原來的數組從新插入到新的數組中。這個過程稱為reHash。

【單線程下的reHash】 
這里寫圖片描述

    • 擴容前:我們的HashMap初始容量為2,加載因子為1,需要向其中存入3個key,分別為5、9、11,放入第三個元素11的時候就涉及到了擴容。
    • 第一步:先創建一個二倍大小的數組,接下來把原來數組中的元素reHash到新的數組中,5插入新的數組,沒有問題。
    • 第二步:將9插入到新的數組中,經過Hash計算,插入到5的后面。
    • 第三步:將11經過Hash插入到index為3的數組節點中。

單線程reHash完全沒有問題。

【多線程下的reHash】 

多線程HashMap的resize

我們假設有兩個線程同時需要執行resize操作,我們原來的桶數量為2,記錄數為3,需要resize桶到4,原來的記錄分別為:[3,A],[7,B],[5,C],在原來的map里面,我們發現這三個entry都落到了第二個桶里面。
假設線程thread1執行到了transfer方法的Entry next = e.next這一句,然后時間片用完了,此時的e = [3,A], next = [7,B]。線程thread2被調度執行並且順利完成了resize操作,需要注意的是,此時的[7,B]的next為[3,A]。此時線程thread1重新被調度運行,此時的thread1持有的引用是已經被thread2 resize之后的結果。線程thread1首先將[3,A]遷移到新的數組上,然后再處理[7,B],而[7,B]被鏈接到了[3,A]的后面,處理完[7,B]之后,就需要處理[7,B]的next了啊,而通過thread2的resize之后,[7,B]的next變為了[3,A],此時,[3,A]和[7,B]形成了環形鏈表,在get的時候,如果get的key的桶索引和[3,A]和[7,B]一樣,那么就會陷入死循環。

(2)HashMap的線程不安全原因二:fail-fast

Fail-safe和iterator迭代器相關。如果某個集合對象創建了Iterator或者ListIterator,然后其它的線程試圖“結構上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因為這並沒有從“結構上”更改集合。但是假如已經從結構上進行了更改,再調用set()方法,將會拋出IllegalArgumentException異常。結構上的更改指的是刪除或者插入一個元素,這樣會影響到map的結構。

解決辦法:可以使用Collections的synchronizedMap方法構造一個同步的map,或者直接使用線程安全的ConcurrentHashMap來保證不會出現fail-fast策略。

Java7 HashMap

不支持並發操作

Java中的數據存儲方式有兩種結構,一種是數組,另一種就是鏈表,前者的特點是連續空間,尋址迅速,但是在增刪元素的時候會有較大幅度的移動,所以數組的特點是查詢速度快,增刪較慢。

而鏈表由於空間不連續,尋址困難,增刪元素只需修改指針,所以鏈表的特點是查詢速度慢、增刪快。

那么有沒有一種數據結構來綜合一下數組和鏈表以便發揮他們各自的優勢?答案就是哈希表。

 

HashMap 里面是一個數組,然后數組中每個元素是一個單向鏈表。

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

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

HashMap的長度為什么要是2的n次方

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

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

put 過程分析

數組初始化

在第一個元素插入 HashMap 的時候做一次數組的初始化,就是先確定初始的數組大小,並計算數組擴容的閾值。

1. 求 key 的 hash 值
     int  hash = hash(key);
2. 找到對應的數組下標
     int  i = indexFor(hash, table.length);
3.放入鏈表頭部

數組擴容

在插入新值的時候,如果當前的 size 已經達到了閾值,並且要插入的數組位置上已經有元素,那么就會觸發擴容,擴容后,數組大小為原來的 2 倍。

get 過程分析

1.根據 key 計算 hash 值。

2.找到相應的數組下標:hash & (length – 1)。

3.遍歷該數組位置處的鏈表,直到找到相等(==或equals)的 key。

Java7 ConcurrentHashMap

——基於分段鎖的ConcurrentHashMap

  • Java7里面的ConcurrentHashMap的底層結構仍然是數組和鏈表,與HashMap不同的是ConcurrentHashMap的最外層不是一個大的數組,而是一個Segment數組。每個Segment包含一個與HashMap結構差不多的鏈表數組。
  • 當我們讀取某個Key的時候它先取出key的Hash值,並將Hash值的高sshift位與Segment的個數取模,決定key屬於哪個Segment。接着像HashMap一樣操作Segment。
  • 為了保證不同的Hash值保存到不同的Segment中,ConcurrentHashMap對Hash值也做了專門的優化。
  • Segment繼承自J.U.C里的ReetrantLock,所以可以很方便的對Segment進行上鎖。即分段鎖。理論上最大並發數是和segment的個數是想等的。 

初始化

initialCapacity:初始容量,這個值指的是整個 ConcurrentHashMap 的初始容量,實際操作的時候需要平均分給每個 Segment。

loadFactor:負載因子,之前我們說了,Segment 數組不可以擴容,所以這個負載因子是給每個 Segment 內部使用的。擴容是 segment 數組某個位置內部的數組 HashEntry<k,v>[] 進行擴容,擴容后,容量為原來的 2 倍。

put 過程分析

1. 計算 key 的 hash 值

2. 根據 hash 值找到 Segment 數組中的位置 

3.再利用 hash 值,求應該放置的segment 內部的數組下標

4.添加到頭部

get 過程分析

1.計算 hash 值,找到 segment 數組中的具體位置,或我們前面用的“槽”

2.槽中也是一個數組,根據 hash 找到數組中具體的位置 

3.到這里是鏈表了,順着鏈表進行查找即可

Java8 HashMap

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

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

Java 8為進一步提高並發性,摒棄了分段鎖的方案,而是直接使用一個大的數組。同時為了提高哈希碰撞下的尋址性能,Java 8在鏈表長度超過一定閾值(8)時將鏈表(尋址時間復雜度為O(N))轉換為紅黑樹(尋址時間復雜度為O(long(N)))。

java8也是通過計算key的hash值和數組長度值進行取模確定該key在數組中的索引。但是java8引入紅黑樹,即使hash沖突比較高,尋址效率也會是比較高的。

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

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

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

put 過程分析

參考get過程

get 過程分析

  1. 計算 key 的 hash 值,根據 hash 值找到對應數組下標: hash & (length-1)
  2. 判斷數組該位置處的元素是否剛好就是我們要找的,如果不是,走第三步
  3. 判斷該元素類型是否是 TreeNode,如果是,用紅黑樹的方法取數據,如果不是,走第四步
  4. 遍歷鏈表,直到找到相等(==或equals)的 key

Java8 ConcurrentHashMap

——基於CAS的ConcurrentHashMap

比java8的HashMap復雜很多,但是結構差不多全。

同步方式

對於put操作,如果Key對應的數組元素為null,則通過CAS操作將其設置為當前值。如果Key對應的數組元素(也即鏈表表頭或者樹的根元素)不為null,則對該元素使用synchronized關鍵字申請鎖,然后進行操作。如果該put操作使得當前鏈表長度超過一定閾值,則將該鏈表轉換為樹,從而提高尋址效率。

對於讀操作,由於數組被volatile關鍵字修飾,因此不用擔心數組的可見性問題。同時每個元素是一個Node實例(Java 7中每個元素是一個HashEntry),它的Key值和hash值都由final修飾,不可變更,無須關心它們被修改后的可見性問題。而其Value及對下一個元素的引用由volatile修飾,可見性也有保障

 

HashMap和ConcurrentHashMap對比:

  • HashMap非線程安全、ConcurrentHashMap線程安全
  • HashMap允許Key與Value為空,ConcurrentHashMap不允許
  • HashMap不允許通過迭代器遍歷的同時修改,ConcurrentHashMap允許。並且更新可見

 

 HashMap和HashTable的對比:

(1)HashMap是非線程安全的,HashTable是線程安全的。

(2)HashMap的鍵和值都允許有null存在,而HashTable則都不行。

(3)因為線程安全、哈希效率的問題,HashMap效率比HashTable的要高。

 

HashTable和ConcurrentHashMap對比:

HashTable里使用的是synchronized關鍵字,這其實是對對象加鎖,鎖住的都是對象整體,當Hashtable的大小增加到一定的時候,性能會急劇下降,因為迭代時需要被鎖定很長的時間。ConcurrentHashMap相對於HashTable的syn關鍵字鎖的粒度更精細了一些,並發性能更好。

 問題:在put的時候是放在鏈表頭部還是尾部?

jdk1.7之前是放在鏈表頭部在jdk1.8之后是放在尾部。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM