天天看點

ConcurrentHashMap總結

摘要: 本文主要參考網上Blog(詳見Reference)總結ConcurrentHashMap的各方面知識,友善複習

并發程式設計實踐中,ConcurrentHashMap是一個經常被使用的資料結構,相比于Hashtable以及Collections.synchronizedMap(),ConcurrentHashMap線上程安全的基礎上提供了更好的寫并發能力,但同時降低了對讀一緻性的要求(這點好像CAP理論啊 O(∩_∩)O)。ConcurrentHashMap的設計與實作非常精巧,大量的利用了volatile,final,CAS等lock-free技術來減少鎖競争對于性能的影響,無論對于Java并發程式設計的學習還是Java記憶體模型的了解,ConcurrentHashMap的設計以及源碼都值得非常仔細的閱讀與揣摩。

這篇日志記錄了自己對ConcurrentHashMap的一些總結,由于JDK6,7,8中實作都不同,需要分開闡述在不同版本中的ConcurrentHashMap。

并發度可以了解為程式運作時能夠同時更新ConccurentHashMap且不産生鎖競争的最大線程數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的數組長度。ConcurrentHashMap預設的并發度為16,但使用者也可以在構造函數中設定并發度。當使用者設定并發度時,ConcurrentHashMap會使用大于等于該值的最小2幂指數作為實際并發度(假如使用者設定并發度為17,實際并發度則為32)。運作時通過将key的高n位(n = 32 – segmentShift)和并發度減1(segmentMask)做位與運算定位到所在的Segment。segmentShift與segmentMask都是在構造過程中根據concurrency level被相應的計算出來。

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

和JDK6不同,JDK7中除了第一個Segment之外,剩餘的Segments采用的是延遲初始化的機制:每次put之前都需要檢查key對應的Segment是否為null,如果是則調用ensureSegment()以確定對應的Segment被建立。

ensureSegment可能在并發環境下被調用,但與想象中不同,ensureSegment并未使用鎖來控制競争,而是使用了Unsafe對象的getObjectVolatile()提供的原子讀語義結合CAS來確定Segment建立的原子性。代碼段如下:

和JDK6一樣,ConcurrentHashMap的put方法被代理到了對應的Segment(定位Segment的原理之前已經描述過)中。與JDK6不同的是,JDK7版本的ConcurrentHashMap在獲得Segment鎖的過程中,做了一定的優化 - 在真正申請鎖之前,put方法會通過tryLock()方法嘗試獲得鎖,在嘗試獲得鎖的過程中會對對應hashcode的連結清單進行周遊,如果周遊完畢仍然找不到與key相同的HashEntry節點,則為後續的put操作提前建立一個HashEntry。當tryLock一定次數後仍無法獲得鎖,則通過lock申請鎖。

需要注意的是,由于在并發環境下,其他線程的put,rehash或者remove操作可能會導緻連結清單頭結點的變化,是以在過程中需要進行檢查,如果頭結點發生變化則重新對表進行周遊。而如果其他線程引起了連結清單中的某個節點被删除,即使該變化因為是非原子寫操作(删除節點後連結後續節點調用的是Unsafe.putOrderedObject(),該方法不提供原子寫語義)可能導緻目前線程無法觀察到,但因為不影響周遊的正确性是以忽略不計。

之是以在擷取鎖的過程中對整個連結清單進行周遊,主要目的是希望周遊的連結清單被CPU cache所緩存,為後續實際put過程中的連結清單周遊操作提升性能。

在獲得鎖之後,Segment對連結清單進行周遊,如果某個HashEntry節點具有相同的key,則更新該HashEntry的value值,否則建立一個HashEntry節點,将它設定為連結清單的新head節點并将原頭節點設為新head的下一個節點。建立過程中如果節點總數(含建立的HashEntry)超過threshold,則調用rehash()方法對Segment進行擴容,最後将建立HashEntry寫入到數組中。

put方法中,連結新節點的下一個節點(HashEntry.setNext())以及将連結清單寫入到數組中(setEntryAt())都是通過Unsafe的putOrderedObject()方法來實作,這裡并未使用具有原子寫語義的putObjectVolatile()的原因是:JMM會保證獲得鎖到釋放鎖之間所有對象的狀态更新都會在鎖被釋放之後更新到主存,進而保證這些變更對其他線程是可見的。

