一文澄清網上對 ConcurrentHashMap 的一個流傳甚廣的誤解!

大家好,我是坤哥

上週我在極客時間某個課程看到某個講師在討論 ConcurrentHashMap(以下簡稱 CHM)是強一致性還是弱一致性時,提到這麼一段話

這個解釋網上也是流傳甚廣,那麼到底對不對呢,在回答這個問題之前,我們得想清楚兩個問題

  1. 什麼是強一致性,什麼是弱一致性
  2. 上文提到 get 沒有加鎖,所以沒法即時獲取 put 的數據,也就意味着如果加鎖就可以立即獲取到 put 的值了?那麼除了加鎖之外,還有其他辦法可以立即獲取到 put 的值嗎

強一致性與弱一致性

強一致性

首先我們先來看第一個問題,什麼是強一致性

一致性(Consistency)是指多副本(Replications)問題中的數據一致性。可以分爲強一致性、弱一致性。

強一致性也被可以被稱做原子一致性(Atomic Consistency)或線性一致性(Linearizable Consistency),必須符合以下兩個要求

  • 任何一次讀都能立即讀到某個數據的最近一次寫的數據
  • 系統中的所有進程,看到的操作順序,都和全局時鐘下的順序一致

簡單地說就是假定對同一個數據集合,分別有兩個線程 A、B 進行操作,假定 A 首先進行了修改操作,那麼從時序上在 A 這個操作之後發生的所有 B 的操作都應該能立即(或者說實時)看到 A 修改操作的結果。

弱一致性

與強一致性相對的就是弱一致性,即數據更新之後,如果立即訪問的話可能訪問不到或者只能訪問部分的數據。如果 A 線程更新數據後 B 線程經過一段時間後都能訪問到此數據,則稱這種情況爲最終一致性,最終一致性也是弱一致性,只不過是弱一致性的一種特例而已

那麼在 Java 中產生弱一致性的原因有哪些呢,或者說有哪些方式可以保證強一致呢,這就得先了解兩個概念,可見性和有序性

一致性的根因:可見性與有序性

可見性

首先我們需要了解一下 Java 中的內存模型

JMM.drawio
JMM.drawio

上圖是 JVM 中的 Java 內存模型,可以看到,它主要由兩部分組成,一部分是線程獨有的程序計數器虛擬機棧本地方法棧,這部分的數據由於是線程獨有的,所以不存在一致性問題(我們說的一致性問題往往指多線程間的數據一致性),一部分是線程共享的方法區,我們重點看一下堆內存。

我們知道,線程執行是要佔用 CPU 的,我們知道 CPU 是從寄存器裏取數據的,寄存器裏沒有數據的話,就要從內存中取,而衆所周知這兩者的速度差異極大,可謂是一個天上一個地上,所以爲了緩解這種矛盾,CPU 內置了三級緩存,每次線程執行需要數據時,就會把堆內存的數據以 cacheline(一般是 64 Byte) 的形式先加載到 CPU 的三級緩存中來,這樣之後取數據就可以直接從緩存中取從而極大地提升了 CPU 的執行效率(如下圖示)

但是這樣的話由於線程加載執行完數據後數據往往會緩存在 CPU 的寄存器中而不會馬上刷新到內存中,從而導致其他線程執行如果需要堆內存中共享數據的話取到的就不會是最新數據了,從而導致數據的不一致

舉個例子,以執行以下代碼爲例

//線程1執行的代碼
int i = 0;
i = 10;

//線程2執行的代碼
j = i;

在線程 1 執行完後 i 的值爲 10,然後 2 開始執行,此時 j 的值很可能還是 0,因爲線程 1 執行時,會先把 i = 0 的值從內存中加載到 CPU 緩存中,然後給 i 賦值 10,此時的 10 是更新在 CPU 緩存中的,而未刷新到內存中,當線程 2 開始執行時,首先會將 i 的值從內存中(其值爲 0)加載到 CPU 中來,故其值依然爲 0,而不是 10,這就是典型的由於 CPU 緩存而導致的數據不一致現象。

那麼怎麼解決可見性導致的數據不一致呢,其實只要讓 CPU 修改共享變量時立即寫回到內存中,同時通過總線協議(比如 MESI)通過其他 CPU 所讀取的此數據所在 cacheline 無效以重新從內存中讀取此值即可

有序性

