淺析HashMap與ConcurrentHashMap的線程安全性

本文要解決的問題:

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

哈希表

在數據結構中有一種稱爲哈希表的數據結構,它實際上是數組的推廣。如果有一個數組,要最有效的查找某個元素的位置,如果存儲空間足夠大,那麼可以對每個元素和內存中的某個地址對應起來,然後把每個元素的地址用一個數組(這個數組也稱爲哈希表)存儲起來,然後通過數組下標就可以直接找到某個元素了。這種方法術語叫做直接尋址法。這種方法的關鍵是要把每個元素和某個地址對應起來,所以如果當一組數據的取值範圍很大的時候,而地址的空間又有限,那麼必然會有多個映射到同一個地址,術語上稱爲哈希衝突,這時映射到同一個地址的元素稱爲同義詞。畢竟,存儲空間有限,所以衝突是不可避免的,但是可以儘量做到減少衝突。目前有兩種比較有效的方法來解決哈希衝突:

  1. 鏈地址法
  2. 開放地址法

這裏簡要說明一下開放地址法,顧名思義,就是哈希表中的每個位置要麼存儲了一個元素要麼爲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的時候,當有多個線程併發訪問哈希表的情況下,會拋出異常,所以併發修改會失敗。

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

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是線程安全的。

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

小結

  1. HashMap採用鏈地址法解決哈希衝突,多線程訪問哈希表的位置並修改映射關係的時候,後執行的線程會覆蓋先執行線程的修改,所以不是線程安全的
  2. Hashtable採用synchronized關鍵字解決了併發訪問的安全性問題但是效率較低
  3. ConcurrentHashMap使用了線程鎖分段技術,每次訪問只允許一個線程修改哈希表的映射關係,所以是線程安全的
    本文大部分內容轉自這篇博客
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章