前言:
前段時間又看了一遍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的底層采用了分段鎖的方式。為此,另一種很自然的想法是讓每個分段自己去統計該區間的節點數目,當在調用size()方法時,先一次性獲取所有的分段鎖(防止在統計節點數目時,還在進行節點數目的變動),將每個分段區間都給鎖住,之后依次獲取各個分段區間的節點數目進行匯總,從而得到ConcurrentHashMap全局的節點數目。那么該方法是否可行呢?事實上,在JDK 1.7中就是采用了該方式來進行實現的且對該實現方式進行了改進(先嘗試采用不獲取任何獨占鎖的方式進行統計)。我們可以知道,在JDK 1.7中,每次對ConcurrentHashMap執行put操作,都會獲取該分段區間對應的鎖。為此,該方式天然契合JDK 1.7中ConcurrentHashMap的實現,不會由於各個分段進行節點數目的統計而導致並發度降低的問題。
JDK1.7的實現方式:
在具體了解JDK1.7的實現之前,我們可以先看下JDK1.7中的源碼:
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該類借鑒了分段鎖的相關思想。其將原本所有線程對一個變量進行的線程安全的更新操作,擴展為不同線程對多個不同的計數單元的線程安全的操作。
當要獲取總數時,將多個計數單元的值進行累加求和,即可得到最終結果。
在講解LongAdder該類的源碼之前,我們先了解Striped64該類。
在Striped64該類中,有着如下幾個重要的成員變量和內部類:
NCPU表示系統CPU的數目,cells是計數單元的數組,其大小始終為2的n次方倍,目的是使取余運算更加高效。base作為基礎計數變量來使用,線程嘗試先將值加在該變量上,如果成功則返回,否則認為存在競爭,則應當去將值添加到對應的計數單元中。cellsBusy是一個用volatile修飾的變量,通過CAS原子更新的方式,充當了自旋鎖的作用,當該值為0時,表示該鎖可以使用,當該值為1時,表示某個線程獲取了鎖。
接着我們來看靜態內部類Cell:
內部類Cell為計數單元,其實現較為簡單,成員變量只有一個被volatile所修飾的long型變量value,對該值的修改是通過CAS的方式進行的,用於疊加各個線程記錄到該計數單元的值。值得注意的是,該類被注解@sun.misc.Contended所修飾,該注解是用於解決偽共享問題的,這里就暫時不展開討論偽共享的問題,留到下一篇文章中再講。
接着我們來看Striped64該類中的重要方法,longAccumulate(),該方法完成了計數單元數組cells的初始化,計數單元數組某個元素的初始化,擴容以及將值疊加到對應的計數單元上等相關操作。
我們先來解析當cells非空的情況,當其非空時,表明之前存在多線程更新base的值發生過沖突。
當cells數組非空,但對應的計數單元為空,且沒有其它線程獲取鎖時,其先嘗試實例化一個計數單元,同時將值傳遞給該計數單元,之后,嘗試獲取鎖,並將該計數單元賦值給cells數組對應的元素,完成賦值操作后,再釋放鎖,並退出循環,進行返回。
當cells數組非空,對應的計數單元也非空時,嘗試采用CAS的方式將值疊加到對應的計數單元中,當執行成功時返回。否則,判斷Cell數組的長度是否是最大的了(大於等於CPU的數目),如已經是最大的了,則重新計算線程的hash值,繼續進行操作。如不是,則進行擴容操作。
我們從源碼中可以得知,每次cells數組擴容時,其大小為先前大小的2倍,同時將舊計數單元數組的每個元素直接復制到新表中,完成擴容的過程。以上便是當cells非空時,所進行的相關操作。
當cells數組為空時,其會先嘗試獲取鎖並初始化數組,同時將值疊加到對應的計數器單元中,當獲取鎖失敗時,則嘗試將值疊加到基礎計數變量base中,成功時返回,失敗時繼續循環操作。以上便是Striped64處理並發情況下統計值的整個過程。
總結:當cells數組非空時,嘗試將值疊加到某個數組元素所表示的計數單元中,疊加失敗時,表明存在多線程的競爭。此時,如果cells的大小小於CPU數目時,嘗試進行擴容操作,否則,將對應線程的hash值進行rehash后,重新執行循環過程。當cells為空時,嘗試對cells進行初始化,並將值疊加到對應的計數單元中,執行初始化完成之后返回。否則,當初始化失敗時,嘗試將值疊加到基礎計數變量base中,初始化成功時結束該過程並返回,失敗時,重新執行循環過程。
在了解了Striped64該類的主要實現方法和相關成員變量后,了解LongAdder該類的實現就是一件非常簡單的事情了。
下面我們對LongAdder該類的源碼進行解析。
我們可以看到,LongAdder類中核心的便是那個add()方法,當cells還沒有進行初始化時(表明之前不存在多線程的競爭累加),其先嘗試將值x通過CAS的方式累加到成員變量base中。當失敗時,表明存在多線程之間的競爭,隨后判斷計數單元cells是否已被初始化完成,如果已經初始化(被其它線程初始化),則嘗試通過將值x累加到某個計數單元中,當同樣失敗時,執行父類Striped64中的longAccumulate方法,將值累加到對應的計數單元或者base中,以完成累加的過程。
那么如何獲取統計操作后的結果呢?
其源碼實現如下所示:
從源碼中我們就可以得知,其累加了base變量的值和各個計數單元Cell的值作為結果進行返回。當該方法被調用的時候,其它線程可能還在進行着修改操作,為此,其最終返回的值並非是精確的當前情況下的統計結果,其只是一個“大概”值。當想要獲得精確值時,只能采用對各個計數單元進行加鎖的方式來實現。
一個問題:仔細想想,ConcurrentHashMap為何采用該方式來實現size()方法?我能想到的一個原因是ConcurrentHashMap並不需要在並發修改情況下的精確節點數目的值,由於ConcurrentHashMap該數據結構是為並發而生的,為此,獲取精確的節點數目的值本身意義並不大。當你消耗了性能,獲取了某個時刻節點數目的精確值,隨后還是可能會被其他線程修改,導致上一刻的值無法使用,為此獲取一個“大概”值便是一個較好的選擇。而采用該方法實現也是線程安全的,能夠確保沒有線程對其進行修改的時候,其值的准確性。
這個是本人的公眾號,致力於寫出絕大部分人都能讀懂的技術文章。歡迎相互交流,我們博采眾長,共同進步。