併發編程專題九-併發容器ConcurrentHashMap源碼分析

在上一章中我們講到在高併發下,使用hashMap會導致一系列的問題。那麼我們當我們需要使用類似於hashMap那樣的存儲集合類的時候,我們該怎麼做呢?

一、併發容器

Java的集合容器框架中,主要有四大類別:List、Set、Queue、Map,大家熟知的這些集合類ArrayList、LinkedList、HashMap這些容器都是非線程安全的。

如果有多個線程併發地訪問這些容器時,就會出現問題。因此,在編寫程序時,在多線程環境下必須要求程序員手動地在任何訪問到這些容器的地方進行同步處理,這樣導致在使用這些容器的時候非常地不方便。

併發類容器是專門針對多線程併發設計的,使用了鎖分段技術,只對操作的位置進行同步操作,但是其他沒有操作的位置其他線程仍然可以訪問,提高了程序的吞吐量。

採用了CAS算法和部分代碼使用synchronized鎖保證線程安全。

二、ConcurrentHashMap實現分析

2.1 1.7下的實現

2.1.1 構造方法和初始化

ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖(ReentrantLock),在ConcurrentHashMap裏扮演鎖的角色;HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組。Segment的結構和HashMap類似,是一種數組和鏈表結構。一個Segment裏包含一個HashEntry數組,每個HashEntry是一個鏈表結構的元素,每個Segment守護着一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先獲得與它對應的Segment鎖。數據結構如下

2.1.2 構造方法

ConcurrentHashMap初始化方法是通過initialCapacity、loadFactor和concurrencyLevel(參數concurrencyLevel是用戶估計的併發級別,就是說你覺得最多有多少線程共同修改這個map,根據這個來確定Segment數組的大小concurrencyLevel默認是DEFAULT_CONCURRENCY_LEVEL = 16;)等幾個參數來初始化segment數組、段偏移量segmentShift、段掩碼segmentMask和每個segment裏的HashEntry數組來實現的。

併發級別可以理解爲程序運行時能夠同時更新ConccurentHashMap且不產生鎖競爭的最大線程數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的數組長度。ConcurrentHashMap默認的併發度爲16,但用戶也可以在構造函數中設置併發度。當用戶設置併發度時,ConcurrentHashMap會使用大於等於該值的最小2冪指數作爲實際併發度(假如用戶設置併發度爲17,實際併發度則爲32)。

如果併發度設置的過小,會帶來嚴重的鎖競爭問題;如果併發度設置的過大,原本位於同一個Segment內的訪問會擴散到不同的Segment中,CPU cache命中率會下降,從而引起程序性能下降。(文檔的說法是根據你併發的線程數量決定,太多會導性能降低)

segments數組的長度ssize是通過concurrencyLevel計算得出的。爲了能通過按位與的散列算法來定位segments數組的索引,必須保證segments數組的長度是2的N次方(power-of-two size),所以必須計算出一個大於或等於concurrencyLevel的最小的2的N次方值來作爲segments數組的長度。假如concurrencyLevel等於14、15或16,ssize都會等於16,即容器裏鎖的個數也是16。

輸入參數initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每個segment的負載因子,在構造方法裏需要通過這兩個參數來初始化數組中的每個segment。上面代碼中的變量cap就是segment裏HashEntry數組的長度,它等於initialCapacity除以ssize的倍數c,如果c大於1,就會取大於等於c的2的N次方值,所以segment裏HashEntry數組的長度不是1,就是2的N次方。

在默認情況下, ssize = 16,initialCapacity = 16,loadFactor = 0.75f,那麼cap = 1,threshold = (int) cap * loadFactor = 0。

既然ConcurrentHashMap使用分段鎖Segment來保護不同段的數據,那麼在插入和獲取元素的時候,必須先通過散列算法定位到Segment。ConcurrentHashMap會首先使用Wang/Jenkins hash的變種算法對元素的hashCode進行一次再散列。

ConcurrentHashMap完全允許多個讀操作併發進行,讀操作並不需要加鎖。ConcurrentHashMap實現技術是保證HashEntry幾乎是不可變的以及volatile關鍵字。

