Java7中的 ConcurrentHashMap存儲結構

Java7中的 ConcurrentHashMap總體的設計思路和Java7中的HashMap差不多,但是因爲需要支持併發的操作,那麼就需要對其中的數據結構進行加鎖處理,用以保證數據的一致性。

值得一題的是ConcurrentHashMap不支持Null值和Null鍵

按照Java7中hashMap的思路,我們去查找這麼一個帶鎖的可以存儲數據的成員,發現,似乎只有以下這麼一個可疑對象:

    /**
     * The segments, each of which is a specialized hash table. 
     * 每一段都是一個hash表
     */
    final Segment<K, V>[] segments;

在Segment的類定義上可以看到是繼承了可重入鎖:

 /**
     * Segments are specialized versions of hash tables.  This
     * subclasses from ReentrantLock opportunistically, just to
     * simplify some locking and avoid separate construction.
     */
static final class Segment<K, V> extends ReentrantLock implements Serializable {...

該類的成員變量中有我們熟悉的table變量

        /**
         * The per-segment table. Elements are accessed via
         * entryAt/setEntryAt providing volatile semantics.
         */
        transient volatile HashEntry<K, V>[] table;

至此,我們找到了對應的鎖,以及存儲數據的數組,那麼可以判斷出來ConcurrentHashMap的結構大致爲:

ConcurrentHashMap 由一個個 Segment 組成(簡言之就是Segment的數組),Segment 代表”部分“或”一段“的意思(其通過繼承ReentrantLock實現了加鎖操作),所以很多地方都會將其描述爲分段鎖

PS:因爲每次需要加鎖的操作鎖住的是一個 segment,這樣只要保證每個 Segment 是線程安全的,也就實現了全局的線程安全。

那麼ConcurrentHashMap的內部組成大致如下:

成員變量: 

    //默認初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    //默認加載因子(針對Segment數組中的某個Segment中的HashEntry數組擴容)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //默認的並行級別 16(Segment數組的大小),也成爲併發量
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    //最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //一個Segment的HashEntry數組的最小容量
    static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

    //一個Segment的HashEntry數組的最大容量
    static final int MAX_SEGMENTS = 1 << 16; // slightly conservative

