深入瞭解ConcurrentHashMap

detectiveHLH發表於2020-06-02

在上一篇文章【簡單瞭解系列】從基礎的使用來深挖HashMap裡,我從最基礎的使用中介紹了HashMap,大致是JDK1.7和1.8中底層實現的變化,和介紹了為什麼在多執行緒下可能會造成死迴圈,擴容機制是什麼樣的。感興趣的可以先看看。

我們知道,HashMap是非執行緒安全的容器,那麼為什麼ConcurrentHashMap能夠做到執行緒安全呢?

底層結構

首先看一下ConcurrentHashMap的底層資料結構,在Java8中,其底層的實現方式與HashMap一樣的,同樣是陣列、連結串列再加紅黑樹,具體的可以參考上面的HashMap的文章,下面所有的討論都是基於Java 1.8。

transient volatile Node<K,V>[] table;

volatile關鍵字

對比HashMap的底層結構可以發現,table的定義中多了一個volatile關鍵字。這個關鍵字是做什麼的呢?我們知道所有的共享變數都存在主記憶體中,就像table。

而執行緒對變數的所有操作都必須線上程自己的工作記憶體中完成,而不能直接讀取主存中的變數,這是JMM的規定。所以每個執行緒都會有自己的工作記憶體,工作記憶體中存放了共享變數的副本。而正是因為這樣,才造成了可見性的問題。

ABCD四個執行緒同時在操作一個共享變數X,此時如果A從主存中讀取了X,改變了值,並且寫回了記憶體。那麼BCD執行緒所得到的X副本就已經失效了。此時如果沒有被volatile修飾,那麼BCD執行緒是不知道自己的變數副本已經失效了。繼續使用這個變數就會造成資料不一致的問題。

記憶體可見性

而如果加上了volatile關鍵字,BCD執行緒就會立馬看到最新的值,這就是記憶體可見性。你可能想問,憑什麼加了volatile的關鍵字就可以保證共享變數的記憶體可見性?

那是因為如果變數被volatile修飾,線上程進行寫操作時,會直接將新的值寫入到主存中,而不是執行緒的工作記憶體中;而在讀操作時,會直接從主存中讀取,而不是執行緒的工作記憶體。

基礎使用

首先這個使用與HashMap沒有任何區別,只是實現改成了ConcurrentHashMap。

Map<String, String> map = new ConcurrentHashMap<>();
map.put("微信搜尋", "SH的全棧筆記");
map.get("微信搜尋"); // SH的全棧筆記

取值

首先我們來看一下get方法的使用,原始碼如下。

public V get(Object key) {
  Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
  int h = spread(key.hashCode());
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (e = tabAt(tab, (n - 1) & h)) != null) {
    if ((eh = e.hash) == h) {
      if ((ek = e.key) == key || (ek != null && key.equals(ek)))
        return e.val;
    }
    else if (eh < 0)
      return (p = e.find(h, key)) != null ? p.val : null;
    while ((e = e.next) != null) {
      if (e.hash == h &&
          ((ek = e.key) == key || (ek != null && key.equals(ek))))
        return e.val;
    }
  }
  return null;
}

大概解釋一下這個過程發生了什麼,首先根據key計算出雜湊值,如果找到了就直接返回值。如果是紅黑樹的話,就在紅黑樹中查詢值,否則就按照連結串列的查詢方式查詢。

這與HashMap也差不多的,元素會首先以連結串列的方式進行儲存,如果該桶中的元素數量大於TREEIFY_THRESHOLD的值,就會觸發樹化。將當前的連結串列轉換為紅黑樹。因為如果數量太多的話,連結串列的查詢效率就會變得非常低,時間複雜度為O(n),而紅黑樹的查詢時間複雜度則為O(logn),這個閾值在Java 1.8中的預設值為8,定義如下。

static final int TREEIFY_THRESHOLD = 8;

賦值

