ConcurrentHashMap中節點數目併發統計的實現原理

前言:

前段時間又看了一遍ConcurrentHashMap的源碼,對該併發容器的底層實現原理有了更進一步的瞭解,本想寫一篇關於ConcurrentHashMap的put方法所涉及的初始化以及擴容操作等的源碼解析的,但是這類文章在各平臺上實在是太多了,寫了感覺意義不是很大。但學了東西,還是想著儘量能夠輸出點什麼,所以就打算稍微寫寫點偏門的,關於ConcurrentHashMap中統計節點數目的size()方法的實現原理。由於JDK 1.7和1.8中ConcurrentHashMap的源碼發生了較大的變化,size()方法的底層實現也發生了變動。為此,本文將會通過

對比兩個版本的size()方法的底層實現來加深對ConcurrentHashMap的理解。

關鍵字:

源碼、JDK1.7、JDK1.8、ConcurrentHashMap、LongAdder

思考:

在瞭解具體實現原理之前,我們可以先自己思考該如何實現併發容器中的節點數目統計問題。 一種很自然的想法是,用一個成員變量(size)來統計容器的節點數目,每次對容器進行put或者remove操作時,都對該成員變量(size)進行線程安全的更新。這樣,當要獲取容器的節點數目時,直接返回該值即可。 這樣的計數方法在返回結果的時候,速度很快,但是在統計的過程中存在著一個很明顯的問題,就是併發度不高。由於需要對該成員變量(size)進行線程安全的更新,為此只能採用獨佔鎖(Synchronized、Lock等)或者CAS(AtomicInteger等)等方式來進行更新,無論採取何種方式,每次都只會允許一個線程執行成功。如果採用該方式來實現,節點統計將會成為該容器的瓶頸。且我們知道,ConcurrentHashMap為提高併發度,採用了分段鎖的方式(無論是JDK 1.7還是1.8版本,1.8版本的可以看成進一步提高了分段鎖的粒度)。為此,當採用一個成員變量線程安全更新的方式來實現時,

分段鎖的實現也變得沒有了意義(因為要在每次put操作或者remove操作之後,執行對該變量的更新操作後才能返回)。

ConcurrentHashMap中節點數目併發統計的實現原理

由於上面那種實現方式會導致存在性能瓶頸,那麼為了併發而生的ConcurrentHashMap肯定是不能採用該方式實現的(事實證明,確實沒有采用該方式進行實現)。我們知道, ConcurrentHashMap的底層採用了分段鎖的方式。為此,另一種很自然的想法是讓每個分段自己去統計該區間的節點數目,當在調用size()方法時,先一次性獲取所有的分段鎖(防止在統計節點數目時,還在進行節點數目的變動),將每個分段區間都給鎖住,之後依次獲取各個分段區間的節點數目進行彙總,從而得到ConcurrentHashMap全局的節點數目。那麼該方法是否可行呢?事實上,在JDK 1.7中就是採用了該方式來進行實現的且對該實現方式進行了改進(先嚐試採用不獲取任何獨佔鎖的方式進行統計)。我們可以知道,在JDK 1.7中,每次對ConcurrentHashMap執行put操作,都會獲取該分段區間對應的鎖。為此,該方式天然契合JDK 1.7中ConcurrentHashMap的實現,不會由於各個分段進行節點數目的統計而導致併發度降低的問題。

JDK1.7的實現方式:

在具體瞭解JDK1.7的實現之前,我們可以先看下JDK1.7中的源碼:

ConcurrentHashMap中節點數目併發統計的實現原理

size為各個分段節點數目的總和,sum為各個segment的modCount的總和。我們知道,當segment對應的hashmap底層結構發生修改時(執行了put、remove操作),modCount值便會加一,也就是modCount為segment對應的hashMap修改的次數,sum即為各個segment的修改次數的總和。last為上一次統計的各個segment的修改次數。通過源碼我們可以得知,其會先進行兩次非獲取獨佔鎖的統計,當sum==last時,也就是上一次統計和這一次統計的過程中,ConcurrentHashMap的各個分段都沒有發生過改動(既沒有新增節點,也沒有刪除節點),則size即為對應的結果。否則,就一次性獲取各個分段的獨佔鎖,再度統計兩次各個分段的節點數,而由於兩次統計的過程中一直持有著各個分段的獨佔鎖,為此,兩次統計的過程中不可能會有別的線程對該ConcurrentHashMap進行改動,sum和last值必定相同,最終會退出循環。也就是size()方法最多循環執行四次,便可以得到節點數統計的結果。

JDK1.8的實現方式:

JDK1.8中ConcurrentHashMap的size()方法的底層實現和在1.8中引入的LongAdder該併發計數類的底層實現原理是相同的。

為此,我們只需要瞭解JDK1.8中LongAdder的底層實現方法便可知道ConcurrentHashMap中對size()方法的實現原理。 LongAdder該類繼承自Striped64,同時封裝了相關方法,以實現對併發計數的要求,Striped64該類借鑑了分段鎖的相關思想。其將原本所有線程對一個變量進行的線程安全的更新操作,擴展為不同線程對多個不同的計數單元的線程安全的操作。

ConcurrentHashMap中節點數目併發統計的實現原理

當要獲取總數時,將多個計數單元的值進行累加求和,即可得到最終結果。 在講解LongAdder該類的源碼之前,我們