2.1.3 get操作

get操作先經過一次再散列,然後使用這個散列值通過散列運算定位到Segment(使用了散列值的高位部分),再通過散列算法定位到table(使用了散列值的全部)。整個get過程,沒有加鎖,而是通過volatile保證get總是可以拿到最新值。

2.1.4 put操作

ConcurrentHashMap 初始化的時候會初始化第一個槽 segment[0],對於其他槽,在插入第一個值的時候再進行初始化。

ensureSegment方法考慮了併發情況,多個線程同時進入初始化同一個槽 segment[k],但只要有一個成功就可以了。

具體實現是

put方法會通過tryLock()方法嘗試獲得鎖,獲得了鎖,node爲null進入try語句塊,沒有獲得鎖,調用scanAndLockForPut方法自旋等待獲得鎖。

scanAndLockForPut方法裏在嘗試獲得鎖的過程中會對對應hashcode的鏈表進行遍歷,如果遍歷完畢仍然找不到與key相同的HashEntry節點,則爲後續的put操作提前創建一個HashEntry。當tryLock一定次數後仍無法獲得鎖,則通過lock申請鎖。

在獲得鎖之後,Segment對鏈表進行遍歷,如果某個HashEntry節點具有相同的key,則更新該HashEntry的value值,

否則新建一個HashEntry節點,採用頭插法,將它設置爲鏈表的新head節點並將原頭節點設爲新head的下一個節點。新建過程中如果節點總數(含新建的HashEntry)超過threshold,則調用rehash()方法對Segment進行擴容,最後將新建HashEntry寫入到數組中。

2.1.5 rehash操作

擴容是新創建了數組,然後進行遷移數據,最後再將 newTable 設置給屬性 table。

爲了避免讓所有的節點都進行復制操作:由於擴容是基於2的冪指來操作,假設擴容前某HashEntry對應到Segment中數組的index爲i,數組的容量爲capacity,那麼擴容後該HashEntry對應到新數組中的index只可能爲i或者i+capacity,因此很多HashEntry節點在擴容前後index可以保持不變。

假設原來table長度爲4,那麼元素在table中的分佈是這樣的

擴容後table長度變爲8,那麼元素在table中的分佈變成:

可以看見 hash值爲34和56的下標保持不變,而15,23,77的下標都是在原來下標的基礎上+4即可,可以快速定位和減少重排次數。

該方法沒有考慮併發,因爲執行該方法之前已經獲取了鎖。

2.1.6 remove操作

與put方法類似,都是在操作前需要拿到鎖,以保證操作的線程安全性。

2.1.7 ConcurrentHashMap的弱一致性

然後對鏈表遍歷判斷是否存在key相同的節點以及獲得該節點的value。但由於遍歷過程中其他線程可能對鏈表結構做了調整,因此get和containsKey返回的可能是過時的數據,這一點是ConcurrentHashMap在弱一致性上的體現。如果要求強一致性,那麼必須使用Collections.synchronizedMap()方法。

2.1.8 size、containsValue

這些方法都是基於整個ConcurrentHashMap來進行操作的,他們的原理也基本類似:首先不加鎖循環執行以下操作:循環所有的Segment,獲得對應的值以及所有Segment的modcount之和。在 put、remove 和 clean 方法裏操作元素前都會將變量 modCount 進行變動,如果連續兩次所有Segment的modcount和相等,則過程中沒有發生其他線程修改ConcurrentHashMap的情況,返回獲得的值。

當循環次數超過預定義的值時,這時需要對所有的Segment依次進行加鎖,獲取返回值後再依次解鎖。所以一般來說,應該避免在多線程環境下使用size和containsValue方法。

2.2 在1.8下的實現

2.2.1 改進

改進一:取消segments字段,直接採用transient volatile HashEntry<K,V>[] table保存數據,採用table數組元素作爲鎖,從而實現了對縮小鎖的粒度,進一步減少併發衝突的概率,並大量使用了採用了 CAS + synchronized 來保證併發安全性。

