Java7 ConcurrentHashMap原始碼淺析

zhong0316發表於2019-03-02

Java中的雜湊表主要包括:HashMap,HashTable,ConcurrentHashMap,LinkedHashMap和TreeMap等。HashMap是無序的,並且不是執行緒安全的,在多執行緒環境下會出現資料安全性問題,典型的問題是多執行緒同時rehash過程中產生的死迴圈問題。LinkedHashMap和TreeMap都是有序的,但是兩者的有序機制不同:LinkedHashMap是通過連結串列的結構保證元素有序,而TreeMap是一種紅黑樹結構,通過堆排序保證元素有序。在Java6中HashMap,HashTable和ConcurrentHashMap都是採用陣列+連結串列的資料結構,在Java8之後則採用陣列+連結串列+紅黑樹的資料結構。HashTable和ConcurrentHashMap都是執行緒安全的,保證執行緒安全無外乎加鎖,但是二者加鎖的粒度不通,HashTable整個表就一把鎖,它的get和put都是通過synchronized保證安全,在多執行緒競爭鎖激烈的情況下,會出現效能問題。本文講解的ConcurrentHashMap是Java7版本。

資料結構

Java7中ConcurrentHashMap採用陣列+連結串列的資料結構,雜湊表整體上是一個Segment的陣列,而每個分段Segment又是一個HashEntry的陣列,每個HashEntry是一個連結串列。

Java7ConcurrentHashMap

ConcurrentHashMap鎖分段

鎖粗化是鎖優化的一種重要措施,而鎖粗化又包含"lock-splitting"(鎖定拆分)和"lock-stripping"(鎖條帶化)。讀寫鎖分離是一種典型的鎖定拆分方式,JUC中的ReentrantReadWriteLock就是一種讀寫分離鎖,鎖定條帶化是指將一把“大鎖”拆分成若干個“小鎖”來降低鎖的競爭。ConcurrentHashMap就是通過鎖條帶化來做鎖的優化。我們都知道ConcurrentHashMap是分段的,它的表是一個Segment陣列:

/**
 * The segments, each of which is a specialized hash table
 */
final Segment<K,V>[] segments;
複製程式碼

而每個Segment都是繼承了一個ReentrantLock:

static final class Segment<K,V> extends ReentrantLock implements Serializable {...}
複製程式碼

所以ConcurrentHashMap的每個Segment都是一把鎖,不同的Segment之間的讀寫不構成競爭,大大降低了鎖的競爭。既然每個Segment都是一把鎖,那麼這個segment陣列的長度是多少呢?也就是說整個表我們需要多少把鎖呢?

public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();

    if (concurrencyLevel > MAX_SEGMENTS) // MAX_SEGMENTS是指整個表最多能分成多少個segment,也即是多少把鎖
        concurrencyLevel = MAX_SEGMENTS;

    // Find power-of-two sizes best matching arguments
    int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) { // 找到不小於我們指定的concurrencyLevel的2的冪次方的一個數作為segment陣列的長度
        ++sshift;
        ssize <<= 1;
    }
    segmentShift = 32 - sshift;
    segmentMask = ssize - 1;
    this.segments = Segment.newArray(ssize);

    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = 1;
    while (cap < c)
        cap <<= 1;

    for (int i = 0; i < this.segments.length; ++i)
        this.segments[i] = new Segment<K,V>(cap, loadFactor);
}
複製程式碼

在ConcurrentHashMap的建構函式中我們指定了concurrencyLevel,也即是多少把鎖。這個數量不能超過上限:MAX_SEGMENTS(1 << 16),鎖的個數必須是2的冪次方,如果我們指定的concurrencyLevel不是2的冪次方,建構函式會找到最接近的一個不小於我們指定的值的一個2的冪次方數作為segment陣列長度。例如:我們指定concurrencyLevel為15,則最終segment陣列長度為16,也即是表一共有16把鎖。設想兩個執行緒同時向表中插入元素,執行緒1插入的第0個segment,執行緒2插入的是第1個segment,執行緒1和執行緒2互不影響,能夠同時並行。但是HashTable就做不到這一點。

put操作

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask; // 通過位運算得到segment的索引位置
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}
複製程式碼