相對于HashMap的resize,ConcurrentHashMap的rehash原理類似,但是Doug Lea為rehash做了一定的優化,避免讓所有的節點都進行複制操作:由于擴容是基于2的幂指來操作,假設擴容前某HashEntry對應到Segment中數組的index為i,數組的容量為capacity,那麼擴容後該HashEntry對應到新數組中的index隻可能為i或者i+capacity,是以大多數HashEntry節點在擴容前後index可以保持不變。基于此,rehash方法中會定位第一個後續所有節點在擴容後index都保持不變的節點,然後将這個節點之前的所有節點重排即可。這部分代碼如下:

和put類似,remove在真正獲得鎖之前,也會對連結清單進行周遊以提高緩存命中率。

get與containsKey兩個方法幾乎完全一緻:他們都沒有使用鎖,而是通過Unsafe對象的getObjectVolatile()方法提供的原子讀語義,來獲得Segment以及對應的連結清單,然後對連結清單周遊判斷是否存在key相同的節點以及獲得該節點的value。但由于周遊過程中其他線程可能對連結清單結構做了調整,是以get和containsKey傳回的可能是過時的資料,這一點是ConcurrentHashMap在弱一緻性上的展現。如果要求強一緻性,那麼必須使用Collections.synchronizedMap()方法。

這些方法都是基于整個ConcurrentHashMap來進行操作的,他們的原理也基本類似:首先不加鎖循環執行以下操作:循環所有的Segment(通過Unsafe的getObjectVolatile()以保證原子讀語義),獲得對應的值以及所有Segment的modcount之和。如果連續兩次所有Segment的modcount和相等,則過程中沒有發生其他線程修改ConcurrentHashMap的情況,傳回獲得的值。

當循環次數超過預定義的值時,這時需要對所有的Segment依次進行加鎖,擷取傳回值後再依次解鎖。值得注意的是,加鎖過程中要強制建立所有的Segment,否則容易出現其他線程建立Segment并進行put,remove等操作。代碼如下:

一般來說,應該避免在多線程環境下使用size和containsValue方法。

注1:modcount在put, replace, remove以及clear等方法中都會被修改。

注2:對于containsValue方法來說,如果在循環過程中發現比對value的HashEntry,則直接傳回true。

ConcurrentHashMap在JDK8中進行了巨大改動,很需要通過源碼來再次學習下Doug Lea的實作方法。

首先來看幾個重要的屬性,與HashMap相同的就不再介紹了,這裡重點解釋一下sizeCtl這個屬性。可以說它是ConcurrentHashMap中出鏡率很高的一個屬性,因為它是一個控制辨別符,在不同的地方有不同用途,而且它的取值不同,也代表不同的含義。

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

-1代表正在初始化

-N 表示有N-1個線程正在進行擴容操作

正數或0代表hash表還沒有被初始化,這個數值表示初始化或下一次進行擴容的大小,這一點類似于擴容門檻值的概念。還後面可以看到,它的值始終是目前ConcurrentHashMap容量的0.75倍,這與loadfactor是對應的。

Node是最核心的内部類,它包裝了key-value鍵值對,所有插入ConcurrentHashMap的資料都包裝在這裡面。它與HashMap中的定義很相似,但是但是有一些差别它對value和next屬性設定了volatile同步鎖(與JDK7的Segment相同),它不允許調用setValue方法直接改變Node的value域,它增加了find方法輔助map.get()方法。

樹節點類,另外一個核心的資料結構。當連結清單長度過長的時候,會轉換為TreeNode。但是與HashMap不相同的是,它并不是直接轉換為紅黑樹,而是把這些結點包裝成TreeNode放在TreeBin對象中,由TreeBin完成對紅黑樹的包裝。而且TreeNode在ConcurrentHashMap內建自Node類,而并非HashMap中的內建自LinkedHashMap.Entry<K,V>類,也就是說TreeNode帶有next指針,這樣做的目的是友善基于TreeBin的通路。

這個類并不負責包裝使用者的key、value資訊,而是包裝的很多TreeNode節點。它代替了TreeNode的根節點,也就是說在實際的ConcurrentHashMap“數組”中,存放的是TreeBin對象,而不是TreeNode對象,這是與HashMap的差別。另外這個類還帶有了讀寫鎖。

這裡僅貼出它的構造方法。可以看到在構造TreeBin節點時,僅僅指定了它的hash值為TREEBIN常量,這也就是個辨別為。同時也看到我們熟悉的紅黑樹構造方法

2.2.4 ForwardingNode

一個用于連接配接兩個table的節點類。它包含一個nextTable指針,用于指向下一張表。而且這個節點的key value next指針全部為null,它的hash值為-1. 這裡面定義的find的方法是從nextTable裡進行查詢節點,而不是以自身為頭節點進行查找。