put的原始碼就不放出來了,放在這大家估計也不會一行一行的去看。所以我就簡單的解釋一下put的過程發生了什麼事,並貼上關鍵程式碼就好了。

整個過程,除開併發的一些細節,大致的流程和1.8中的HashMap是差不多的。

  • 首先會根據傳入的key計算出hashcode,如果是第一次被賦值,那自然需要進行初始化table
  • 如果這個key沒有存在過,直接用CAS在當前槽位的頭節點建立一個Node,會用自旋來保證成功
  • 如果當前的Node的hashcode是否等於-1,如果是則證明有其它的執行緒正在執行擴容操作,當前執行緒就加入到擴容的操作中去
  • 且如果該槽位(也就是桶)上的資料結構如果是連結串列,則按照連結串列的插入方式,直接接在當前的連結串列的後面。如果數量大於了樹化的閾值就會轉為紅黑樹。
  • 如果這個key存在,就會直接覆蓋。
  • 判斷是否需要擴容

看到這你可能會有一堆的疑問。

例如在多執行緒的情況下,幾個執行緒同時來執行put操作時,怎麼保證只執行一次初始化,或者怎麼保證只執行一次擴容呢?萬一我已經寫入了資料,另一個執行緒又初始化了一遍,豈不是造成了資料不一致的問題。同樣是多執行緒的情況下, 怎麼保證put值的時候不會被其他執行緒覆蓋。CAS又是什麼?

接下來我們就來看一下在多執行緒的情況下,ConcurrentHashMap是如何保證執行緒安全的。

初始化的執行緒安全

首先我們來看初始化的原始碼。

private final Node<K,V>[] initTable() {
  Node<K,V>[] tab; int sc;
  while ((tab = table) == null || tab.length == 0) {
    if ((sc = sizeCtl) < 0)
      Thread.yield(); // lost initialization race; just spin
    else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
      try {
        if ((tab = table) == null || tab.length == 0) {
          int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
          @SuppressWarnings("unchecked")
          Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
          table = tab = nt;
          sc = n - (n >>> 2);
        }
      } finally {
        sizeCtl = sc;
      }
      break;
    }
  }
  return tab;
}

可以看到有一個關鍵的變數,sizeCtl,其定義如下。

private transient volatile int sizeCtl;

sizeCtl使用了關鍵字volatile修飾,說明這是一個多執行緒的共享變數,可以看到如果是首次初始化,第一個判斷條件if ((sc = sizeCtl) < 0)是不會滿足的,正常初始化的話sizeCtl的值為0,初始化設定了size的話sizeCtl的值會等於傳入的size,而這兩個值始終是大於0的。

CAS

然後就會進入下面的U.compareAndSwapInt(this, SIZECTL, sc, -1)方法,這就是上面提到的CAS,Compare and Swap(Set),比較並交換,Unsafe是位於sun.misc下的一個類,在Java底層用的比較多,它讓Java擁有了類似C語言一樣直接操作記憶體空間的能力。

例如可以操作記憶體、CAS、記憶體屏障、執行緒排程等等,但是如果Unsafe類不能被正確使用,就會使程式變的不安全,所以不建議程式直接使用它。

compareAndSwapInt的四個引數分別是,例項、偏移地址、預期值、新值。偏移地址可以快速幫我們在例項中定位到我們要修改的欄位,此例中便是sizeCtl。如果記憶體當中的sizeCtl是傳入的預期值,則將其更新為新的值。這個Unsafe類的方法可以保證這個操作的原子性。當你在使用parallelStream進行併發的foreach遍歷時,如果涉及到修改一個整型的共享變數時,你肯定不能直接用i++,因為在多執行緒下,i++每次操作不能保證原子性。所以你可能會用到如下的方式。

AtomicInteger num = new AtomicInteger();
arr.parallelStream().forEach(item -> num.getAndIncrement());

