Java集合--ConcurrentHashMap原理

1.1 ConcurrentHashMap源碼理解

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

1.2 ConcurrentHashMap初始化

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

public class ConcurrentHashMap<K, V> {

    //用于根據給定的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 << 30;

    //Segment[]初始并發等級:決定了Segment[]的長度
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    //最小Segment[]容量:
    static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

    //最大Segement[]容量
    static final int MAX_SEGMENTS = 1 << 16;

    static final int RETRIES_BEFORE_LOCK = 2;

    //Segment[]
    final Segment<K,V>[] 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 < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();

    //傳入的并發等級不能大于Segment[]長度最大值
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;

    //sshift用來記錄向左按位移動的次數
    int sshift = 0; 

    //ssize用來記錄segment數組的大小
    int ssize = 1;

    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }

    //segmentShift、segmentMask用于元素在Segment[]數組的定位
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;

    //傳入初始化的值大于最大容量值,則默認為最大容量值
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;

    //c影響了每個Segment[]上要放置多少個HashEntry;
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;

    //創建第一個segment對象,并創建該對象下HashEntry[]
    Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]);

    //創建Segment[],指定segment數組的長度:
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];

    //使用CAS方式,將上面創建的segment對象放入segment[]數組中;
    UNSAFE.putOrderedObject(ss, SBASE, s0);

    //對ConcurrentHashMap中的segment數組賦值:
    this.segments = ss;
}

首先,我們來普及下 <<= 運算符的含義:

x <<= 1,就是x等于x左移動1位,就是將左移的數據進行2次方處理;

例如:14 << 2,14的二進制的 00001110 向左移兩位等于二進制 00111000,也就是十進制的56;

規律: 1 << i,是把1向左移i位,每次左移一位就是乘以2,所以 1 << i 的結果是 1 乘以 2的i次方;

在上面的代碼中,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 < concurrencyLevel) {
    ++sshift;
    ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;

這個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<K,V> 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<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
        //如果在Segment[]中的j角標處沒有元素,則在j角標處新建元素---Segment對象;
        s = ensureSegment(j);

    //底層使用Segment對象的put方法:
    return s.put(key, hash, value, false);
}

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

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

Segment中插入元素方法:

//Segment類,繼承了ReentrantLock類:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    //插入元素:
    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        //獲取鎖:
        HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
        V oldValue;
        try {
            //獲取Segment對象中的 HashEntry[]:
            HashEntry<K,V>[] tab = table;

            //計算key的hash值在HashEntry[]中的角標:
            int index = (tab.length - 1) & hash;

            //根據index角標獲取HashEntry對象:
            HashEntry<K,V> first = entryAt(tab, index);
            //遍歷此HashEntry對象(鏈表結構):
            for (HashEntry<K,V> 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<K,V>(hash, key, value, first);
                    int c = count + 1;
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                        //超過了Segment中HashEntry[]的閥值,對HashEntry[]進行擴容;
                        rehash(node);
                    else
                        setEntryAt(tab, index, node);
                    ++modCount;
                    count = c;
                    oldValue = null;
                    break;
                }
            }
        } finally {
            unlock();
        }
        return oldValue;
    }
}

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

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

1.4 ConcurrentHashMap獲取元素操作

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

public V get(Object key) {
    Segment<K,V> s;
    HashEntry<K,V>[] tab;

    //計算key的hash值:
    int h = hash(key);

    //計算該hash值所屬的Segment[]的角標:
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;

    //獲取Segment[]中u角標下的Segment對象:不存在直接返回
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
        //再根據hash值,從Segment對象中的HashEntry[]獲取HashEntry對象:并進行鏈表遍歷
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            //在鏈表中找到對應元素,便返回;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

在獲取操作中,獲取Segment對象和HashEntry對象,使用了不同的計算規則,其目的主要為了避免散列后的值一樣,盡可能將元素分散開來。

int h = hash(key)

計算Segment[]角標:
(((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE

計算HashEntry[]角標:
(tab.length - 1) & h

上面我們說過,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<K,V> extends ReentrantLock implements Serializable {
    transient volatile HashEntry<K,V>[] table;
}

被volatile所修飾的變量,可以在多線程中保持可見性,可以執行同時讀的操作,并且保證不會讀到過期的值。當HashEntry對象被修改后,會立刻更新到內存中,并且使存在于CPU緩存中的HashEntry對象過期無效,當其他線程進行讀取時,永遠都會讀取到內存中最新的值。

1.5 ConcurrentHashMap獲取長度操作

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

public int size() {
    //得到所有的Segment[]:
    final Segment<K,V>[] 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 < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                //遍歷Segment[],獲取其中的Segment對象:
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    //Segment對象被操作的次數:
                    sum += seg.modCount;
                    //Segment對象內元素的個數:也就是HashEntry對象的個數;
                    int c = seg.count;
                    //size每遍歷一次增加一次:
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        //釋放鎖:retries只有大于2的情況下,才會加鎖;
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

想要知道整個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的處理方式,即保證了數據準確,又得到了效率!!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容