在ConcurrentHashMap中,随處可以看到U, 大量使用了U.compareAndSwapXXX的方法,這個方法是利用一個CAS算法實作無鎖化的修改值的操作,他可以大大降低鎖代理的性能消耗。這個算法的基本思想就是不斷地去比較目前記憶體中的變量值與你指定的一個變量值是否相等,如果相等,則接受你指定的修改的值,否則拒絕你的操作。因為目前線程中的值已經不是最新的值,你的修改很可能會覆寫掉其他線程修改的結果。這一點與樂觀鎖,SVN的思想是比較類似的。

unsafe代碼塊控制了一些屬性的修改工作,比如最常用的SIZECTL 。在這一版本的concurrentHashMap中,大量應用來的CAS方法進行變量、屬性的修改工作。利用CAS進行無鎖操作,可以大大提高性能。

ConcurrentHashMap定義了三個原子操作,用于對指定位置的節點進行操作。正是這些原子操作保證了ConcurrentHashMap的線程安全。

對于ConcurrentHashMap來說,調用它的構造方法僅僅是設定了一些參數而已。而整個table的初始化是在向ConcurrentHashMap中插入元素的時候發生的。如調用put、computeIfAbsent、compute、merge等方法的時候,調用時機是檢查table==null。

初始化方法主要應用了關鍵屬性sizeCtl 如果這個值〈0,表示其他線程正在進行初始化,就放棄這個操作。在這也可以看出ConcurrentHashMap的初始化隻能由一個線程完成。如果獲得了初始化權限,就用CAS方法将sizeCtl置為-1,防止其他線程進入。初始化數組後,将sizeCtl的值改為0.75*n。

當ConcurrentHashMap容量不足的時候,需要對table進行擴容。這個方法的基本思想跟HashMap是很像的,但是由于它是支援并發擴容的,是以要複雜的多。原因是它支援多線程進行擴容操作,而并沒有加鎖。我想這樣做的目的不僅僅是為了滿足concurrent的要求,而是希望利用并發處理去減少擴容帶來的時間影響。因為在擴容的時候,總是會涉及到從一個“數組”到另一個“數組”拷貝的操作,如果這個操作能夠并發進行,那真真是極好的了。

整個擴容操作分為兩個部分

 第一部分是建構一個nextTable,它的容量是原來的兩倍,這個操作是單線程完成的。這個單線程的保證是通過RESIZE_STAMP_SHIFT這個常量經過一次運算來保證的,這個地方在後面會有提到;

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

先來看一下單線程是如何完成的:

它的大體思想就是周遊、複制的過程。首先根據運算得到需要周遊的次數i,然後利用tabAt方法獲得i位置的元素:

如果這個位置為空,就在原table中的i位置放入forwardNode節點,這個也是觸發并發擴容的關鍵點;

如果這個位置是Node節點(fh>=0),如果它是一個連結清單的頭節點,就構造一個反序連結清單,把他們分别放在nextTable的i和i+n的位置上

如果這個位置是TreeBin節點(fh<0),也做一個反序處理,并且判斷是否需要untreefi,把處理的結果分别放在nextTable的i和i+n的位置上

周遊過所有的節點以後就完成了複制工作,這時讓nextTable作為新的table,并且更新sizeCtl為新容量的0.75倍 ,完成擴容。

再看一下多線程是如何完成的:

在代碼的69行有一個判斷,如果周遊到的節點是forward節點,就向後繼續周遊,再加上給節點上鎖的機制,就完成了多線程的控制。多線程周遊節點,處理了一個節點,就把對應點的值set為forward,另一個線程看到forward,就向後周遊。這樣交叉就完成了複制工作。而且還很好的解決了線程安全的問題。 這個方法的設計實在是讓我膜拜。

ConcurrentHashMap總結

前面的所有的介紹其實都為這個方法做鋪墊。ConcurrentHashMap最常用的就是put和get兩個方法。現在來介紹put方法,這個put方法依然沿用HashMap的put方法的思想,根據hash值計算這個新插入的點在table中的位置i,如果i位置是空的,直接放進去,否則進行判斷,如果i位置是樹節點,按照樹的方式插入新的節點,否則把i插入到連結清單的末尾。ConcurrentHashMap中依然沿用這個思想,有一個最重要的不同點就是ConcurrentHashMap不允許key或value為null值。另外由于涉及到多線程,put方法就要複雜一點。在多線程中可能有以下兩個情況