改進二:將原先table數組+單向鏈表的數據結構,變更爲table數組+單向鏈表+紅黑樹的結構。對於hash表來說,最核心的能力在於將key hash之後能均勻的分佈在數組中。如果hash之後散列的很均勻,那麼table數組中的每個隊列長度主要爲0或者1。但實際情況並非總是如此理想,雖然ConcurrentHashMap類默認的加載因子爲0.75,但是在數據量過大或者運氣不佳的情況下,還是會存在一些隊列長度過長的情況,如果還是採用單向列表方式,那麼查詢某個節點的時間複雜度爲O(n);因此,對於個數超過8(默認值)的列表,jdk1.8中採用了紅黑樹的結構,那麼查詢的時間複雜度可以降低到O(logN),可以改進性能。

使用 Node(1.7 爲 Entry) 作爲鏈表的數據結點,仍然包含 key,value,hash 和 next 四個屬性。 紅黑樹的情況使用的是 TreeNode(extends Node)。

根據數組元素中,第一個結點數據類型是 Node 還是 TreeNode 可以判斷該位置下是鏈表還是紅黑樹。

用於判斷是否需要將鏈表轉換爲紅黑樹的閾值

用於判斷是否需要將紅黑樹轉換爲鏈表的閾值

2.2.2 核心數據結構和屬性

Node

Node是最核心的內部類,它包裝了key-value鍵值對。

定義基本和1.7中的HashEntry相同。而這個map本身所持有的也是一個Node型的數組

增加了一個find方法來用以輔助map.get()方法。其實就是遍歷鏈表,子類中會覆蓋這個方法。

在map中還定義了Segment這個類,不過只是爲了向前兼容而已,不做過多考慮。

TreeNode

樹節點類,另外一個核心的數據結構。當鏈表長度過長的時候,會轉換爲TreeNode。

與1.8中HashMap不同點:

1、它並不是直接轉換爲紅黑樹,而是把這些結點放在TreeBin對象中,由TreeBin完成對紅黑樹的包裝。

2、TreeNode在ConcurrentHashMap擴展自Node類,而並非HashMap中的擴展自LinkedHashMap.Entry<K,V>類,也就是說TreeNode帶有next指針。

TreeBin

負責TreeNode節點。它代替了TreeNode的根節點,也就是說在實際的ConcurrentHashMap“數組”中,存放的是TreeBin對象,而不是TreeNode對象。另外這個類還帶有了讀寫鎖機制。

特殊的ForwardingNode

一個特殊的 Node 結點,hash 值爲 -1,其中存儲 nextTable 的引用。有 table 發生擴容的時候,ForwardingNode 發揮作用,作爲一個佔位符放在 table 中表示當前結點爲 null 或者已經被移動。

sizeCtl

用來控制 table 的初始化和擴容操作。

負數代表正在進行初始化或擴容操作

  • -1代表正在初始化
  • -N 表示有N-1個線程正在進行擴容操作

0爲默認值,代表當時的table還沒有被初始化

正數表示初始化大小或Map中的元素達到這個數量時,需要進行擴容了。

2.2.3 核心方法

2.2.4 構造方法

可以發現,在new出一個map的實例時,並不會創建其中的數組等等相關的部件,只是進行簡單的屬性設置而已,同樣的,table的大小也被規定爲必須是2的乘方數。

真正的初始化在放在了是在向ConcurrentHashMap中插入元素的時候發生的。如調用put、computeIfAbsent、compute、merge等方法的時候,調用時機是檢查table==null。

2.2.5 get操作

get方法比較簡單,給定一個key來確定value的時候,必須滿足兩個條件  key相同  hash值相同,對於節點可能在鏈表或樹上的情況,需要分別去查找。

2.2.6 put操作

 

總結來說,put方法就是,沿用HashMap的put方法的思想,根據hash值計算這個新插入的點在table中的位置i,如果i位置是空的,直接放進去,否則進行判斷,如果i位置是樹節點,按照樹的方式插入新的節點,否則把i插入到鏈表的末尾。