ConcurrentHashMap不支援插入null的值,因此首先校驗value是否為null。如果value是null則丟擲異常。 注意這裡計算segment索引方式是: (hash >>> segmentShift) & segmentMask; 而不是hash % segment陣列長度。這兒是一個優化:因為取模"%"操作相對位運算來說是很慢的,因此這裡是用位運算來得到segment索引。而當segment陣列長度是2的冪次方記為segmentSize時:hash % segmentSize == hash & (segmentSize - 1)這裡不做證明。因此segmentSize必須是2的冪次方。來看看Segment中的put()方法:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null :  // 獲取segment的鎖,這裡會有一個優化:獲取鎖的時候首先會通過 `tryLock()` 嘗試若干次
        scanAndLockForPut(key, hash, value);  // 如果若干次之後還沒有獲取鎖,則用 `lock()` 方法阻塞等待,直到獲取鎖
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash; // 得到segment的table的索引,也是通過位運算
        HashEntry<K,V> first = entryAt(tab, index); // table中index位置的first節點
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) { // 對應的key已經有了value
                    oldValue = e.value;
                    if (!onlyIfAbsent) { // 是否覆蓋原來的value
                        e.value = value; // 覆蓋原來的value
                        ++modCount;
                    }
                    break;
                }
                e = e.next;  // 遍歷
            }
            else {
                if (node != null)
                    node.setNext(first); // 如果node已經在scanAndLockForPut()方法中初始化過
                else
                    node = new HashEntry<K,V>(hash, key, value, first); // 如果node為null,則初始化
                int c = count + 1;
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node); // 如果超過閾值,則擴容
                else
                    setEntryAt(tab, index, node); // 通過UNSAFE設定table陣列的index位置的元素為node
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}
複製程式碼

首先,會獲取segment的鎖,然後判斷新增元素後是否需要擴容。注意這裡的擴容是指Segment中的HashEntry[] table表陣列擴容,而不是最外層的segment[]陣列擴容。segment[]陣列是不可擴充套件的,在建構函式中已經確定了segment[]陣列的長度。 接著同樣通過位運算得到待新增元素在HashEntry[] table陣列中的位置。接著再判斷這個連結串列中是否已經存在這個key,如果存在並且onlyIfAbsent為false,就覆蓋原value;如果連結串列不存在key,則將新的node通過UNSAFE放到table陣列指定的位置。

get操作

get操作比較簡單,不需要加鎖。可見性由volatile來保證:HashEntry的value是volatile的,Segment中的HashEntry[] table陣列也是volatile。這保證了其他執行緒對雜湊表的修改能夠及時地被讀執行緒發現。

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // 計算key應該落在segments陣列的哪個segment中
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); // 計算key應該落在table的哪個位置
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k))) // 如果key和當前節點的key指向同一塊記憶體地址或者當前節點的hash
                return e.value;                                       // 等於key的hash並且key"equals"當前節點的key則說明當前節點就是目標節點
        }
    }
    return null; // key不在當前雜湊表中,返回null
}
複製程式碼

rehash

private void rehash(HashEntry<K,V> node) {
    /*
     * Reclassify nodes in each list to new table.  Because we
     * are using power-of-two expansion, the elements from
     * each bin must either stay at same index, or move with a
     * power of two offset. We eliminate unnecessary node
     * creation by catching cases where old nodes can be
     * reused because their next fields won't change.
     * Statistically, at the default threshold, only about
     * one-sixth of them need cloning when a table
     * doubles. The nodes they replace will be garbage
     * collectable as soon as they are no longer referenced by
     * any reader thread that may be in the midst of
     * concurrently traversing table. Entry accesses use plain
     * array indexing because they are followed by volatile
     * table write.
     */
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    int newCapacity = oldCapacity << 1; // 每次擴容成原來capacity的2倍,這樣元素在新的table中的索引要麼不變要麼是原來的索引加上2的一個倍數
    threshold = (int)(newCapacity * loadFactor); // 新的擴容閾值
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity]; // 新的segment table陣列
    int sizeMask = newCapacity - 1;
    for (int i = 0; i < oldCapacity ; i++) {
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            int idx = e.hash & sizeMask;
            if (next == null)   //  Single node on list
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                for (HashEntry<K,V> last = next;  // 在拷貝原來連結串列的元素到新的table中時有個優化:通過遍歷找到原先連結串列中的lastRun節點,這個節點以及它的後續節點都不需要重新拷貝,直接放到新的table中就行
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                newTable[lastIdx] = lastRun; // lastRun節點以及lastRun後續節點都不需要重新拷貝,直接賦值引用
                // Clone remaining nodes
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) { // 迴圈拷貝原先連結串列lastRun之前的節點到新的table連結串列中
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]); // rehash之後,執行新增新的節點
    newTable[nodeIndex] = node;
    table = newTable;
}
複製程式碼

由於rehash過程中是加排它鎖的,這樣其他的寫入請求將被阻塞等待。而對於讀請求,需要分情況討論:讀請求在rehash之前,此時segment中的table陣列指標還是指向原先舊的陣列,所以讀取是安全的;如果讀請求在rehash之後,因為table陣列和HashEntry的value都是volatile,所以讀執行緒也能及時讀取到更新的值,因此也是執行緒安全的。所以rehash不會影響到讀。

remove操作

public V remove(Object key) {
    int hash = hash(key);
    Segment<K,V> s = segmentForHash(hash); // key落在哪個segment中
    return s == null ? null : s.remove(key, hash, null); // 如果segment為null,則說明雜湊表中沒有key,直接返回null,否則呼叫Segment的remove
}