你可能會好奇,為什麼使用了AtomicInteger就可以保證原子性,跟Unsafe類和CAS又有什麼關係,讓我們接著往下,看getAndIncrement方法的底層實現。

public final int getAndIncrement() {
  return unsafe.getAndAddInt(this, valueOffset, 1);
}

可以看到,底層呼叫的是Unsafe類的方法,這不就聯絡上了嗎,而getAndIncrement的實現又長這樣。

public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  do {
    var5 = this.getIntVolatile(var1, var2);
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
  return var5;
}

沒錯,這裡底層呼叫了compareAndSwapInt方法。可以看到這裡加了while,如果該方法返回false就一直迴圈,直到成功為止。這個過程有個??的名字,叫自旋。特別高階啊,說人話就是無限迴圈。

什麼情況會返回false呢?那就是var5變數儲存的值,和現在記憶體中實際var5的值不同,說明這個變數已經被其他執行緒修改過了,此時通過自旋來重新獲取,直到成功為止,然後自旋結束。

結論

聊的稍微有點多,這小節的問題是如何保證不重複初始化。那就是執行首次擴容時,會將變數sizeCtl設定為-1,因為其被volatile修飾,所以其值的修改對其他執行緒可見。

其它執行緒再呼叫初始化時,就會發現sizeCtl的值為-1,說明已經有執行緒正在執行初始化的操作了,就會執行Thread.yield(),然後退出。

yield相信大家都不陌生,和sleep不同,sleep可以讓執行緒進入阻塞狀態,且可以指定阻塞的時間,同時釋放CPU資源。而yield不會讓執行緒進入阻塞狀態,而且也不能指定時間,它讓執行緒重新進入可執行狀態,讓出CPU排程,讓CPU資源被同優先順序或者高優先順序的執行緒使用,稍後再進行嘗試,這個時間依賴於當前CPU的時間片劃分。

如何保證值不被覆蓋

我們在上一節舉了在併發下i++的例子,說在併發下i++並不是一個具有原子性的操作,假設此時i=1,執行緒A和執行緒B同時取了i的值,同時+1,然後此時又同時的寫回。那麼此時i++的值會是2而不是3,在併發下1+1+1=2是可能出現的。

讓我們來看一下ConcurrentHashMap在目標key已經存在時的賦值操作,因為如果不存在會直接呼叫Unsafe的方法建立一個Node,所以後續的執行緒就會進入到下面的邏輯中來,由於太長,我省略了一些程式碼。

......
V oldVal = null;
synchronized (f) {
  if (tabAt(tab, i) == f) {
    if (fh >= 0) {
      binCount = 1;
      for (Node<K,V> e = f;; ++binCount) {
        ......
      }
    }
    else if (f instanceof TreeBin) {
      Node<K,V> p;
      binCount = 2;
      if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
        oldVal = p.val;
        if (!onlyIfAbsent)
          p.val = value;
      }
    }
  }
}
if (binCount != 0) {
  if (binCount >= TREEIFY_THRESHOLD)
    treeifyBin(tab, i);
  if (oldVal != null)
    return oldVal;
  break;
}

上述程式碼在賦值的邏輯外層包了一個synchronized,這個有什麼用呢?

synchronized關鍵字

這個地方也可以換一個方式來理解,那就是synchronized如何保證執行緒安全的。執行緒安全,我認為更多的是描述一種風險。在堆記憶體中的資料由於可以被任何執行緒訪問到,在沒有任何限制的情況下存在被意外修改的風險。

synchronized是通過對共享資源加鎖的方式,使同一時間只能有一個執行緒能夠訪問到臨界區(也就是共享資源),共享資源包括了方法、鎖程式碼塊和物件。

那是不是使用了synchronized就一定能保證執行緒安全呢?不是的,如果不能正確的使用,很可能就會引發死鎖,所以,保證執行緒安全的前提是正確的使用synchronized

自動擴容的執行緒安全

