1. 程式人生 > >Java集合---ConcurrentHashMap實現原理

Java集合---ConcurrentHashMap實現原理

ConcurrentHashMap 的實現原理

概述

我們在之前的博文中瞭解到關於 HashMap Hashtable 這兩種集合。其中 HashMap 是非執行緒安全的,當我們只有一個執行緒在使用 HashMap 的時候,自然不會有問題,但如果涉及到多個執行緒,並且有讀有寫的過程中,HashMap 就不能滿足我們的需要了(fail-fast)。在不考慮效能問題的時候,我們的解決方案有 Hashtable 或者Collections.synchronizedMap(hashMap),這兩種方式基本都是對整個 hash 表結構做鎖定操作的,這樣在鎖表的期間,別的執行緒就需要等待了,無疑效能不高。

所以我們在本文中學習一個 util.concurrent 包的重要成員,ConcurrentHashMap

ConcurrentHashMap 的實現是依賴於 Java 記憶體模型,所以我們在瞭解ConcurrentHashMap 的前提是必須瞭解Java 記憶體模型。但 Java 記憶體模型並不是本文的重點,所以我假設讀者已經對 Java 記憶體模型有所瞭解。

ConcurrentHashMap 分析

ConcurrentHashMap 的結構是比較複雜的,都深究去本質,其實也就是陣列和連結串列而已。我們由淺入深慢慢的分析其結構。

先簡單分析一下,ConcurrentHashMap 的成員變數中,包含了一個

Segment 的陣列(finalSegment<K,V>[] segments;),而 Segment ConcurrentHashMap 的內部類,然後在 Segment 這個類中,包含了一個 HashEntry 的陣列(transient volatile HashEntry<K,V>[] table;)。而 HashEntry 也是 ConcurrentHashMap 的內部類。HashEntry 中,包含了 key value 以及 next 指標(類似於 HashMap Entry),所以 HashEntry 可以構成一個連結串列。

所以通俗的講,

ConcurrentHashMap 資料結構為一個 Segment 陣列,Segment 的資料結構為 HashEntry 的陣列,而 HashEntry 存的是我們的鍵值對,可以構成連結串列。

首先,我們看一下 HashEntry 類。

HashEntry

HashEntry 用來封裝雜湊對映表中的鍵值對。在 HashEntry 類中,keyhash next 域都被宣告為 final 型,value 域被宣告為 volatile 型。其類的定義為:

static finalclass HashEntry<K,V> {

        final int hash;

        final K key;

        volatile V value;

        volatile HashEntry<K,V> next;

        HashEntry(int hash, K key, V value,HashEntry<K,V> next) {

            this.hash = hash;

            this.key = key;

            this.value = value;

            this.next = next;

        }

        ...

        ...

}

HashEntry 的學習可以類比著 HashMap 中的 Entry。我們的儲存鍵值對的過程中,雜湊的時候如果發生碰撞,將採用分離連結串列法來處理碰撞:把碰撞的 HashEntry 物件連結成一個連結串列。

如下圖,我們在一個空桶中插入 ABC 兩個 HashEntry 物件後的結構圖(其實應該為鍵值對,在這進行了簡化以方便更容易理解):

Segment

Segment 的類定義為static final class Segment<K,V> extendsReentrantLock implements Serializable。其繼承於 ReentrantLock 類,從而使得 Segment 物件可以充當鎖的角色。Segment 中包含HashEntry 的陣列,其可以守護其包含的若干個桶(HashEntry的陣列)。Segment 在某些意義上有點類似於 HashMap了,都是包含了一個數組,而陣列中的元素可以是一個連結串列。

table:table 是由 HashEntry 物件組成的陣列如果雜湊時發生碰撞,碰撞的 HashEntry 物件就以連結串列的形式連結成一個連結串列table陣列的陣列成員代表雜湊對映表的一個桶每個 table 守護整個ConcurrentHashMap 包含桶總數的一部分如果併發級別為 16table 則守護 ConcurrentHashMap 包含的桶總數的 1/16

count 變數是計算器,表示每個 Segment 物件管理的 table 陣列(若干個 HashEntry 的連結串列)包含的HashEntry 物件的個數。之所以在每個Segment物件中包含一個 count 計數器,而不在ConcurrentHashMap 中使用全域性的計數器,是為了避免出現熱點域而影響併發性。

/**

     * Segments are specialized versions ofhash tables.  This

     * subclasses from ReentrantLockopportunistically, just to

     * simplify some locking and avoid separateconstruction.

     */

    static final class Segment<K,V>extends ReentrantLock implements Serializable {

      /**

         * The per-segment table. Elements areaccessed via

         * entryAt/setEntryAt providingvolatile semantics.

         */

        transient volatileHashEntry<K,V>[] table;

        /**

         * The number of elements. Accessedonly either within locks

         * or among other volatile reads thatmaintain visibility.

         */

        transient int count;

        transient int modCount;

        /**

         * 裝載因子

         */

        final float loadFactor;

    }

我們通過下圖來展示一下插入 ABC 三個節點後,Segment 的示意圖:

其實從我個人角度來說,Segment結構是與HashMap很像的。

ConcurrentHashMap

ConcurrentHashMap 的結構中包含的 Segment 的陣列,在預設的併發級別會建立包含 16 Segment 物件的陣列。通過我們上面的知識,我們知道每個 Segment 又包含若干個散列表的桶,每個桶是由 HashEntry 連結起來的一個連結串列。如果 key 能夠均勻雜湊,每個 Segment 大約守護整個散列表桶總數的 1/16