整體流程上,就是首先定義不允許key或value爲null的情況放入  對於每一個放入的值,首先利用spread方法對key的hashcode進行一次hash計算,由此來確定這個值在table中的位置。

如果這個位置是空的,那麼直接放入,而且不需要加鎖操作。

 如果這個位置存在結點,說明發生了hash碰撞,首先判斷這個節點的類型。如果是鏈表節點,則得到的結點就是hash值相同的節點組成的鏈表的頭節點。需要依次向後遍歷確定這個新加入的值所在位置。如果遇到hash值與key值都與新加入節點是一致的情況,則只需要更新value值即可。否則依次向後遍歷,直到鏈表尾插入這個結點。如果加入這個節點以後鏈表長度大於8,就把這個鏈表轉換成紅黑樹。如果這個節點的類型已經是樹節點的話,直接調用樹節點的插入方法進行插入新的值。

2.2.7 初始化

前面說過,構造方法中並沒有真正初始化,真正的初始化在放在了是在向ConcurrentHashMap中插入元素的時候發生的。具體實現的方法就是initTable

2.2.8 transfer

當ConcurrentHashMap容量不足的時候,需要對table進行擴容。這個方法的基本思想跟HashMap是很像的,但是由於它是支持併發擴容的,所以要複雜的多。我們不深入源碼去講述,只講述其大概原理。

爲何要併發擴容?因爲在擴容的時候,總是會涉及到從一個“數組”到另一個“數組”拷貝的操作,如果這個操作能夠併發進行,就能利用併發處理去減少擴容帶來的時間影響。

整個擴容操作分爲兩個部分:

 第一部分是構建一個nextTable,它的容量是原來的2倍。

第二個部分就是將原來table中的元素複製到nextTable中,這裏允許多線程進行操作。

整個擴容流程就是遍歷和複製:

爲null或者已經處理過的節點,會被設置爲forwardNode節點,當線程準備擴容時,發現節點是forwardNode節點,跳過這個節點,繼續尋找未處理的節點,找到了,對節點上鎖,

如果這個位置是Node節點(fh>=0),說明它是一個鏈表,就構造一個反序鏈表,把他們分別放在nextTable的i和i+n的位置上

如果這個位置是TreeBin節點(fh<0),也做一個反序處理,並且判斷是否需要紅黑樹轉鏈表,把處理的結果分別放在nextTable的i和i+n的位置上

遍歷過所有的節點以後就完成了複製工作,這時讓nextTable作爲新的table,並且更新sizeCtl爲新容量的0.75倍 ,完成擴容。

併發擴容其實就是將數據遷移任務拆分成多個小遷移任務,在實現上使用了一個變量stride作爲步長控制,每個線程每次負責遷移其中的一部分。

2.2.9 emove方法

移除方法的基本流程和put方法很類似,只不過操作由插入數據變爲移除數據而已,而且如果存在紅黑樹的情況下,會檢查是否需要將紅黑樹轉爲鏈表的步驟。不再重複講述。

2.2.10 treeifyBin方法

用於將過長的鏈表轉換爲TreeBin對象。但是他並不是直接轉換,而是進行一次容量判斷,如果容量沒有達到轉換的要求,直接進行擴容操作並返回;如果滿足條件纔將鏈表的結構轉換爲TreeBin ,這與HashMap不同的是,它並沒有把TreeNode直接放入紅黑樹,而是利用了TreeBin這個小容器來封裝所有的TreeNode。

2.2.11 size方法

在JDK1.8版本中,對於size的計算,在擴容和addCount()方法就已經有處理了,可以注意一下Put函數,裏面就有addCount()函數,早就計算好的,然後你size的時候直接給你。JDK1.7是在調用size()方法纔去計算,其實在併發集合中去計算size是沒有多大的意義的,因爲size是實時在變的。

在具體實現上,計算大小的核心方法都是 sumCount()

可以看見,統計數量時使用了 baseCount、和CounterCell 類型的變量counterCells 。其實baseCount就是記錄容器數量的,而counterCells則是記錄CAS更新baseCounter值時,由於高併發而導致失敗的值。這兩個變量的變化在addCount() 方法中有體現,大致的流程就是:

1、對 baseCount 做 CAS 自增操作。

2、如果併發導致 baseCount CAS 失敗了,則使用 counterCells。

3、如果counterCells CAS 失敗了,在 fullAddCount 方法中,會繼續死循環操作,直到成功。

三 HashTable

HashTable容器你點進去會發現,純粹是hashMaP方法中添加了synchronized,來保證線程安全。在線程競爭激烈的情況下HashTable的效率非常低下。因爲當一個線程訪問HashTable的同步方法,其他線程也訪問HashTable的同步方法時,會進入阻塞或輪詢狀態。如線程1使用put進行元素添加,線程2不但不能使用put方法添加元素,也不能使用get方法來獲取元素,所以競爭越激烈效率越低。

四 常見問題

QHashMap HashTable 有什麼區別?

A:①、HashMap 是線程不安全的,HashTable 是線程安全的;

②、由於線程安全,所以 HashTable 的效率比不上 HashMap;

③、HashMap最多隻允許一條記錄的鍵爲null,允許多條記錄的值爲null,而 HashTable 不允許;

④、HashMap 默認初始化數組的大小爲16,HashTable 爲 11,前者擴容時,擴大兩倍,後者擴大兩倍+1;

⑤、HashMap 需要重新計算 hash 值,而 HashTable 直接使用對象的 hashCode

 

QJava 中的另一個線程安全的與 HashMap 極其類似的類是什麼?同樣是線程安全,它與 HashTable 在線程同步上有什麼不同?

A:ConcurrentHashMap 類(是 Java併發包 java.util.concurrent 中提供的一個線程安全且高效的 HashMap 實現)。

HashTable 是使用 synchronize 關鍵字加鎖的原理(就是對對象加鎖);

而針對 ConcurrentHashMap,在 JDK 1.7 中採用分段鎖的方式;JDK 1.8 中直接採用了CAS(無鎖算法)+ synchronized,也採用分段鎖的方式並大大縮小了鎖的粒度。

 

HashMap & ConcurrentHashMap 的區別?

A:除了加鎖,原理上無太大區別。

另外,HashMap 的鍵值對允許有null,但是ConCurrentHashMap 都不允許。

在數據結構上,紅黑樹相關的節點類

 

Q:爲什麼 ConcurrentHashMap  HashTable 效率要高?

A:HashTable 使用一把鎖(鎖住整個鏈表結構)處理併發問題,多個線程競爭一把鎖,容易阻塞;

ConcurrentHashMap 

JDK 1.7 中使用分段鎖(ReentrantLock + Segment + HashEntry),相當於把一個 HashMap 分成多個段,每段分配一把鎖,這樣支持多線程訪問。鎖粒度:基於 Segment,包含多個 HashEntry。

JDK 1.8 中使用 CAS + synchronized + Node + 紅黑樹。鎖粒度:Node(首結點)(實現 Map.Entry<K,V>)。鎖粒度降低了。

 

Q:針對 ConcurrentHashMap 鎖機制具體分析(JDK 1.7 VS JDK 1.8)?

JDK 1.7 中,採用分段鎖的機制,實現併發的更新操作,底層採用數組+鏈表的存儲結構,包括兩個核心靜態內部類 Segment 和 HashEntry。

①、Segment 繼承 ReentrantLock(重入鎖) 用來充當鎖的角色,每個 Segment 對象守護每個散列映射表的若干個桶;

②、HashEntry 用來封裝映射表的鍵-值對;

③、每個桶是由若干個 HashEntry 對象鏈接起來的鏈表。

JDK 1.8 中,採用Node + CAS + Synchronized來保證併發安全。取消類 Segment,直接用 table 數組存儲鍵值對;當 HashEntry 對象組成的鏈表長度超過 TREEIFY_THRESHOLD 時,鏈表轉換爲紅黑樹,提升性能。底層變更爲數組 + 鏈表 + 紅黑樹。

 