    // 鎖之前重試次數
    static final int RETRIES_BEFORE_LOCK = 2;

構造方法:類中共提供了5個構造,其他構造都是下面構造的重載

public ConcurrentHashMap(int initialCapacity,
                          float loadFactor, int concurrencyLevel) {...//核心構造

 

初始化:構造中的核心代碼如下

initialCapacity:初始容量,這個值指的是整個 ConcurrentHashMap 的初始容量,
                 實際操作的時候需要平均分給每個 Segment。

loadFactor:負載因子,因爲Segment 數組不可以擴容,所以這個負載因子是給每個 Segment 內部使用的。

 

public ConcurrentHashMap(int initialCapacity,
                              float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;// 默認計算結果是 4
            ssize <<= 1; // 計算下來是16 2的4次方
        }
        //用默認值,concurrencyLevel 爲 16,sshift 爲 4
        // 那麼計算出 segmentShift 爲 28,segmentMask 爲 15,後面會用到這兩個值
        this.segmentShift = 32 - sshift; // 默認計算完是28
        this.segmentMask = ssize - 1; // 默認15
        if (initialCapacity > MAXIMUM_CAPACITY)// initialCapacity默認16
            initialCapacity = MAXIMUM_CAPACITY;
        // initialCapacity 是設置整個 map 初始的大小,
        // 根據 initialCapacity 計算 Segment 數組中每個位置可以分到的大小
        // 如 initialCapacity 爲 64,那麼每個 Segment 可以分到 4 個
        int c = initialCapacity / ssize; // 默認是1
        if (c * ssize < initialCapacity)
            ++c;
        // 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,這個值也是有講究的,因爲這樣的話,對於具體的槽上,
        // 插入一個元素不至於擴容,插入第二個的時候纔會擴容
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0]
        // 創建 Segment 數組,
        // 並創建數組的第一個元素 segment[0]
        Segment<K, V> s0 =
                new Segment<K, V>(loadFactor, (int) (cap * loadFactor),
                        (HashEntry<K, V>[]) new HashEntry[cap]);
        // 創建一個segment數組
        Segment<K, V>[] ss = (Segment<K, V>[]) new Segment[ssize];
        // 往數組寫入 segment[0]
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss; //  默認構建了一個 長度爲16的segment數組
    }

 put 方法分析:

    public V put(K key, V value) {
        Segment<K, V> s;
        if (value == null)
            throw new NullPointerException();
//      1.計算key的hash值
        int hash = hash(key); // 根據key找segment
//      2.根據hash值找到segment數組中的位置 j hash 是 32 位,
//        無符號右移 segmentShift(28) 位,剩下高 4 位,然後和 segmentMask(15) 做一次與操作,也就是說 j 是 hash 值的高 4 位,也就是槽的數組下標
        int j = (hash >>> segmentShift) & segmentMask;
//        初始化的時候初始化了 segment[0],但是其他位置還是 null, ensureSegment(j) 對 segment[j] 進行初始化
        if ((s = (Segment<K, V>) UNSAFE.getObject          // nonvolatile; recheck
                (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
//      3.插入新值到segment中
        return s.put(key, hash, value, false);
    }
  •  首先,計算key的hash值
  • 其次,根據hash值找到需要操作的Segment的數組位置
  • 最後,將新值插入到Segment對應的鏈表中

其中有個初始化分段鎖的方法值得注意: 

private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        // 這裏看到爲什麼之前要初始化 segment[0] 了,
        // 使用當前 segment[0] 處的數組長度和負載因子來初始化 segment[k]
        // 爲什麼要用“當前”,因爲 segment[0] 可能早就擴容過了
        Segment<K,V> proto = ss[0];
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);

        // 初始化 segment[k] 內部的數組
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // 再次檢查一遍該槽是否被其他線程初始化了。

            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 使用 while 循環,內部用 CAS,當前線程成功設值或其他線程成功設值後,退出
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

 

       final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//            在向segment 寫入前,需要先獲取該 segment 的獨佔鎖
            HashEntry<K, V> node = tryLock() ? null :
                    scanAndLockForPut(key, hash, value); // put時鎖定。如果當前沒這條數據,則會返回新創建的HashEntry,否則爲空
            V oldValue;
            try {
//              segment 內部的數組
                HashEntry<K, V>[] tab = table;
                // 再利用 hash 值,求應該放置的數組下標
                int index = (tab.length - 1) & hash;
                // 返回數組中對應位置的元素(鏈表頭部)
                HashEntry<K, V> first = entryAt(tab, index);
            //循環鏈表
                for (HashEntry<K, V> e = first; ; ) {
                    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;
                        //如果超過了該 segment 的閾值,這個 segment 需要擴容
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node); // 超過了容量閾值,但沒達到最大限制,則擴容table
//                        沒有達到閾值,將 node 放到數組 tab 的 index 位置,
                        else
                            setEntryAt(tab, index, node); // 直接用新的node,替換掉舊的first node
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();//最終釋放鎖
            }
            return oldValue;
        }

簡單理解主要分爲三個部分,爲實現線程安全,首先要加鎖,然後操作數據(這個部分稍複雜些),最後釋放鎖 

 獲取寫入鎖:在tryLock成功時,獲取鎖,在重試次數超過最大次數後方法阻塞直至成功獲取鎖

scanAndLockForPut(key, hash, value)
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node

    // 循環獲取鎖
    while (!tryLock()) {//---如果tryLock成功及跳出循環
        HashEntry<K,V> f; // to recheck first below
        if (retries < 0) {
            if (e == null) {
                if (node == null) // speculatively create node
                    // 進到這裏說明數組該位置的鏈表是空的,沒有任何元素
                    // 當然,進到這裏的另一個原因是 tryLock() 失敗,所以該分段鎖存在併發,不一定是該位置
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))
                retries = 0;
            else
                // 順着鏈表往下走
                e = e.next;
        }
        // 重試次數如果超過 MAX_SCAN_RETRIES(單核1多核64),那麼不搶了,進入到阻塞隊列等待鎖
        //    lock() 是阻塞方法,直到獲取鎖後返回
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
        else if ((retries & 1) == 0 &&
                 // 這個時候是有大問題了,那就是有新的元素進到了鏈表,成爲了新的表頭
                 //     所以這邊的策略是,相當於重新走一遍這個 scanAndLockForPut 方法
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

 Segment內部的數組擴容:擴容比例是原來的2倍,這個和Java7 HashMap一致

// 方法參數上的 node 是這次擴容後,需要添加到新的數組中的數據。
private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 2 倍
    int newCapacity = oldCapacity << 1;
    threshold = (int)(newCapacity * loadFactor);
    // 創建新數組
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩碼,如從 16 擴容到 32,那麼 sizeMask 爲 31,對應二進制 ‘000...00011111’
    int sizeMask = newCapacity - 1;

    // 遍歷原數組,老套路,將原數組位置 i 處的鏈表拆分到 新數組位置 i 和 i+oldCap 兩個位置
    for (int i = 0; i < oldCapacity ; i++) {
        // e 是鏈表的第一個元素
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 計算應該放置在新數組中的位置,
            // 假設原數組長度爲 16,e 在 oldTable[3] 處,那麼 idx 只可能是 3 或者是 3 + 16 = 19
            int idx = e.hash & sizeMask;
            if (next == null)   // 該位置處只有一個元素,那比較好辦
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // e 是鏈表表頭
                HashEntry<K,V> lastRun = e;
                // idx 是當前鏈表的頭結點 e 的新位置
                int lastIdx = idx;

                // 下面這個 for 循環會找到一個 lastRun 節點,這個節點之後的所有元素是將要放到一起的
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // 將 lastRun 及其之後的所有節點組成的這個鏈表放到 lastIdx 這個位置
                newTable[lastIdx] = lastRun;
                // 下面的操作是處理 lastRun 之前的節點,
                //    這些節點可能分配在另一個鏈表中,也可能分配到上面的那個鏈表中
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    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);
                }
            }
        }
    }
    // 將新來的 node 放到新數組中剛剛的 兩個鏈表之一 的 頭部
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

 該部分的擴容邏輯比起Java7 HashMap較爲複雜,容許再度消化下。