下面我們還有通過一個圖來演示一下 ConcurrentHashMap 的結構:

併發寫操作

ConcurrentHashMap 中,當執行 put 方法的時候,會需要加鎖來完成。我們通過程式碼來解釋一下具體過程:當我們 new 一個 ConcurrentHashMap 物件,並且執行put操作的時候,首先會執行ConcurrentHashMap 類中的 put 方法,該方法原始碼為:

/**

     * Maps the specified key to the specifiedvalue in this table.

     * Neither the key nor the value can benull.

     *

     * <p> The value can be retrieved bycalling the <tt>get</tt> method

     * with a key that is equal to the originalkey.

     *

     * @param key key with which the specifiedvalue is to be associated

     * @param value value to be associated withthe specified key

     * @return the previous value associatedwith <tt>key</tt>, or

     *        <tt>null</tt> if there was no mapping for<tt>key</tt>

     * @throws NullPointerException if thespecified key or value is null

     */

    @SuppressWarnings("unchecked")

    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;

        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 不允許空值。該方法首先有一個 Segment 的引用 s,然後會通過 hash() 方法對 key 進行計算,得到雜湊值;繼而通過呼叫 Segment put(K key, inthash, V value, boolean onlyIfAbsent)方法進行儲存操作。該方法原始碼為:

final V put(Kkey, int hash, V value, boolean onlyIfAbsent) {

    //加鎖,這裡是鎖定的Segment而不是整個ConcurrentHashMap

    HashEntry<K,V> node = tryLock() ?null :scanAndLockForPut(key, hash, value);

    V oldValue;

    try {

        HashEntry<K,V>[] tab = table;

        //得到hash對應的table中的索引index

        int index = (tab.length - 1) &hash;

        //找到hash對應的是具體的哪個桶,也就是哪個HashEntry連結串列

        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 = newHashEntry<K,V>(hash, key, value, first);

                int c = count + 1;

                if (c > threshold &&tab.length < MAXIMUM_CAPACITY)

                    rehash(node);

                else

                    setEntryAt(tab, index,node);

                ++modCount;

                count = c;

                oldValue = null;

                break;

            }

        }

    } finally {

        //解鎖

        unlock();

    }

    return oldValue;

}

關於該方法的某些關鍵步驟,在原始碼上加上了註釋。

需要注意的是:加鎖操作是針對的 hash 值對應的某個 Segment,而不是整個ConcurrentHashMap。因為 put 操作只是在這個 Segment 中完成,所以並不需要對整個 ConcurrentHashMap 加鎖。所以,此時,其他的執行緒也可以對另外的 Segment 進行 put 操作,因為雖然該 Segment 被鎖住了,但其他的 Segment 並沒有加鎖。同時,讀執行緒並不會因為本執行緒的加鎖而阻塞。

正是因為其內部的結構以及機制,所以 ConcurrentHashMap 在併發訪問的效能上要比Hashtable和同步包裝之後的HashMap的效能提高很多。在理想狀態下,ConcurrentHashMap可以支援 16 個執行緒執行併發寫操作(如果併發級別設定為 16),及任意數量執行緒的讀操作。

總結

在實際的應用中,散列表一般的應用場景是:除了少數插入操作和刪除操作外,絕大多數都是讀取操作,而且讀操作在大多數時候都是成功的。正是基於這個前提,ConcurrentHashMap 針對讀操作做了大量的優化。通過 HashEntry 物件的不變性和用 volatile 型變數協調執行緒間的記憶體可見性,使得大多數時候,讀操作不需要加鎖就可以正確獲得值。這個特性使得 ConcurrentHashMap 的併發效能在分離鎖的基礎上又有了近一步的提高。

ConcurrentHashMap 是一個併發雜湊對映表的實現,它允許完全併發的讀取,並且支援給定數量的併發更新。相比於 HashTable 和用同步包裝器包裝的 HashMapCollections.synchronizedMap(new HashMap())),ConcurrentHashMap 擁有更高的併發性。在 HashTable 和由同步包裝器包裝的 HashMap 中,使用一個全域性的鎖來同步不同執行緒間的併發訪問。同一時間點,只能有一個執行緒持有鎖,也就是說在同一時間點,只能有一個執行緒能訪問容器。這雖然保證多執行緒間的安全併發訪問,但同時也導致對容器的訪問變成序列化的了。

ConcurrentHashMap 的高併發性主要來自於三個方面:

·       用分離鎖實現多個執行緒間的更深層次的共享訪問。

·        HashEntery 物件的不變性來降低執行讀操作的執行緒在遍歷連結串列期間對加鎖的需求。

·       通過對同一個 Volatile 變數的寫 / 讀訪問,協調不同執行緒間讀 / 寫操作的記憶體可見性。

使用分離鎖,減小了請求同一個鎖的頻率。

通過 HashEntery 物件的不變性及對同一個 Volatile 變數的讀 / 寫來協調記憶體可見性,使得讀操作大多數時候不需要加鎖就能成功獲取到需要的值。由於雜湊對映表在實際應用中大多數操作都是成功的讀操作,所以 2 3 既可以減少請求同一個鎖的頻率,也可以有效減少持有鎖的時間。通過減小請求同一個鎖的頻率和儘量減少持有鎖的時間,使得 ConcurrentHashMap 的併發性相對於 HashTable 和用同步包裝器包裝的 HashMap有了質的提高。