QConcurrentHashMap JDK 1.8 中,爲什麼要使用內置鎖 synchronized 來代替重入鎖 ReentrantLock

A:

1、JVM 開發團隊在1.8中對 synchronized做了大量性能上的優化,而且基於 JVM 的 synchronized 優化空間更大,更加自然。

2、在大量的數據操作下,對於 JVM 的內存壓力,基於 API  的 ReentrantLock 會開銷更多的內存。

 

QConcurrentHashMap 簡單介紹?

A:

①、重要的常量:

private transient volatile int sizeCtl;

當爲負數時,-1 表示正在初始化,-N 表示 N - 1 個線程正在進行擴容;

當爲 0 時,表示 table 還沒有初始化;

當爲其他正數時,表示初始化或者下一次進行擴容的大小。

②、數據結構:

Node 是存儲結構的基本單元,繼承 HashMap 中的 Entry,用於存儲數據;

TreeNode 繼承 Node,但是數據結構換成了二叉樹結構,是紅黑樹的存儲結構,用於紅黑樹中存儲數據;

TreeBin 是封裝 TreeNode 的容器,提供轉換紅黑樹的一些條件和鎖的控制。

③、存儲對象時(put() 方法):

1.如果沒有初始化,就調用 initTable() 方法來進行初始化;

2.如果沒有 hash 衝突就直接 CAS 無鎖插入;

3.如果需要擴容,就先進行擴容;

4.如果存在 hash 衝突,就加鎖來保證線程安全,兩種情況:一種是鏈表形式就直接遍歷到尾端插入,一種是紅黑樹就按照紅黑樹結構插入;

5.如果該鏈表的數量大於閥值 8,就要先轉換成紅黑樹的結構,break 再一次進入循環

6.如果添加成功就調用 addCount() 方法統計 size,並且檢查是否需要擴容。

④、擴容方法 transfer():默認容量爲 16,擴容時,容量變爲原來的兩倍。

helpTransfer():調用多個工作線程一起幫助進行擴容,這樣的效率就會更高。

⑤、獲取對象時(get()方法):

1.計算 hash 值,定位到該 table 索引位置,如果是首結點符合就返回;

2.如果遇到擴容時,會調用標記正在擴容結點 ForwardingNode.find()方法,查找該結點,匹配就返回;

3.以上都不符合的話,就往下遍歷結點,匹配就返回,否則最後就返回 null。

 

QConcurrentHashMap 的併發度是什麼?

A:1.7中程序運行時能夠同時更新 ConccurentHashMap 且不產生鎖競爭的最大線程數。默認爲 16,且可以在構造函數中設置。當用戶設置併發度時,ConcurrentHashMap 會使用大於等於該值的最小2冪指數作爲實際併發度(假如用戶設置併發度爲17,實際併發度則爲32)。

1.8中併發度則無太大的實際意義了,主要用處就是當設置的初始容量小於併發度,將初始容量提升至併發度大小。

五、總結

本章主要介紹ConcurrentHashMap的源碼以及與HashMap的區別。以及爲什麼平時我們都不用HashTable。希望大家可以通過本章學習到分段表的設計思想以及以及前面學習到的cas操作的應用,瞭解容器和它的方法,以及懂得在合適場景選擇最合適的容器和方法。

——————————————————————分割線————————————————————————————

下一節將給大家介紹下其他的併發容器,同時也是併發專題的最後一篇。後續如果有了解到新的知識點會繼續更新相關專題。以及對以前老的博客進行更新,使其更加細緻。讓每個開發者都能看懂。

其他閱讀   

併發編程專題八-hashMap死循環分析

併發編程專題七-什麼是線程安全

併發編程專題六-線程池的使用與原理

併發編程專題五-AbstractQueuedSynchronizer源碼分析

併發編程專題四-原子操作和顯示鎖

併發編程專題三-JAVA線程的併發工具類

併發編程專題二-線程間的共享和協作

併發編程專題一-線程相關基礎概念

大家有問題可以加我微信哈~

481021518c4b8fa00ba60ef9609c53b2b5f.jpg

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