除了初始化、併發的寫入值,還有一個問題值得關注,那就是在多執行緒下,ConcurrentHashMap是如何保證自動擴容是執行緒安全的。

擴容的關鍵方案是transfer,但是由於程式碼太多了,貼在這個地方可能會影響大家的理解,感興趣的可以自己的看一下。

還是大概說一下自動擴容的過程,我們以一個執行緒來舉例子。在putVal的最後一步,會呼叫addCount方法,然後在方法裡判讀是否需要擴容,如果容量超過了實際容量 * 負載因子(也就是sizeCtl的值)就會呼叫transfer方法。

計算分割槽的範圍

因為ConcurrentHashMap是支援多執行緒同時擴容的,所以為了避免每個執行緒處理的數量不均勻,也為了提高效率,其對當前的所有桶按數量(也就是上面提到的槽位)進行分割槽,每個執行緒只處理自己分到的區域內的桶的資料即可。

當前執行緒計算當前stride的程式碼如下。

stride = (NCPU > 1) ? (n >>> 3) / NCPU : n);

如果計算出來的值小於設定的最小範圍,也就是private static final int MIN_TRANSFER_STRIDE = 16;,就把當前分割槽範圍設定為16。

初始化nextTable

nextTable也是一個共享變數,定義如下,用於存放在正在擴容之後的ConcurrentHashMap的資料,當且僅當正在擴容時才不為空。

private transient volatile Node<K,V>[] nextTable;

如果當前transfer方法傳入的nextTab(這是個區域性變數,比上面提到的nextTable少了幾個字母,不要搞混了)是null,說明是當前執行緒是第一個呼叫擴容操作的執行緒,就需要初始化一個size為原來容量2被的nextTable,核心程式碼如下。

Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 可以看到傳入的初始化容量是n << 1。

初始化成功之後就更新共享變數nextTable的值,並設定transferIndex的值為擴容前的length,這也是一個共享的變數,表示擴容使還未處理的桶的下標。

設定分割槽邊界

一個新的執行緒加入擴容操作,在完成上述步驟後,就會開始從現在正在擴容的Map中找到自己的分割槽。例如,如果是第一個執行緒,那麼其取到的分割槽就會如下。

start = nextIndex - 1;
end = nextIndex > stride ? nextIndex - stride : 0;
// 實際上就是當還有足夠的桶可以分的時候,執行緒分到的分割槽為 [n-stride, n - 1]

可以看到,分割槽是從尾到首進行的。而如果是首次進入的執行緒,nextIndex 的值會被初始化為共享變數transferIndex 的值。

Copy分割槽內的值

當前執行緒在自己劃分到的分割槽內開始遍歷,如果當前桶是null,那麼就生成一個 ForwardingNode,程式碼如下。

ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);

並把當前槽位賦值為fwd,你可以把ForwardingNode理解為一個標誌位,如果有執行緒遍歷到了這個桶, 發現已經是ForwardingNode了,就代表這個桶已經被處理過了,就會跳過這個桶。

如果這個桶沒有被處理過,就會開始給當前的桶加鎖,我們知道ConcurrentHashMap會在多執行緒的場景下使用,所以當有執行緒正在擴容的時候,可能還會有執行緒正在執行put操作,所以如果當前Map正在執行擴容操作,如果此時再寫入資料,很可能會造成的資料丟失,所以要對桶進行加鎖。

總結

對比在1.7中採用的Segment分段鎖的臃腫設計,1.8中直接使用了CASSynchronized來保證併發下的執行緒安全。總的來說,在1.8中,ConcurrentHashMap和HashMap的底層實現都差不多,都是陣列、連結串列和紅黑樹的方式。其主要區別就在於應用場景,非併發的情況可以使用HashMap,而如果要處理併發的情況,就需要使用ConcurrentHashMap。關於ConcurrentHashMap就先聊到這裡。

本文使用 mdnice 排版

相關文章