先了解Striped64該類。 在Striped64該類中,有著如下幾個重要的成員變量和內部類:

ConcurrentHashMap中節點數目併發統計的實現原理

NCPU表示系統CPU的數目,cells是計數單元的數組,其大小始終為2的n次方倍,目的是使取餘運算更加高效。base作為基礎計數變量來使用,線程嘗試先將值加在該變量上,如果成功則返回,否則認為存在競爭,則應當去將值添加到對應的計數單元中。cellsBusy是一個用volatile修飾的變量,通過CAS原子更新的方式,充當了自旋鎖的作用,當該值為0時,表示該鎖可以使用,當該值為1時,表示某個線程獲取了鎖。

接著我們來看靜態內部類Cell:

ConcurrentHashMap中節點數目併發統計的實現原理

內部類Cell為計數單元,其實現較為簡單,成員變量只有一個被volatile所修飾的long型變量value,對該值的修改是通過CAS的方式進行的,用於疊加各個線程記錄到該計數單元的值。值得注意的是,該類被註解@sun.misc.Contended所修飾,該註解是用於解決偽共享問題的,這裡就暫時不展開討論偽共享的問題,留到下一篇文章中再講。

接著我們來看Striped64該類中的重要方法,longAccumulate(),該方法完成了計數單元數組cells的初始化,計數單元數組某個元素的初始化,擴容以及將值疊加到對應的計數單元上等相關操作。 我們先來解析當cells非空的情況,當其非空時,表明之前存在多線程更新base的值發生過沖突。

ConcurrentHashMap中節點數目併發統計的實現原理

當cells數組非空,但對應的計數單元為空,且沒有其它線程獲取鎖時,其先嚐試實例化一個計數單元,同時將值傳遞給該計數單元,之後,嘗試獲取鎖,並將該計數單元賦值給cells數組對應的元素,完成賦值操作後,再釋放鎖,並退出循環,進行返回。

ConcurrentHashMap中節點數目併發統計的實現原理

當cells數組非空,對應的計數單元也非空時,嘗試採用CAS的方式將值疊加到對應的計數單元中,當執行成功時返回。否則,判斷Cell數組的長度是否是最大的了(大於等於CPU的數目),如已經是最大的了,則重新計算線程的hash值,繼續進行操作。如不是,則進行擴容操作。 我們從源碼中可以得知,每次cells數組擴容時,其大小為先前大小的2倍,同時將舊計數單元數組的每個元素直接複製到新表中,完成擴容的過程。以上便是當cells非空時,所進行的相關操作。

ConcurrentHashMap中節點數目併發統計的實現原理

當cells數組為空時,其會先嚐試獲取鎖並初始化數組,同時將值疊加到對應的計數器單元中,當獲取鎖失敗時,則嘗試將值疊加到基礎計數變量base中,成功時返回,失敗時繼續循環操作。以上便是Striped64處理併發情況下統計值的整個過程。

總結:當cells數組非空時,嘗試將值疊加到某個數組元素所表示的計數單元中,疊加失敗時,表明存在多線程的競爭。此時,如果cells的大小小於CPU數目時,嘗試進行擴容操作,否則,將對應線程的hash值進行rehash後,重新執行循環過程。當cells為空時,嘗試對cells進行初始化,並將值疊加到對應的計數單元中,執行初始化完成之後返回。否則,當初始化失敗時,嘗試將值疊加到基礎計數變量base中,初始化成功時結束該過程並返回,失敗時,重新執行循環過程。

在瞭解了Striped64該類的主要實現方法和相關成員變量後,瞭解LongAdder該類的實現就是一件非常簡單的事情了。 下面我們對LongAdder該類的源碼進行解析。

ConcurrentHashMap中節點數目併發統計的實現原理

我們可以看到,LongAdder類中核心的便是那個add()方法,當cells還沒有進行初始化時(表明之前不存在多線程的競爭累加),其先嚐試將值x通過CAS的方式累加到成員變量base中。當失敗時,表明存在多線程之間的競爭,隨後判斷計數單元cells是否已被初始化完成,如果已經初始化(被其它線程初始化),則

嘗試通過將值x累加到某個計數單元中,當同樣失敗時,執行父類Striped64中的longAccumulate方法,將值累加到對應的計數單元或者base中,以完成累加的過程。 那麼如何獲取統計操作後的結果呢? 其源碼實現如下所示:

ConcurrentHashMap中節點數目併發統計的實現原理

從源碼中我們就可以得知,其累加了base變量的值和各個計數單元Cell的值作為結果進行返回。當該方法被調用的時候,其它線程可能還在進行著修改操作,為此,其最終返回的值並非是精確的當前情況下的統計結果,

其只是一個“大概”值。當想要獲得精確值時,只能採用對各個計數單元進行加鎖的方式來實現。

一個問題:仔細想想,ConcurrentHashMap為何採用該方式來實現size()方法?我能想到的一個原因是ConcurrentHashMap並不需要精確的節點數目的值,由於ConcurrentHashMap該數據結構是為併發而生的,為此,獲取精確的節點數目的值本身意義並不大。當你消耗了性能,獲取了此時此刻的節點數目的精確值,隨後還是可能會被其他線程修改,導致上一刻的值無法使用,為此獲取一個“大概”值便是一個較好的選擇。


分享到:


相關文章: