1. 程式人生 > 實用技巧 >你真的瞭解【HashMap】麼?-二

你真的瞭解【HashMap】麼?-二

1、currentHashMap內部結構

(1)在JDK1.7版本中,CurrentHashMap的資料結構是由一個Segment陣列和多個HashEntry組成,每一個HashEntry可以看成一個HashMap(陣列+連結串列)

ConcurrentHashMap 與HashMap和Hashtable 最大的不同在於:   put和 get 兩次Hash到達指定的HashEntry,第一次hash到達Segment,第二次到達Segment裡面的Entry,然後在遍歷entry連結串列

實現併發原理:

  使用分段鎖技術,將資料分成一段一段的儲存,然後給每一段資料配一把鎖,當一個執行緒佔用鎖訪問其中一個段資料的時候,其他段的資料也能被其他執行緒訪問,能夠實現真正的併發訪問
(2)在JDK1.8版本中,摒棄了Segment的概念,而是直接用Node陣列+連結串列+紅黑樹的資料結構來實現(和HashMap資料結構一樣),同時值value和next採用了volatile去修飾,保證了可見性;實現了有序性(禁止進行指令重排序);volatile 只能保證對單次讀/寫的原子性,i++ 這種操作不能保證原子性。

實現併發原理:

  拋棄了原有的 Segment 分段鎖,而採用了 CAS + Synchronized 來保證併發安全性

  CAS(compare and swap)的縮寫,也就是我們說的比較交換,CAS是樂觀鎖的一種實現方式,是一種輕量級鎖,java的鎖中分為樂觀鎖和悲觀鎖:

    悲觀鎖是指將資源鎖住,等待當前佔用鎖的執行緒釋放掉鎖,另一個執行緒才能夠獲取鎖,訪問資源

    樂觀鎖是通過某種方式不加鎖,比如說新增version欄位來獲取資料

  CAS操作包含三個運算元(記憶體位置,預期的原值,和新值)。如果記憶體的值和預期的原值是一致的,那麼就轉化為新值,CAS是通過不斷的迴圈來獲取新值的,如果執行緒中的值被另一個執行緒修改了,那麼執行緒就需要自旋,到下次迴圈才有可能執行 。

  CAS缺點:

  (1)ABA問題

      CAS對於ABA問題無法判斷是否有執行緒修改過資料(ABA問題:原始資料A,執行緒1將其修改為B,執行緒2又將其修改為A),其實很多場景如果只追求最後結果正確,這是沒有影響,但是實際過程中還是需要記錄修改過程的,比如資金修改,你每次修改的都應該有記錄,方便回溯;

      解決:修改前去查詢他原來的值的時候再帶一個版本號或者時間戳,判斷成功後更新版本號或者時間戳

  (2)迴圈時間長開銷大

      自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷

  (3)只能保證一個共享變數的原子操作

      當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,

      這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作

2、currentHashMap的put、get、size方法

JDK1.7

  get方法:不需要加鎖,非常高效,只需要將Key通過Hash之後定位到具體的Segment,再通過一次Hash定位到具體的元素上。由於HashEntry中的value屬性是用volatile關鍵詞修飾的,保證了記憶體可見性,所以每次獲取時都是最新值。

  put方法:需要加鎖,雖然 HashEntry 中的 value 是用volatile關鍵詞修飾的,但是並不能保證併發的原子性,所以需要加鎖(在 put 之前會進行一次擴容校驗,HashMap是插入元素之後再看是否需要擴容,有可能擴容之後後續就沒有插入操作了)

  size方法:累加每個Segment的count值,因為是併發操作,在你計算size的時候,他還在併發的插入資料,可能會導致你計算出來的size和你實際的size有相差,1.7中有兩種解決方案  

    1、使用不加鎖的模式去嘗試多次計算ConcurrentHashMap的size,最多三次,比較前後兩次計算的結果,結果一致就認為當前沒有元素加入,計算的結果是準確的
    2、如果第一種方案不符合,他就會給每個Segment加上鎖,然後計算ConcurrentHashMap的size返回

JDK1.8

  get方法:根據計算出來的 hashcode 定址,如果就在桶上那麼直接返回值,如果是紅黑樹那就按照樹的方式獲取值,都不滿足那就按照連結串列的方式遍歷獲取值

  put方法: 

  1. 根據 key 計算出 hashcode 。
  2. 判斷是否需要進行初始化。
  3. 根據當前 key 定位出對應 Node,如果為空表示當前位置可以寫入資料,利用 CAS 嘗試寫入,失敗則自旋保證成功。
  4. 如果當前位置的hashcode == MOVED == -1,則需要進行擴容。
  5. 如果都不滿足,則利用 synchronized 鎖寫入資料。
  6. 如果數量大於TREEIFY_THRESHOLD則要轉換為紅黑樹

  size方法:對於size的計算,在擴容和addCount()方法就已經有處理了,只需要對 baseCount 和 counterCell 進行 CAS 計算,最終通過 baseCount 和 遍歷 CounterCell 陣列得出 size

3、擴容

  JDK1.7

    在Segment的鎖的保護下進行擴容的,不需要關注併發問題

    Segment陣列不能擴容,對segment陣列中某一個HashEntry陣列進行擴容,擴大為原來的2倍

    先對陣列的長度增加一倍,然後遍歷原來的舊的table陣列,把每一個數組元素也就是Node連結串列遷移到新的數組裡面,最後遷移完畢之後,把新陣列的引用直接替換舊的

  JDK1.8

    擴容支援併發遷移節點,每個執行緒獲取一部分桶的遷移任務,如果當前執行緒的任務完成,檢視是否還有未遷移的桶,若有則繼續領取任務執行,若沒有則退出。在退出時需要檢查是否還有其他執行緒在參與遷移工作,如果有則自己什麼也不做直接退出,如果沒有了則執行最終的收尾工作;

    從old陣列的尾部開始,如果該桶被其他執行緒處理過了,就建立一個 ForwardingNode 放到該桶的首節點,hash值為-1,其他執行緒判斷hash值為-1後就知道該桶被處理過了

4、1.8為什麼用synchronized

  1.因為鎖的粒度降低了,在相對而言的低粒度加鎖方式,synchronized並不比ReentrantLock差,在粗粒度加鎖中ReentrantLock可能通過Condition來控制各個低粒度的邊界,更加的靈活,而在低粒度中,Condition的優勢就沒有了。

  2.在大量的資料操作下,對於JVM的記憶體壓力,基於API的ReentrantLock會開銷更多的記憶體,雖然不是瓶頸,但是也是一個選擇依據。

  3.對於synchronized 獲取鎖的方式,JVM 使用了鎖升級的優化方式,就是先使用偏向鎖優先同一執行緒然後再次獲取鎖,如果失敗,就升級為CAS 輕量級鎖,如果失敗就會短暫自旋,防止執行緒被系統掛起。最後如果以上都失敗就升級為重量級鎖

感謝

  參考:

  https://my.oschina.net/pingpangkuangmo/blog/817973

  https://blog.csdn.net/qq_35190492/article/details/103589011