HashMap為何執行緒不安全?HashMap,HashTable,ConcurrentHashMap對比

寒光瀲灩晴方好 發表於 2022-11-30
HashMap

這兩天寫爬蟲幫組裡收集網上資料做訓練,需要進一步對收集到的json資料做資料清洗,結果就用到了多執行緒下的雜湊表資料結構,猛地回想起自己看《Java併發程式設計的藝術》框架篇的時候,在ConcurrentHashMap的章節看到過使用HashMap是執行緒不安全的,HashTable雖然安全但效率很低,推薦使用ConcurrentHashMap巴拉巴拉,突然有了興趣來查閱一下各自的原始碼,看看具體區別在哪裡呢?HashMap為什麼執行緒不安全?順帶記錄下來,還是那句話,好記性不如爛筆頭


我們知道的Java中的雜湊表資料結構有下面三種

  • HashMap
  • HashTable
  • ConcurrentHashMap

下面就依次來看看它們是如何保證併發時可靠的,各自有什麼優缺點

HashMap

首先是大家都很熟悉的雜湊表:HashMap,刷演算法題必備雜湊表資料結構。它的儲存結構如下圖所示

雜湊表結構圖

很好看懂的一個圖,簡單來說就是HashMap採用的是拉鍊法處理雜湊衝突。所謂雜湊衝突就是由於雜湊表根據雜湊值索引目標節點來對隨機存取獲得O(1)的時間複雜度,那麼每個雜湊值當然只能站一個節點,如果存在多個節點計算出的雜湊值一致就發生了雜湊衝突,此時一般有三種思路:

  • 拉鍊法:在一個雜湊值上設定一個資料集結構,也就是一個雜湊值代表一個資料集,我們對資料集的隨機存取獲得O(1)時間複雜度,對資料集內獲取目標Key節點獲得O(m)時間複雜度,如果雜湊值的數量遠多於資料集內節點的數量,那麼我們近似取到O(1)時間複雜度
  • 開放定址法:一旦碰到雜湊衝突就順延後來的節點的雜湊值,比如節點A取雜湊為1,而雜湊值1,2,3上都已經有節點在了,那麼我們根據順延規則取4作為該節點的真實儲存位置,這種方案一般表現比較糟糕
  • 再雜湊法:同時構造多個不同的雜湊函式,等發生雜湊衝突時就使用第二個、第三個,第四個等等等等的其他的雜湊函式計算地址,直到不發生衝突為止。雖然不易發生聚集,但是大大增加了計算時間

這裡我們常用的三種雜湊表結構全部是採用的拉鍊法,這是一種認可度較高的解決方案,那麼拉鍊法就要求我們每個雜湊值都獨立設定一個連結串列來儲存雜湊衝突的節點。那麼我們關於多執行緒安全的問題自然也就來自於此。
總的來說,HashMap在多執行緒時視使用的Java版本有以下三大問題

  • 資料覆蓋(一直存在)
  • 死迴圈(JDK1.7前存在)
  • 資料丟失(JDK1.7前存在)
    我們一個一個來看

資料覆蓋

顧名思義,兩個執行緒同時往裡面放資料,但是其中一個資料放丟了,這個問題是根本問題,目前的HashMap依然有這個問題,出問題的原因也很簡單,HashMap本來就沒做多執行緒適配當然出問題,但是原理還是值得一看。

// 程式碼擷取自HashMap.java,方法final V putVal()中
for (int binCount = 0; ; ++binCount) {
    // 根據下面程式碼,我們看出其實插入新節點就是反覆探測目前節點的next指標是否為空,若為空則在該指標上插入新節點
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

看完以後我們發現,其實就是很簡單的探測連結串列尾部的方法,看該節點next指標是否為null,若為null說明是尾節點,在其後插入新節點,那麼資料覆蓋的真相也就很簡單了:A,B兩個執行緒同時向同一個雜湊值的連結串列發起插入,A探測到C節點的next為空然後時間片用完被換下,此時B也探測到C的next為空並完成了插入,等到A再次換入時間片,完成插入,最終,A,B執行結束,但B插入的新節點就這樣消失了。這就是資料覆蓋問題。

死迴圈與資料丟失
其實這兩個問題的核心都來自JDK1.7前,HashMap的擴容操作(擴容採用頭插法插入)會重新定位每個桶的下標,並採用頭插法將元素遷移到新陣列中。而頭插法會將連結串列的順序翻轉,這也是造成死迴圈和資料丟失的關鍵。

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    // 採用頭插法
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
} 

