ConcurrentHashMap

前言

HashMap是平時開發過程中用的比較多的集合,但它是非線程安全的,在涉及到多線程併發的情況,執行get方法有可能會引起循環遍歷(前提是其它線程的put方法引起了resize動作),導致CPU利用率接近100%

解決方案有Hashtable和Collections.synchronizedMap(hashMap),不過這兩個方案基本上是對讀寫進行加鎖操作,一個線程在讀寫元素,其餘線程必須等待,有嚴重的性能問題,在高性能服務中,當然不能容忍。

所以,Doug Lea帶來了併發安全的ConcurrentHashMap,本文分析的源碼是JDK1.8的版本,與之前的版本有較大差異。

JDK1.6分析

在1.6版本中,採用分段鎖的機制,實現併發的更新操作,底層採用"數組+鏈表+紅黑樹"的存儲結構,其包含兩個核心靜態內部類 Segment和HashEntry。
(1) Segment繼承ReentrantLock用來充當鎖的角色,每個 Segment 對象守護每個散列映射表的若干個桶。
(2) HashEntry 用來封裝映射表的鍵 / 值對;
(3) 每個桶是由若干個 HashEntry 對象鏈接起來的鏈表。
(4) 一個 ConcurrentHashMap 實例中包含由若干個 Segment 對象組成的數組,下面通過一個圖來演示一下 ConcurrentHashMap 的結構:

JDK1.8分析

在1.8的實現中,已經拋棄了Segment分段鎖機制,利用CAS+Synchronized來保證併發更新的安全,底層依然採用"數組+鏈表+紅黑樹"的存儲結構。

重要概念

  • table:默認爲null,初始化發生在第一次插入操作,默認大小爲16的數組,用來存儲Node節點數據,擴容時大小總是2的冪次方。
  • nextTable:默認爲null,擴容時新生成的數組,其大小爲原數組的兩倍。
  • sizeCtl :默認爲0,用來控制table的初始化和擴容操作,具體應用在後續會體現出來。
    • -1 : 代表table正在初始化
    • -N: 表示有N-1個線程正在進行擴容操作
      其餘情況:
      (a) 如果table未初始化,表示table需要初始化的大小。
      (b) 如果table初始化完成,表示table的容量,默認是table大小的0.75倍,居然用這個公式算0.75(n - (n >>> 2))
  • Node:保存key,value及key的hash值的數據結構。其中value和next都用volatile修飾,保證併發的可見性。
  • ForwardingNode:一個特殊的Node節點,hash值爲-1,其中存儲nextTable的引用
    只有table發生擴容的時候,ForwardingNode纔會發揮作用,作爲一個佔位符放在table中表示當前節點爲null或則已經被移動。

實例初始化

實例化ConcurrentHashMap時帶參數時,會根據參數調整table的大小,假設參數爲100,最終會調整成256,確保table的大小總是2的冪次方,算法如下:

注意,ConcurrentHashMap在構造函數中只會初始化sizeCtl值,並不會直接初始化table,而是延緩到第一次put操作。

table初始化

前面已經提到過,table初始化操作會延緩到第一次put行爲。但是put是可以併發執行的,Doug Lea是如何實現table只初始化一次的?讓我們來看看源碼的實現。

sizeCtl默認爲0,如果ConcurrentHashMap實例化時有傳參數,sizeCtl會是一個2的冪次方的值。所以執行第一次put操作的線程會執行Unsafe.compareAndSwapInt方法修改sizeCtl爲-1,有且只有一個線程能夠修改成功,其它線程通過Thread.yield()讓出CPU時間片等待table初始化完成。

put操作

假設table已經初始化完成,put操作採用CAS+synchronized實現併發插入或更新操作,具體實現如下。

  • hash算法

  • table中定位索引位置,n是table的大小

  • 獲取table中對應索引的元素f。 Doug Lea採用Unsafe.getObjectVolatile來獲取,也許有人質疑,直接table[index]不可以麼,爲什麼要這麼複雜? 在java內存模型中,我們已經知道每個線程都有一個工作內存,裏面存儲着table的副本,雖然table是volatile修飾的,但不能保證線程每次都拿到table中的最新元素,Unsafe.getObjectVolatile可以直接獲取指定內存的數據,保證了每次拿到數據都是最新的。

  • 如果f爲null,說明table中這個位置第一次插入元素,利用Unsafe.compareAndSwapObject方法插入Node節點。
    (a) 如果CAS成功,說明Node節點已經插入,隨後addCount(1L, binCount)方法會檢查當前容量是否需要進行擴容。(break出來)
    (b) 如果CAS失敗,說明有其它線程提前插入了節點,自旋重新嘗試在這個位置插入節點。(for 自旋一遍)

  • 如果f的hash值爲-1,說明當前f是ForwardingNode節點,意味有其它線程正在擴容,則一起進行擴容操作。

  • 其餘情況把新的Node節點按鏈表或紅黑樹的方式插入到合適的位置,這個過程採用同步內置鎖實現併發,代碼如下:

    在節點f上進行同步,節點插入之前,再次利用tabAt(tab, i) == f判斷,防止被其它線程修改。
    (a) 如果f.hash >= 0,說明f是鏈表結構的頭結點,遍歷鏈表,如果找到對應的node節點,則修改value,否則在鏈表尾部加入節點。
    (b) 如果f是TreeBin類型節點,說明f是紅黑樹根節點,則在樹結構上遍歷元素,更新或增加節點。
    © 如果鏈表中節點數binCount >= TREEIFY_THRESHOLD(默認是8),則把鏈表轉化爲紅黑樹結構。