除了可見性造成的數據不一致外,指令重排序也會造成數據不一致

int x = 1;   ① 
boolean flag = true; ② 
int y = x + 1; ③ 

以上代碼執行步驟可能很多人認爲是按正常的 ①,②,③ 執行的,但實際上很可能編譯器會將其調換一下位置,實際的執行順序可能是 ①③②,或 ②①③,也就是說 ①③ 是緊鄰的,爲什麼會這樣呢,因爲執行 1 後,CPU 會把 x = 1 從內存加載到寄存器中,如果此時直接調用 ③ 執行,那麼 CPU 就可以直接讀取 x 在寄存器中的值 1 進行計算,反之,如果先執行了語句 ②,那麼有可能 x 在寄存器中的值被覆蓋掉從而導致執行 ③ 後又要重新從內存中加載 x 的值,有人可能會說這樣的指令重排序貌似也沒有多大問題呀,那麼考慮如下代碼

public class Reordering {

    private static boolean flag;
    private static int num;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag) {
                    Thread.yield();
                }

                System.out.println(num);
            }
        }, "t1");
        t1.start();
        num = 5;                ① 
        flag = true;        ② 
    }
}

以上代碼最終輸出的值正常情況下是 5,但如果上述 ① ,② 兩行指令發生重排序,那麼結果是有可能爲 0 的,從而導致我們觀察到的數據不一致的現象發生,所以顯然解決方案是避免指令重排序的發生,也就是保證指令按我們看到的代碼的順序有序執行,也就是我們常說的有序性,一般是通過在指令之間添加內存屏障來避免指令的重排序

那麼如何保證可見性與有序性呢

相信大家都非常熟悉了,使用 volatile 可以保證可見性與有序性,只要在聲明屬性變量時添加上 volatile 就可以讓此變量實現強一致性,也就是說上述的 Reordering 類的 flag 只要聲明爲 volatile,那麼打印結果就永遠是 5!

好了,現在問題來了,CHM 到底是不是強一致性呢,首先我們以 Java 8 爲例來看下它的設計結構(和之前的版本相差不大,主要加上了紅黑樹提升了查詢效率)

來看下這個 table 數組和節點的聲明方式(以下定義 8 和 之前的版本中都是一樣的):

public class ConcurrentHashMap<K,Vextends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable 
{
        transient volatile Node<K,V>[] table;
      ...
}

static class Node<K,Vimplements Map.Entry<K,V{
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
      ...
}

可以看到 CHM 的 table 數組,Node 中的 值 val,下一個節點 next 都聲明爲了 volatile,於是有學員就提出了一個疑問

講師的回答也提到 CHM 爲弱一致性的重要原因:即如果 table 中的某個槽位爲空,此時某個線程執行了 key,value 的賦值操作,那麼此槽位會新增一個 Node 節點,在 JDK 8 以前,CHM 是通過以下方式給槽位賦 Node 的

put(K key, int hash, V value, boolean onlyIfAbsent) {
  lock();
  ...
    tab[index] = new HashEntry<K,V>(...);
  ...
    unlock();
}

然後是通過以下方式來根據 key 來讀取 value 的

get(Object key, int hash) {
    if (count != 0) { // read-volatile
        HashEntry<K,V> e = getFirst(hash);
        while (e != null) {
            if (e.hash == hash && key.equals(e.key)) {
                V v = e.value;
                if (v != null)
                    return v;
                return readValueUnderLock(e); // recheck
            }
            e = e.next;
        }
    }
    return null;
}

可以看到 put 時是直接給數組中的元素賦值的,而由於 get 沒有加鎖,所以無法保證線程 A put 的新元素對執行 get 的線程可見。

put 是有加鎖的,所以其實如果 get 也加鎖的話,那麼毫無疑問 get 是可以立即拿到 put 的值的。爲什麼加鎖也可以呢,其實這是 JLS(Java Language Specification Java 語言規範) 規定的幾種情況,簡單地說就是支持 happens before 語義的可以保證數據的強一致性,在官網(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)中列出了幾種支持 Happens before 的情況,其中指出使用 volatile,synchronize,lock 是可以確保 happens before 語義的,也就是說使用這三者可以保證數據的強一致性,可能有人就問了,到底什麼是 happens before 呢,其實本質是一種能確保線程及時刷新數據到內存,另一線程能實時從內存讀取最新數據以保證數據在線程之間保持一致性的一種機制,我們以 lock 爲例來簡單解釋下