怎麼一回事呢?

現在假設執行緒啟動,有執行緒A和執行緒B都準備對HashMap進行擴容操作, 此時A和B指向的都是連結串列的頭節點NodeA,而A和B的下一個節點的指標即head.next和head.next都指向NodeB節點。

那麼,開始擴容,這時候,假設執行緒B的時間片用完,被換下CPU,而執行緒A開始執行擴容操作,一直到執行緒A擴容完成後,執行緒B才被喚醒。

此時因為HashMap擴容採用的是頭插法,執行緒A執行之後,連結串列中的節點順序已經倒轉,本來NodeA->NodeB,現在變成了NodeB->NodeA。但就緒態的執行緒B對於發生的一切都不清楚,所以它指向的節點引用依然沒變。那麼一旦B被換上CPU,重複一次剛剛A做過的事情,就會導致NodeA和NodeB的next指標相互指向,導致死迴圈和資料丟失。

不過JDK1.8以後,HashMap的雜湊值擴容改為了尾插法擴容,就不會再出現這些問題了。


HashTable

效率很差的一個類,根據我自己的周邊統計學,我的感覺是這個玩意根本沒人用,用它就類似於如果你給線上專案的MySQL突然整出個表級鎖一樣,等你的只能是一通臭罵!為什麼呢?看下面

// 你就看這個synchronized關鍵字就可以了,不用往下看了
public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}

很明顯了。HashTable採用了Synchronized關鍵字來保證執行緒安全。

我們知道Synchronized關鍵字的底層原理是給物件頭上的MarkWord的內容做改動從而將該物件當作互斥變數使用,也就是說,這把鎖是物件級別的。
問題就在於我本來雜湊衝突只是一個雜湊值上的衝突,而你的解決方案是鎖住整個雜湊表,這會不會有點太過分了?可以說表級鎖的比喻是很貼切了。

不推薦使用,效率很低。


ConcurrentHashMap

《Java併發程式設計的藝術》裡面提到的第一個併發結構,它的思路就是在HashTable表級鎖的基礎上把它改為行級鎖,什麼意思呢?放原始碼

// 擷取自ConcurrentHashMap.java,final V putVal()方法
// 注意下面這句,f 是我們需要的雜湊值對應的首節點
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
          break;                   // no lock when adding to empty bin
  }
  else if ((fh = f.hash) == MOVED)
      tab = helpTransfer(tab, f);
  else if (onlyIfAbsent // check first node without acquiring lock
           && fh == hash
           && ((fk = f.key) == key || (fk != null && key.equals(fk)))
           && (fv = f.val) != null)
      return fv;
  else {
      V oldVal = null;
// 來到這裡,上面的都不用看,主要是排查初始化情況,這裡synchronized(f)就解釋清楚了我們的ConcurrentHashMap的方法
      synchronized (f) {
          if (tabAt(tab, i) == f) {
              if (fh >= 0) {
                  binCount = 1;
                  for (Node<K,V> e = f;; ++binCount) {
                      K ek;
                      if (e.hash == hash &&
                          ((ek = e.key) == key ||
                           (ek != null && key.equals(ek)))) {
                          oldVal = e.val;
                          if (!onlyIfAbsent)
                              e.val = value;
                          break;
                      }
                      Node<K,V> pred = e;
                      if ((e = e.next) == null) {
                          pred.next = new Node<K,V>(hash, key, value);
                          break;
                      }
                  }
              }

很明顯,ConcurrentHashMap同樣是透過Synchronized關鍵字來實現執行緒安全的,只不過這把鎖從原來的表級鎖,變為了以首節點為物件的行級鎖,當我們併發的對ConcurrentHashMap操作時,鎖只會鎖住某一個雜湊值,而不會鎖住整個表,保證了我們的雜湊表在高併發場景下的效率。

總結

HashMap只適用於非併發情況下,ConcurrentHashMap適用於併發情況下,而HashTable則不推薦使用