1. 程式人生 > >淺析HashMap與ConcurrentHashMap的執行緒安全性

淺析HashMap與ConcurrentHashMap的執行緒安全性

本文要解決的問題:

最近無意中發現有很多對Map尤其是HashMap的執行緒安全性的話題討論,在我的理解中,對HashMap的理解中也就知道它是執行緒不安全的,以及HashMap的底層演算法採用了鏈地址法來解決雜湊衝突的知識,但是對其執行緒安全性的認知有限,故寫這篇部落格的目的就是讓和我一樣對這塊內容不熟悉的小夥伴有一個對HashMap更深的認知。

雜湊表

在資料結構中有一種稱為雜湊表的資料結構,它實際上是陣列的推廣。如果有一個數組,要最有效的查詢某個元素的位置,如果儲存空間足夠大,那麼可以對每個元素和記憶體中的某個地址對應起來,然後把每個元素的地址用一個數組(這個陣列也稱為雜湊表)儲存起來,然後通過陣列下標就可以直接找到某個元素了。這種方法術語叫做直接定址法

。這種方法的關鍵是要把每個元素和某個地址對應起來,所以如果當一組資料的取值範圍很大的時候,而地址的空間又有限,那麼必然會有多個對映到同一個地址,術語上稱為雜湊衝突,這時對映到同一個地址的元素稱為同義詞。畢竟,儲存空間有限,所以衝突是不可避免的,但是可以儘量做到減少衝突。目前有兩種比較有效的方法來解決雜湊衝突:

  • 鏈地址法
  • 開放地址法

這裡簡要說明一下開放地址法,顧名思義,就是雜湊表中的每個位置要麼儲存了一個元素要麼為NULL。當資料比較多的時候,查詢一個元素挺費事的,但是可以使用探測的方法進行查詢。這個話題與本主題關係不大,感興趣的小夥伴可以自行研究。

鏈地址法

為什麼要把鏈地址法單獨拿出來呢?因為後面有用。
鏈地址法的大概思想是:對於每個關鍵字,使用雜湊函式確定其在雜湊表中位置(也就是下標),如果該位置沒有元素則直接對映到該地址;如果該位置已經有元素了,就把該元素連線到已存在元素的尾部,也就是一個連結串列,並把該元素的next設定為null。這樣的話,每個雜湊表的位置都可能存在一個連結串列,這種方式要查詢某個元素效率比較高,時間複雜度為O(1+a),a為雜湊表中每個位置連結串列的平均長度。這裡需要假設每個元素都被等可能對映到雜湊表中的任意一個位置。

下面這張圖展示了鏈地址法的過程:

鏈地址法

HashMap

HashMap底層實現

HashMap允許使用null作為key或者value,並且HashMap不是執行緒安全的,除了這兩點外,HashMap與Hashtable大致相同,下面是官方API對HashMap的描述:

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

如果有多個執行緒對Hash對映進行訪問,那麼至少有一個執行緒會對雜湊對映進行結構的修改:

結構上的修改是指新增或刪除一個或多個對映關係的任何操作;僅改變與例項已經包含的鍵關聯的值不是結構上的修改

那麼很顯然,當多個執行緒同時(嚴格來說不能稱為同時,因為CPU每次只能允許一個執行緒獲取資源,只不過時間上非常短,CPU執行速度很快,所以理解為同時)修改雜湊對映,那麼最終的雜湊對映(就是雜湊表)的最終結果是不能確定的,這隻能看CPU心情了。如果要解決這個問題,官方的參考方案是保持外部同步,什麼意思?看下面的程式碼就知道了:

Map m = Collections.synchronizedMap(new HashMap(...));

但是不建議這麼使用,因為當多個併發的非同步操作修改雜湊表的時候,最終結果不可預測,所以使用上面的方法建立HashMap的時候,當有多個執行緒併發訪問雜湊表的情況下,會丟擲異常,所以併發修改會失敗。比如下面這段程式碼:

for (int i = 0; i < 20; i++) {
        collectionSynMap.put(i, String.valueOf(i));
    }
    Set<Entry<Integer,String>> keySets = collectionSynMap.entrySet();
    Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
    try {
        while(keySetsIterator.hasNext()){
            Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
            System.out.println(entrys.getValue());
            if(entrys.getValue().equals("1")){
                System.out.println(entrys.getValue());
                collectionSynMap.remove(1);
                //keySetsIterator.remove();
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }

就會丟擲ConcurrentModificationException異常,因為在使用迭代器遍歷的時候修改對映結構,但是使用程式碼中註釋的刪除是不會丟擲異常的。

通過上面的分析,我們初步瞭解HashMap的非執行緒安全的原理,下面從原始碼的角度分析一下,為什麼HashMap不是執行緒安全的:

public V put(K key, V value) {
    //這裡省略了對重複鍵值的處理程式碼
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

那麼問題應該處在addEntry()上,下面來看看其原始碼:

void addEntry(int hash, K key, V value, int bucketIndex) {
    //如果達到Map的閾值,那麼就擴大雜湊表的容量
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //擴容
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    //建立Entry鍵值對,此處省略這部分程式碼
}

假設有執行緒A和執行緒B都呼叫addEntry()方法,執行緒A和B會得到當前雜湊表位置的頭結點(就是上面鏈地址法的第一個元素),並修改該位置的頭結點,如果是執行緒A先獲取頭結點,那麼B的操作就會覆蓋執行緒A的操作,所以會有問題。

下面再看看resize方法的原始碼:

void resize(int newCapacity) {
    //此處省略如果達到閾值擴容為原來兩倍的過程程式碼
    Entry[] newTable = new Entry[newCapacity];
    //把當前的雜湊錶轉移到新的擴容後的雜湊表中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

所以如果有多個執行緒執行put方法,並呼叫resize方法,那麼就會出現多種情況,在轉移的過程中丟失資料,或者擴容失敗,都有可能,所以從原始碼的角度分析這也是執行緒不安全的。

HashMap測試程式碼

for (int i = 0; i < 40; i++) {
    hashMap.put(i, String.valueOf(i));
}
Set<Entry<Integer,String>> keySets = hashMap.entrySet();
final Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
Thread t3 = new Thread(){
    public void run(){
        try {
            while(keySetsIterator.hasNext()){
                Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
                System.out.println(entrys.getValue());
                if(entrys.getValue().equals("1")){
                    System.out.println(entrys.getValue());
                    hashMap.remove(1);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
};
Thread t4 = new Thread(){
    public void run(){
        try {
            while(keySetsIterator.hasNext()){
                Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
                System.out.println(entrys.getValue());
                if(entrys.getValue().equals("1")){
                    System.out.println(entrys.getValue());
                    hashMap.remove(1);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
};
t3.start();
t4.start();

這段程式碼啟動了兩個執行緒併發修改HashMap的對映關係,所以會丟擲兩個ConcurrentModificationException異常,通過這段測試程式碼在此證明了HashMap的非執行緒安全。

Hashtable和ConcurrentHashMap

Hashtable的底層實現

在介紹HashMap提到Hashtable是執行緒安全的,那麼H啊時table是如何實現執行緒安全的呢?有了上面的介紹,我們直接從原始碼中分析其執行緒安全性:

public synchronized V put(K key, V value) {
    // 保證value值不為空,此處省略其程式碼
    // 保證key是不重複的,此處省略其程式碼
    //查過閾值則擴容,此處省略
    // Creates the new entry.
    Entry<K,V> e = tab[index];
    tab[index] = new Entry<>(hash, key, value, e);
    count++;
    return null;
}

通過原始碼可以很明顯看到其put方法使用synchronized關鍵字,線上程中這是實現執行緒安全的一種方式,所以Hashtable是執行緒安全的。

Hashtable的測試案例

下面使用一段測試程式碼驗證Hashtable的執行緒安全:

Thread t3 = new Thread(){
        public void run(){
            for (int i = 0; i < 20; i++) {
                hashTable.put(i, String.valueOf(i));
            }
        }
    };
    Thread t4 = new Thread(){
        public void run(){
            for (int i = 20; i < 40; i++) {
                hashTable.put(i, String.valueOf(i));
            }
        }
    };
    t3.start();
    t4.start();
    //放完資料後,從map中取出資料,如果map是執行緒安全的,那麼取出的entry應該和放進去的一一對應
    for (int i = 0; i < 40; i++) {
        System.out.println(i + "=" + hashTable.get(i));
    }

最後得到的輸出結果是這樣的:

![Hashtable](http://7xkjk9.com1.z0.glb.clouddn.com/ConcurrentHashMap_put結果.jpg)

OK,再次說明Hashtable是執行緒安全的。

ConcurrentHashMap的底層實現

ConcurrentHashMap支援完全併發的對雜湊表的操作,ConcurrentHashMap遵從了和Hashtable一樣的規範,這裡指的是執行緒安全的規範,但是其底層的實現與Hashtable並不一致。ConcurrentHashMap底層採用的鎖機制,執行put方法的執行緒會獲得鎖,只有當此執行緒的put方法執行結束後才會釋放鎖,根據多執行緒的知識,獲得鎖的執行緒會通知其他試圖操作put方法的執行緒,並通知其他執行緒出於等待狀態,直到釋放鎖後,其他執行緒才會去重新競爭鎖。這一點保證了ConcurrentHashMap的執行緒安全。

注:這裡涉及到了執行緒鎖的知識,如果對這塊內容不熟悉,可以參考API。
引用一段官方API對ConcurrentHashMap的描述:

A hash table supporting full concurrency of retrievals and adjustable expected concurrency for updates. This class obeys the same functional specification as Hashtable, and includes versions of methods corresponding to each method of Hashtable. However, even though all operations are thread-safe, retrieval operations do not entail locking, and there is not any support for locking the entire table in a way that prevents all access. This class is fully interoperable with Hashtable in programs that rely on its thread safety but not on its synchronization details.

從這段描述可以看出,ConcurrentHashMap實際上是Hashtable的升級版,除了具備執行緒安全外還增加了迭代器快速失敗行為的異常處理,也就是說,通過ConcurrentHashMap對Iterator迭代器結構的修改不會丟擲異常,而Hashtable會丟擲異常,因而就Hashtable來說,如果迭代器修改了對映結構,那麼遍歷的結果是不確定的,而ConcurrentHashmap支援之允許一個執行緒對迭代器的對映結構進行修改。

那麼我們接著從原始碼的角度分析ConcurrentHashMap是如何實現執行緒安全的:

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把要放入的資料分成了多段資料,然後對每段的put操作進行加鎖,下面看一下ensureSegment方法:

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<K,V> proto = ss[0]; // use segment 0 as prototype
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // recheck
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

這段程式碼的作用就是根據給定的索引,返回某個具體的Segment,然後根據返回的Segment(塊)加鎖執行put方法。
再看s.put()方法:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        HashEntry<K,V> node = tryLock() ? null :
            scanAndLockForPut(key, hash, value);
        V oldValue;
        try {
            //此處省略詳細的處理過程
            }
        } finally {
            unlock();
        }
        return oldValue;
    }

在上面的原始碼中出現了Segment s,我們來看看它何方神聖:

Segments are specialized versions of hash tables. This subclasses from ReentrantLock opportunistically, just tosimplify some locking and avoid separate construction.

從這段註釋中可以發現每次執行ConcurrentHashMap的put方法都是呼叫s.put()方法的,而Segments物件是一個繼承了ReentrantLock鎖物件的子類,那麼剩下的就很清晰了,每一個Segments都有一個鎖,只有執行完上面try語句塊中的程式碼才會釋放鎖,從而保證了多執行緒併發訪問的安全性。

下面來看看ConcurrentHashMap的get方法:

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;
    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);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

get操作會通過key找到雜湊表的雜湊值,根據雜湊值定位到某個Segment,然後再從Segment中返回value

ConcurrentHashMap的測試案例

下面仍然通過一段測試程式驗證ConcurrentHashMap的執行緒安全:

Thread t5 = new Thread(){
        public void run(){
            for (int i = 0; i < 20; i++) {
                concurrentHashMap.put(i, String.valueOf(i));
            }
        }
    };
    Thread t6 = new Thread(){
        public void run(){
            for (int i = 20; i < 40; i++) {
                concurrentHashMap.put(i, String.valueOf(i));
            }
        }
    };
    t5.start();
    t6.start();
    for (int i = 0; i < 40; i++) {
        System.out.println(i + "=" + concurrentHashMap.get(i));
    }

最後,控制檯輸出的結果如下:

![ConcurrentHashMap](http://7xkjk9.com1.z0.glb.clouddn.com/ConcurrentHashMap_put結果.jpg)

小結

說了那麼多,針對Map子類的安全性可以總結如下幾點:

  • HashMap採用鏈地址法解決雜湊衝突,多執行緒訪問雜湊表的位置並修改對映關係的時候,後執行的執行緒會覆蓋先執行執行緒的修改,所以不是執行緒安全的
  • Hashtable採用synchronized關鍵字解決了併發訪問的安全性問題但是效率較低
  • ConcurrentHashMap使用了執行緒鎖分段技術,每次訪問只允許一個執行緒修改雜湊表的對映關係,所以是執行緒安全的