final V remove(Object key, int hash, Object value) { // Segment的remove方法
    if (!tryLock()) // 獲取Segment的鎖,套路還是一樣的首先進行若干次 `tryLock()`, 如果失敗了則通過 `lock()` 方法阻塞等待直到獲取鎖
        scanAndLock(key, hash);
    V oldValue = null;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> e = entryAt(tab, index); // 找到key具體在table的哪個連結串列中,e代表連結串列當前節點
        HashEntry<K,V> pred = null; // pred代表e節點的前置節點
        while (e != null) {
            K k;
            HashEntry<K,V> next = e.next;
            if ((k = e.key) == key ||
                (e.hash == hash && key.equals(k))) { // 找到了這個key對應的HashEntry
                V v = e.value;
                if (value == null || value == v || value.equals(v)) {
                    if (pred == null) // 如果當前節點的前置節點為空,說明要刪除的節點是當前連結串列的頭節點,直接將當前連結串列的頭節點指向當前節點的next就可以了
                        setEntryAt(tab, index, next);
                    else
                        pred.setNext(next); // 否則修改前置節點的next指標,指向當前節點的next節點,這樣當前節點將不再"可達",可以被GC回收
                    ++modCount;
                    --count;
                    oldValue = v;
                }
                break;
            }
            pred = e;
            e = next;
        }
    } finally {
        unlock(); // 解鎖
    }
    return oldValue;
}
複製程式碼

remove時,首先會找到這個key落在哪個Segment中,如果key沒有落在任何一個Segment中,說明key不存在,直接返回null。找到具體的Segment後,呼叫Segment的remove方法來進行刪除:找到key落在Segment的table陣列中的哪個連結串列中,遍歷連結串列,如果要刪除的節點是當前連結串列的頭節點,則直接修改連結串列的頭指標為當前節點的next節點;如果要刪除的節點不是頭節點,繼續遍歷找到目標節點,修改目標節點的前置節點的next指標指向目標節點的next節點完成操作。 安全性分析:remove時首先會加鎖,其他mutable請求都是會被阻塞的,對於讀請求也是安全的。如果讀取的key不是當前要刪除的key不會有任何問題。如果讀取的key恰巧是當前需要刪除key:讀請求在remove之前,這時可以讀取到;如果讀請求在remove操作之後,由於HashEntry的next指標都是volatile的,所以讀執行緒也是可以及時發現這個key已經被刪除了的。也是安全的。

size操作

ConcurrentHashMap的size操作在Java7實現還是比較有意思的。其首先會進行若干次嘗試,每次對各個Segment的count求和,如果任意前後兩次求和結果相同,則說明在這段時間之內各個Segment的元素個數沒有改變,直接返回當前的求和結果就行了。如果超過一定重試次數之後,會採取悲觀策略,直接鎖定各個Segment,然後依次求和。注意這裡是鎖定所有Segment,因此在採取悲觀策略時整個雜湊表都不能有寫入操作。這裡先樂觀再悲觀的策略和前面的put操作中的scanAndLockForPut有異曲同工之妙。

public int size() {
    // Try a few times to get accurate count. On failure due to
    // continuous async changes in table, resort to locking.
    final Segment<K,V>[] segments = this.segments; // 首先不加鎖,每次對各個Segment的count累加求和,如果任意兩次的累加結果相同,則直接返回這個結果;超過一定的次數之後悲觀鎖定所有的Segment,再求和。鎖定之後整個雜湊表不能有任何的寫入操作。
    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 (;;) {
            if (retries++ == RETRIES_BEFORE_LOCK) { // 最大樂觀重試次數
                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的count累加,不加鎖
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            if (sum == last) // 如果本次累加結果和上次相同,說明這中間沒有插入或者刪除操作,直接返回這個結果
                break;
            last = sum;
        }
    } finally {
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size; // 如果溢位,返回最大整型作為結果,否則返回累加結果
}
複製程式碼

總結

  1. ConcurrentHashMap採取分段鎖,是一種典型的"lock-stripping"策略,目的是為了降低高併發情況下的鎖競爭。
  2. rehash過程的擴容不是segment陣列的擴容,而是Segment中HashEntry[] table陣列的擴容,Segment[] segments陣列是final的,在雜湊表初始化完成後不再更改。
  3. ConcurrentHashMap中很多地方用到了volatile,保證了可見性,例如,Segment中的HashEntry[] table陣列,HashEntry中的value和next指標都是volatile的。
  4. 加鎖是低效的,執行緒的上下文切換需要消耗效能,因此ConcurrentHashMap很多地方都用到了樂觀重試的策略,在超過一定次數之後再採取悲觀策略。例如size操作。
  5. 在使用ConcurrentHashMap之前請明確你的資料結構是否真的會有多執行緒併發操作,如果沒有,僅僅是單執行緒操作,請使用HashMap,因為在不考慮併發安全性問題時,不論是HashTable還是ConcurrentHashMap他們的效能都沒有HashMap好。

相關文章