get方法分析

  • 計算hash值,定位到segment數組的位置
  • 根據上面的hash值再度定位到Segment分段鎖下,數據存儲的位置
  • 此時已經獲取到連接結構,獲取邏輯至此已經明瞭了 
public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    // 1. hash 值
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 2. 根據 hash 找到對應的 segment
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        // 3. 找到segment 內部數組相應位置的鏈表,遍歷
        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;
}

 總結(摘錄他人的思考):

在 put 過程和 get 過程,我們可以看到 get 過程中是沒有加鎖的,那自然我們就需要去考慮併發問題。

添加節點的操作 put 和刪除節點的操作 remove 都是要加 segment 上的獨佔鎖的,
所以它們之間不會有問題,那麼 get 的時候在同一個 segment 中發生了 put 或 remove時會怎樣呢?

put 操作的線程安全性。

初始化分段鎖,使用了 CAS 來初始化 Segment 中的數組。
添加節點到鏈表的操作是插入到表頭的,
    所以,如果這個時候 get 操作在鏈表遍歷的過程已經到了中間,是不會影響的。
    當然,另一個併發問題就是 get 操作在 put 之後,需要保證剛剛插入表頭的節點被讀取,
    這個依賴於 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
擴容。
    擴容是新創建了數組,然後進行遷移數據,最後面將 newTable 設置給屬性 table。
    所以,如果 get 操作此時也在進行,那麼也沒關係,如果 get 先行,
    那麼就是在舊的 table 上做查詢操作;而 put 先行,那麼 put 操作的可見性保證就是 table 使用了 volatile 關鍵字。
remove 操作的線程安全性。

remove 操作我們沒有分析源碼,所以這裏說的讀者感興趣的話還是需要到源碼中去求實一下的。

get 操作需要遍歷鏈表,但是 remove 操作會"破壞"鏈表。

如果 remove 破壞的節點 get 操作已經過去了,那麼這裏不存在任何問題。

如果 remove 先破壞了一個節點,分兩種情況考慮。 

1、如果此節點是頭結點,那麼需要將頭結點的 next 設置爲數組該位置的元素,table 雖然使用了 volatile 修飾,
    但是 volatile 並不能提供數組內部操作的可見性保證,所以源碼中使用了 UNSAFE 來操作數組,請看方法 setEntryAt。
2、如果要刪除的節點不是頭結點,它會將要刪除節點的後繼節點接到前驅節點中,
    這裏的併發保證就是 next 屬性是 volatile 的。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章