public class LockDemo {
  private int x = 0;

  private void test() {
    lock();
    x++;
    unlock();
  }
}

如果線程 1 執行 test,由於拿到了鎖,所以首先會把數據(此例中爲 x = 0)從內存中加載到 CPU 中執行,執行 x++ 後,x 在 CPU 中的值變爲 1,然後解鎖,解鎖時會把 x = 1 的值立即刷新到內存中,這樣下一個線程再執行 test 方法再次獲取相同的鎖時又從內存中獲取 x 的最新值(即 1),這就是我們通常說的對一個鎖的解鎖, happens-before 於隨後對這個鎖的加鎖,可以看到,通過這種方式可以保證數據的一致性

至此我們明白了:在 Java 8 以前,CHM 的 get,put 確實是弱一致羽性,可能有人會問爲什麼不對 get 加鎖呢,加上了鎖不就可以確保數據的一致性了嗎,可以是可以,但別忘了 CHM 是爲高併發設計而生的,加了鎖不就導致併發性大幅度下降了麼,那 CHM 存在的意義是啥?

所以 put,get 就無法做到強一致性了嗎?

我們在上文中已經知道,使用 volatile,synchronize,lock 是可以確保 happens before 語義的,同時經過分析我們知道使用 synchronize,lock 加鎖的設計是不滿足我們設計 CHM 的初衷的,那麼只剩下 volatile 了,遺憾的是由於 Java 數組在元素層面的元數據設計上的缺失,是無法表達元素是 final、volatile 等語義的,所以 volatile 可以修飾變量,卻無法修飾數組中的元素,還有其他辦法嗎?來看看 Java 8 是怎麼處理的(這裏只列出了寫和讀方法中的關鍵代碼)

private static final sun.misc.Unsafe U;

// 寫
final V putVal(K key, V value, boolean onlyIfAbsent) {
      ...
    for (Node<K,V>[] tab = table;;) {
      if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
        if (casTabAt(tab, i, null,
                     new Node<K,V>(hash, key, value, null)))
          break;
      }
    }
      ...
}

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v)
 
{
  return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}


// 讀
public V get(Object key) {

  if ((tab = table) != null && (n = tab.length) > 0 &&
      (e = tabAt(tab, (n - 1) & h)) != null) {
    ...
  }
  return null;
}

@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
  return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

可以看到在 Java 8 中,CHM 使用了 unsafe 類來實現讀寫操作

  • 對於寫首先使用 compareAndSwapObject(即我們熟悉的 CAS)來更新內存中數組中的元素
  • 對於讀則使用了 getObjectVolatile 來讀取內存中數組中的元素(在底層其實是用了 C++ 的 volatile 來實現 java 中的 volatile 效果,有興趣可以看看)

由於讀寫都是直接對內存操作的,所以通過這樣的方式可以保證 put,get 的強一致性,至此真相大白! Java 8 以後 put,get 是可以保證強一致性的!CHM 是通過 compareAndSwapObject 來取代對數組元素直接賦值的操作,通過 getObjectVolatile 來補上無法表達數組元素是 volatile 的坑來實現的

注意並不是說 CHM 所有的操作都是強一致性的,比如 Java 8 中計算容量的方法 size() 就是弱一致性(Java 7 中此方法反而是強一致性),所以我們說強/弱一致性一定要確定好前提(比如指定 Java 8 下 CHM 的 put,get 這種場景)

總結

其實 Java 8 對 CHM 進行了一番比較徹底的重構,讓它的性能大幅度得到了提升,比如棄用 segment 這種設計,改用對每個槽位做分段鎖,使用紅黑樹來降低查詢時的複雜度,擴容時多個線程可以一起參與擴容等等,可以說 Java 8 的 CHM 的設計非常精妙,集 CAS,synchroinize,泛型等 Java 基礎語法之大成,又有巧妙的算法設計,讀後確實讓人大開眼界,有機會我會再和大家分享一下其中的設計精髓,另外我們對某些知識點一定要多加思考,最好能自己去翻翻源碼驗證一下真僞,相信你會對網上的一些謬誤會更容易看穿。

最後歡迎大家關注我的公號,加我好友:「geekoftaste」,一起交流,共同進步!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章