如果一個或多個線程正在對ConcurrentHashMap進行擴容操作,目前線程也要進入擴容的操作中。這個擴容的操作之是以能被檢測到,是因為transfer方法中在空結點上插入forward節點,如果檢測到需要插入的位置被forward節點占有,就幫助進行擴容;

如果檢測到要插入的節點是非空且不是forward節點,就對這個節點加鎖,這樣就保證了線程安全。盡管這個有一些影響效率,但是還是會比hashTable的synchronized要好得多。

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

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

 如果這個位置存在結點,說明發生了hash碰撞,首先判斷這個節點的類型。如果是連結清單節點(fh>0),則得到的結點就是hash值相同的節點組成的連結清單的頭節點。需要依次向後周遊确定這個新加入的值所在位置。如果遇到hash值與key值都與新加入節點是一緻的情況,則隻需要更新value值即可。否則依次向後周遊,直到連結清單尾插入這個結點。如果加入這個節點以後連結清單長度大于8,就把這個連結清單轉換成紅黑樹。如果這個節點的類型已經是樹節點的話,直接調用樹節點的插入方法進行插入新的值。

我們可以發現JDK8中的實作也是鎖分離的思想,隻是鎖住的是一個Node,而不是JDK7中的Segment,而鎖住Node之前的操作是無鎖的并且也是線程安全的,建立在之前提到的3個原子操作上。

這是一個協助擴容的方法。這個方法被調用的時候,目前ConcurrentHashMap一定已經有了nextTable對象,首先拿到這個nextTable對象,調用transfer方法。回看上面的transfer方法可以看到,當本線程進入擴容方法的時候會直接進入複制階段。

這個方法用于将過長的連結清單轉換為TreeBin對象。但是他并不是直接轉換,而是進行一次容量判斷,如果容量沒有達到轉換的要求,直接進行擴容操作并傳回;如果滿足條件才連結清單的結構抓換為TreeBin ,這與HashMap不同的是,它并沒有把TreeNode直接放入紅黑樹,而是利用了TreeBin這個小容器來封裝所有的TreeNode.

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

對于ConcurrentHashMap來說,這個table裡到底裝了多少東西其實是個不确定的數量,因為不可能在調用size()方法的時候像GC的“stop the world”一樣讓其他線程都停下來讓你去統計,是以隻能說這個數量是個估計值。對于這個估計值,ConcurrentHashMap也是大費周章才計算出來的。

為了統計元素個數,ConcurrentHashMap定義了一些變量和一個内部類

mappingCount與size方法的類似  從Java工程師給出的注釋來看,應該使用mappingCount代替size方法 兩個方法都沒有直接傳回basecount 而是統計一次這個值,而這個值其實也是一個大概的數值,是以可能在統計的時候有其他線程正在執行插入或删除操作。

在put方法結尾處調用了addCount方法,把目前ConcurrentHashMap的元素個數+1這個方法一共做了兩件事,更新baseCount的值,檢測是否進行擴容。

JDK6,7中的ConcurrentHashmap主要使用Segment來實作減小鎖粒度,把HashMap分割成若幹個Segment,在put的時候需要鎖住Segment,get時候不加鎖,使用volatile來保證可見性,當要統計全局時(比如size),首先會嘗試多次計算modcount來确定,這幾次嘗試中,是否有其他線程進行了修改操作,如果沒有,則直接傳回size。如果有,則需要依次鎖住所有的Segment來計算。

jdk7中ConcurrentHashmap中,當長度過長碰撞會很頻繁,連結清單的增改删查操作都會消耗很長的時間,影響性能,是以jdk8 中完全重寫了concurrentHashmap,代碼量從原來的1000多行變成了 6000多 行,實作上也和原來的分段式存儲有很大的差別。

主要設計上的變化有以下幾點: 

不采用segment而采用node,鎖住node來實作減小鎖粒度。

設計了MOVED狀态 當resize的中過程中 線程2還在put資料,線程2會幫助resize。

使用3個CAS操作來確定node的一些操作的原子性,這種方式代替了鎖。

sizeCtl的不同值來代表不同含義,起到了控制的作用。

至于為什麼JDK8中使用synchronized而不是ReentrantLock,我猜是因為JDK8中對synchronized有了足夠的優化吧。

1. http://www.jianshu.com/p/4806633fcc55

2. https://www.zhihu.com/question/22438589

3. http://blog.csdn.net/u010723709/article/details/48007881