Java集合--ConcurrentHashMap原理

Jack2k發表於2021-09-09

1.1 ConcurrentHashMap原始碼理解

上篇,介紹了ConcurrentHashMap的結構。本節中,我們來從原始碼的角度出發,來看下ConcurrentHashMap原理。

1.2 ConcurrentHashMap初始化

我們首先,來看下ConcurrentHashMap中的主要成員變數;

public class ConcurrentHashMap {    //用於根據給定的key的hash值定位到一個Segment
    final int segmentMask;    //用於根據給定的key的hash值定位到一個Segment
    final int segmentShift;    //HashEntry[]初始容量:決定了HashEntry陣列的初始容量和初始閥值大小
    static final int DEFAULT_INITIAL_CAPACITY = 16;    //Segment物件下HashEntry[]的初始載入因子:
    static final float DEFAULT_LOAD_FACTOR = 0.75f;    //Segment物件下HashEntry[]最大容量:
    static final int MAXIMUM_CAPACITY = 1 [] segments;
}

在ConcurrentHashMap中,定位到Segment[]中的某一角標,需要用到segmentMask和segmentShift這兩個屬性,他們的主要作用就是定位Segment[];

在上述屬性中,有的屬性是負責Segment[]的初始化,有的是負責HashEntry[]的初始化操作。如果單純靠屬性的名字來區分,還是很容易弄混淆的,這一點還要大家多多注意觀察,以及後續的分析。

DEFAULT_INITIAL_CAPACITY、DEFAULT_LOAD_FACTOR、MAXIMUM_CAPACITY與HashEntry[]的構建有關。

DEFAULT_CONCURRENCY_LEVEL、MIN_SEGMENT_TABLE_CAPACITY、MAX_SEGMENTS與Segment[]的構建有關。

下面,來看看ConcurrentHashMap的構造,它是如何初始化的!