table擴容

  • 當table容量不足的時候,即table的元素數量達到容量閾值sizeCtl,需要對table進行擴容。 整個擴容分爲兩部分:
    (1) 構建一個nextTable,大小爲table的兩倍。
    (2) 把table的數據複製到nextTable中。

  • 這兩個過程在單線程下實現很簡單,但是ConcurrentHashMap是支持併發插入的,擴容操作自然也會有併發的出現,這種情況下,第二步可以支持節點的併發複製,這樣性能自然提升不少,但實現的複雜度也上升了一個臺階。

    先看第一步,構建nextTable,毫無疑問,這個過程只能由單個線程進行nextTable的初始化,具體實現如下:

    通過Unsafe.compareAndSwapInt修改sizeCtl值,保證只有一個線程能夠初始化nextTable,擴容後的數組長度爲原來的兩倍,但是容量是原來的1.5。

  • 節點從table移動到nextTable,大體思想是遍歷、複製的過程。
    (a) 首先根據運算得到需要遍歷的次數i,然後利用tabAt方法獲得i位置的元素f,初始化一個forwardNode實例fwd。
    (b) 如果f == null,則在table中的i位置放入fwd,這個過程是採用Unsafe.compareAndSwapObject方法實現的,很巧妙的實現了節點的併發移動。
    © 如果f是鏈表的頭節點,就構造一個反序鏈表,把他們分別放在nextTable的i和i+n的位置上,移動完成,採用Unsafe.putObjectVolatile方法給table原位置賦值fwd。
    (d) 如果f是TreeBin節點,也做一個反序處理,並判斷是否需要untreeify,把處理的結果分別放在nextTable的i和i+n的位置上,移動完成,同樣採用Unsafe.putObjectVolatile方法給table原位置賦值fwd。

    遍歷過所有的節點以後就完成了複製工作,把table指向nextTable,並更新sizeCtl爲新數組大小的0.75倍 ,擴容完成。

紅黑樹構造

注意:如果鏈表結構中元素超過TREEIFY_THRESHOLD閾值,默認爲8個,則把鏈表轉化爲紅黑樹,提高遍歷查詢效率。

接下來我們看看如何構造樹結構,代碼如下:

可以看出,生成樹節點的代碼塊是同步的,進入同步代碼塊之後,再次驗證table中index位置元素是否被修改過。
(a) 根據table中index位置Node鏈表,重新生成一個hd爲頭結點的TreeNode鏈表。
(b) 根據hd頭結點,生成TreeBin樹結構,並把樹結構的root節點寫到table的index位置的內存中,具體實現如下:

主要根據Node節點的hash值大小構建二叉樹。這個紅黑樹的構造過程實在有點複雜,感興趣的同學可以看看源碼。

get操作

get操作和put操作相比,顯得簡單了許多。

(1) 判斷table是否爲空,如果爲空,直接返回null。
(2) 計算key的hash值,並獲取指定table中指定位置的Node節點,通過遍歷鏈表或則樹結構找到對應的節點,返回value值。

總結

ConcurrentHashMap 是一個併發散列映射表的實現,它允許完全併發的讀取,並且支持給定數量的併發更新。相比於 HashTable 和同步包裝器包裝的 HashMap,使用一個全局的鎖來同步不同線程間的併發訪問,同一時間點,只能有一個線程持有鎖,也就是說在同一時間點,只能有一個線程能訪問容器,這雖然保證多線程間的安全併發訪問,但同時也導致對容器的訪問變成串行化的了。

1.6中採用ReentrantLock 分段鎖的方式,使多個線程在不同的segment上進行寫操作不會發現阻塞行爲;
1.8中直接採用了內置鎖synchronized,難道是因爲1.8的虛擬機對內置鎖已經優化的足夠快了?

文章推薦

多線程之併發容器ConcurrentHashMap(JDK1.6)
ConcurrentHashMap源碼分析_JDK1.8版本

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