public ConcurrentHashMap(int initialCapacity,                         float loadFactor, int concurrencyLevel) {    //對容量、載入因子、併發等級做限制,不能小於(等於0)
    if (!(loadFactor > 0) || initialCapacity  MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;    //sshift用來記錄向左按位移動的次數
    int sshift = 0; 

    //ssize用來記錄segment陣列的大小
    int ssize = 1;    while (ssize  MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;    //c影響了每個Segment[]上要放置多少個HashEntry;
    int c = initialCapacity / ssize;    if (c * ssize  s0 = new Segment(loadFactor, (int)(cap * loadFactor), (HashEntry[])new HashEntry[cap]);    //建立Segment[],指定segment陣列的長度:
    Segment[] ss = (Segment[])new Segment[ssize];    //使用CAS方式,將上面建立的segment物件放入segment[]陣列中;
    UNSAFE.putOrderedObject(ss, SBASE, s0);    //對ConcurrentHashMap中的segment陣列賦值:
    this.segments = ss;
}

首先,我們來普及下

在上面的程式碼中,initialCapacity--初始容量大小,該引數影響著Segment物件下HashEntry[]的長度大小;loadFactor--載入因子,該引數影響著Segment物件下HashEntry[]陣列擴容閥值;concurrencyLevel--併發等級,該引數影響著Segment[]的長度大小。

在ConcurrentHashMap構造中,先是根據concurrencyLevel來計算出Segment[]的大小,而Segment[]的大小 就是大於或等於concurrencyLevel的最小的2的N次方。這樣的好處是是為了方便採用位運算來加速進行元素的定位。假如concurrencyLevel等於14,15或16,ssize都會等於16;

接下來,根據intialCapacity的值來確定Segment[]的大小,與計算Segment[]的方法一致。

值得一提的是,segmentShift和segmentMask這兩個屬性。上面說了,Segment[]長度就是2的N次方,在下面這段程式碼裡:

int sshift = 0; 
int ssize = 1;while (ssize 

這個N次方的N,就代表著sshift的大小,每while迴圈一次,sshift就增加1,那麼segmentShift的值就等於32減去n,而segmentMask就等於2的n次方減去1。

1.3 ConcurrentHashMap插入元素操作

在ConcurrentHashMap類中,使用put()最終呼叫的是Segment物件中的put()。

由於ConcurrentHashMap是執行緒安全的集合,所以在新增元素時,需要在操作時進行加鎖處理。

public V put(K key, V value) {
    Segment s;    //傳入的value不能為null
    if (value == null)        throw new NullPointerException();    //計算key的hash值:
    int hash = hash(key);    //透過key的hash值,定位ConcurrentHashMap中Segment[]的角標
    int j = (hash >>> segmentShift) & segmentMask;    //使用CAS方式,從Segment[]中獲取j角標下的Segment物件,並判斷是否存在:
    if ((s = (Segment)UNSAFE.getObject(segments, (j 

在ConcurrentHashMap的put()中,首先需要透過key來定位到Segment[]的角標,然後在Segment中進行插入操作。

透過原始碼可以看到:定位Segment[]操作不但需要key的hash值,還需要使用到segmentShift、segmentMask屬性,前面提到過這兩個屬性的初始化是在ConcurrentHashMap中進行的。

Segment中插入元素方法:

//Segment類,繼承了ReentrantLock類:static final class Segment extends ReentrantLock implements Serializable {    //插入元素:
    final V put(K key, int hash, V value, boolean onlyIfAbsent) {        //獲取鎖:
        HashEntry node = tryLock() ? null : scanAndLockForPut(key, hash, value);
        V oldValue;        try {            //獲取Segment物件中的 HashEntry[]:
            HashEntry[] tab = table;            //計算key的hash值在HashEntry[]中的角標:
            int index = (tab.length - 1) & hash;            //根據index角標獲取HashEntry物件:
            HashEntry first = entryAt(tab, index);            //遍歷此HashEntry物件(連結串列結構):
            for (HashEntry e = first;;) {                //判斷邏輯與HashMap大體相似:
                if (e != null) {
                    K k;                    if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                        oldValue = e.value;                        if (!onlyIfAbsent) {
                            e.value = value;
                            ++modCount;
                        }                        break;
                    }
                    e = e.next;
                } else {                    if (node != null)
                        node.setNext(first);                    else
                        node = new HashEntry(hash, key, value, first);                    int c = count + 1;                    if (c > threshold && tab.length 

在Segment物件中,首先進行獲取鎖操作,也就是說在ConcurrentHashMap中,鎖是加到了每一個Segment物件上,而不是整個ConcurrentHashMap上。這樣的好處就是,當我們進行插入操作時,只要插入的不是同一個Segment物件,那麼併發執行緒就不需要進行等待操作,在保證安全的同時,又極大的提高了併發效能。

獲取鎖之後,透過hash值計算元素需要插入HashEntry[]的角標,再之後的操作基本與HashMap保持一致。

1.4 ConcurrentHashMap獲取元素操作

透過key,去獲取對應的value,大體邏輯與HashMap一致;

public V get(Object key) {
    Segment s;
    HashEntry[] tab;    //計算key的hash值:
    int h = hash(key);    //計算該hash值所屬的Segment[]的角標:
    long u = (((h >>> segmentShift) & segmentMask) )UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {        //再根據hash值,從Segment物件中的HashEntry[]獲取HashEntry物件:並進行連結串列遍歷
        for (HashEntry e = (HashEntry) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) 

在獲取操作中,獲取Segment物件和HashEntry物件,使用了不同的計算規則,其目的主要為了避免雜湊後的值一樣,儘可能將元素分散開來。

int h = hash(key)

計算Segment[]角標:
(((h >>> segmentShift) & segmentMask) 

上面我們說過,Segment[]的大小為2的N次方,segmentShift屬性為32減去n,segmentMask屬性為2的n次方減去1。當我們假設都使用ConcurrentHashMap的預設值時候,Segment[]的大小為16,n為4,segmentShift位28,segmentMask位15。

則h無符號右移28位,剩餘4位有效值(高位補0)與segmentMask進行 &運算,得到Segment[]角標。

0000 0000 0000 0000 0000 0000 0000 XXXX 4位有效值
0000 0000 0000 0000 0000 0000 0000 1111 15的二進位制
---------------------------------- &運算

也就是根據元素的hash值的高n位就可以確定元素到底在哪一個Segment中。

與HashTable不同的是,ConcurrentHashMap在獲取元素時並沒有進行加鎖處理,那麼在併發場景下會不會產生資料隱患呢?

答案是NO!!!!

原因是,在ConcurrentHashMap的get()中,要獲取的元素被volatitle修飾符所修飾:HashEntry[]

static final class Segment extends ReentrantLock implements Serializable {    transient volatile HashEntry[] table;
}

被volatile所修飾的變數,可以在多執行緒中保持可見性,可以執行同時讀的操作,並且保證不會讀到過期的值。當HashEntry物件被修改後,會立刻更新到記憶體中,並且使存在於CPU快取中的HashEntry物件過期無效,當其他執行緒進行讀取時,永遠都會讀取到記憶體中最新的值。

1.5 ConcurrentHashMap獲取長度操作

上面說完了put()和get(),本節在說說size()。與插入、獲取不同的是,size()有可能會對整個hash表進行加鎖處理。

public int size() {    //得到所有的Segment[]:
    final Segment[] segments = this.segments;    int size;    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {        for (;;) {            //先比較在++,所以說能進到此邏輯中來,肯定retries大於2了
            if (retries++ == RETRIES_BEFORE_LOCK) {                //-1比較,變0
                //0比較,變1
                //1比較,變2
                //2比較,變3
                for (int j = 0; j  seg = segmentAt(segments, j);                if (seg != null) {                    //Segment物件被操作的次數:
                    sum += seg.modCount;                    //Segment物件內元素的個數:也就是HashEntry物件的個數;
                    int c = seg.count;                    //size每遍歷一次增加一次:
                    if (c  RETRIES_BEFORE_LOCK) {            for (int j = 0; j 

想要知道整個ConcurrentHashMap中的元素數量,就必須統計Segment物件下HashEntry[]中元素的個數。在Segment物件中有一個count屬性,它是負責記錄Segment物件中到底有多少個HashEntry的。當呼叫put()時,每增加一個元素,都會對count進行一次++,那麼是不是統計所有Segment物件中的count值就行了呢?

答案:不一定。

如果在遍歷Segment[]過程中,可能先遍歷的Segment進行了插入(刪除)操作,導致count發生了改變,引起整個統計結果不準確。所以最安全的做法就行是遍歷之前,將整個ConcurrentHashMap加鎖處理。

不過,整體加鎖的做法有失考慮,畢竟加鎖意味著效能下降,而ConcurrentHashMap的做法進行了一個折中處理。

我們思考下,在平常的工作場景,當我們對Map進行size()操作時,會有多大的機率,又同時進行插入(刪除)操作呢?

想必這個事情發生的可能還是很低的,那麼ConcurrentHashMap的作法是,連續遍歷2次Segment陣列,將count的值,進行相加操作。如果遍歷2次後的結果,都沒有變化,那麼就直接將count的和返回,如果此時發生的變化,那麼就對整張hash表進行加鎖處理。

這就是ConcurrentHashMap的處理方式,即保證了資料準確,又得到了效率!!


作者:賈博巖
連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/36/viewspace-2802568/,如需轉載,請註明出處,否則將追究法律責任。

相關文章