1. 程式人生 > >深入剖析ConcurrentHashMap(1)

深入剖析ConcurrentHashMap(1)

ConcurrentHashMap是Java5中新增加的一個執行緒安全的Map集合,可以用來替代HashTable。對於ConcurrentHashMap是如何提高其效率的,可能大多人只是知道它使用了多個鎖代替HashTable中的單個鎖,也就是鎖分離技術(Lock Stripping)。實際上,ConcurrentHashMap對提高併發方面的優化,還有一些其它的技巧在裡面(比如你是否知道在get操作的時候,它是否也使用了鎖來保護?)。

我會試圖用通俗一點的方法講解一下 ConcurrentHashMap的實現方式,不過因為水平有限,在整理這篇文件的過程中,發現了更多自己未曾深入思考過的地方,使得我不得不從新調整了自己的講解方式。我假設受眾者大多是對Java儲存模型(JMM)認識並不很深的(我本人也是)。如果我們不斷的對ConcurrentHashMap中一些實現追問下去,最終還是要歸到JMM層面甚至更底層的。這篇文章的關注點主要在同步方面,並不去分析HashMap中的一些資料結構方面的實現。

ConcurrentHashMap實現了ConcurrentMap介面,先看看 ConcurrentMap 介面的文件說明:

提供其他原子 putIfAbsent、remove、replace 方法的 Map。

記憶體一致性效果:當存在其他併發 collection 時,將物件放入 ConcurrentMap 之前的執行緒中的操作 happen-before 隨後通過另一執行緒從 ConcurrentMap 中訪問或移除該元素的操作。

我們不關心ConcurrentMap中新增的介面,重點理解一下記憶體一致性效果中的“happens-before”是怎麼回事。因為要想從根本上講明白,這個是無法避開的。這又不得不從Java儲存模型來談起了。

1. 理解JAVA儲存模型(JMM)的Happens-Before規則。

在解釋該規則之前,我們先看一段多執行緒訪問資料的程式碼例子:

public class Test1 {
    private int a=1, b=2;

    public void foo(){  // 執行緒1 
        a=3;
        b=4;
    }

    public int getA(){ // 執行緒2
        return a;
    }    
    public int getB(){ // 執行緒2
        return b;
    }
}

上面的程式碼,當執行緒1執行foo方法的時候,執行緒2訪問getA和getB會得到什麼樣的結果?
答案:

A:a=1, b=2  // 都未改變
B:a=3, b=4  // 都改變了
C:a=3, b=2  //  a改變了,b未改變
D:a=1, b=4  //  b改變了,a未改變

上面的A,B,C都好理解,但是D可能會出乎一些人的預料。
一些不瞭解JMM的同學可能會問怎麼可能 b=4語句會先於 a=3 執行?

這是一個多執行緒之間記憶體可見性(Visibility)順序不一致的問題。有兩種可能會造成上面的D選項。

1) Java編譯器的重排序(Reording)操作有可能導致執行順序和程式碼順序不一致。
關於Reording:

Java語言規範規定了JVM要維護內部執行緒類似順序化語義(within-thread as-is-serial semantics):只要程式的最終結果等同於它在嚴格的順序化環境中執行的結果,那麼上述所有的行為都是允許的。

上面的話是《Java併發程式設計實踐》一書中引自Java語言規範的,感覺翻譯的不太好。簡單的說:假設程式碼有兩條語句,程式碼順序是語句1先於語句2執行;那麼只要語句2不依賴於語句1的結果,打亂它們的順序對最終的結果沒有影響的話,那麼真正交給CPU去執行時,他們的順序可以是沒有限制的。可以允許語句2先於語句1被CPU執行,和程式碼中的順序不一致。

重排序(Reordering)是JVM針對現代CPU的一種優化,Reordering後的指令會在效能上有很大提升。(不知道這種優化對於多核CPU是否更加明顯,也或許和單核多核沒有關係。)

因為我們例子中的兩條賦值語句,並沒有依賴關係,無論誰先誰後結果都是一樣的,所以就可能有Reordering的情況,這種情況下,對於其他執行緒來說就可能造成了可見性順序不一致的問題。

2) 從執行緒工作記憶體寫回主存時順序無法保證。
下圖描述了JVM中主存和執行緒工作記憶體之間的互動:

JLS中對執行緒和主存互操作定義了6個行為,分別為load,save,read,write,assign和use,這些操作行為具有原子性,且相互依賴,有明確的呼叫先後順序。這個細節也比較繁瑣,我們暫不深入追究。先簡單認為執行緒在修改一個變數時,先拷貝入執行緒工作記憶體中,線上程工作記憶體修改後再寫回主存(Main Memery)中。

假設例子中Reording後順序仍與程式碼中的順序一致,那麼接下來呢?
有意思的事情就發生線上程把Working Copy Memery中的變數寫回Main Memery的時刻。
執行緒1把變數寫回Main Memery的過程對執行緒2的可見性順序也是無法保證的。
上面的列子,a=3; b=4; 這兩個語句在 Working Copy Memery中執行後,寫回主存的過程對於執行緒2來說同樣可能出現先b=4;後a=3;這樣的相反順序。

正因為上面的那些問題,JMM中一個重要問題就是:如何讓多執行緒之間,物件的狀態對於各執行緒的“可視性”是順序一致的。
它的解決方式就是 Happens-before 規則:
JMM為所有程式內部動作定義了一個偏序關係,叫做happens-before。要想保證執行動作B的執行緒看到動作A的結果(無論A和B是否發生在同一個執行緒中),A和B之間就必須滿足happens-before關係。

我們現在來看一下“Happens-before”規則都有哪些(摘自《Java併發程式設計實踐》):

① 程式次序法則:執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B,其中,在程式中,所有的動作B都能出現在A之後。
② 監視器鎖法則:對一個監視器鎖的解鎖 happens-before於每一個後續對同一監視器鎖的加鎖。
③ volatile變數法則:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
④ 執行緒啟動法則:在一個執行緒裡,對Thread.start的呼叫會happens-before於每個啟動執行緒的動作。
⑤ 執行緒終結法則:執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已經終結、或者從Thread.join呼叫中成功返回,或Thread.isAlive返回false。
⑥ 中斷法則:一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷。
⑦ 終結法則:一個物件的建構函式的結束happens-before於這個物件finalizer的開始。
⑧ 傳遞性:如果A happens-before於B,且B happens-before於C,則A happens-before於C
(更多關於happens-before描述見附註2)

我們重點關注的是②,③,這兩條也是我們通常程式設計中常用的。
後續分析ConcurrenHashMap時也會看到使用到鎖(ReentrantLock),Volatile,final等手段來保證happens-before規則的。

使用鎖方式實現“Happens-before”是最簡單,容易理解的。

早期Java中的鎖只有最基本的synchronized,它是一種互斥的實現方式。在Java5之後,增加了一些其它鎖,比如ReentrantLock,它基本作用和synchronized相似,但提供了更多的操作方式,比如在獲取鎖時不必像synchronized那樣只是傻等,可以設定定時,輪詢,或者中斷,這些方法使得它在獲取多個鎖的情況可以避免死鎖操作。

而我們需要了解的是ReentrantLock的效能相對synchronized來說有很大的提高。(不過據說Java6後對synchronized進行了優化,兩者已經接近了。)在ConcurrentHashMap中,每個hash區間使用的鎖正是ReentrantLock。

Volatile可以看做一種輕量級的鎖,但又和鎖有些不同。
a) 它對於多執行緒,不是一種互斥(mutex)關係。
b) 用volatile修飾的變數,不能保證該變數狀態的改變對於其他執行緒來說是一種“原子化操作”。

在Java5之前,JMM對Volatile的定義是:保證讀寫volatile都直接發生在main memory中,執行緒的working memory不進行快取。
它只承諾了讀和寫過程的可見性,並沒有對Reording做限制,所以舊的Volatile並不太可靠。
在Java5之後,JMM對volatile的語義進行了增強。就是我們看到的③ volatile變數法則

那對於“原子化操作”怎麼理解呢?看下面例子:

private static volatile int nextSerialNum = 0;

public static int generateSerialNumber(){
    return nextSerialNum++;
}

上面程式碼中對nextSerialNum使用了volatile來修飾,根據前面“Happens-Before”法則的第三條Volatile變數法則,看似不同執行緒都會得到一個新的serialNumber

問題出在了 nextSerialNum++ 這條語句上,它不是一個原子化的,實際上是read-modify-write三項操作,這就有可能使得線上程1在write之前,執行緒2也訪問到了nextSerialNum,造成了執行緒1和執行緒2得到一樣的serialNumber。
所以,在使用Volatile時,需要注意
a) 需不需要互斥;
b)物件狀態的改變是不是原子化的。

最後也說一下final 關鍵字。
不變模式(immutable)是多執行緒安全裡最簡單的一種保障方式。因為你拿他沒有辦法,想改變它也沒有機會。
不變模式主要通過final關鍵字來限定的。
在JMM中final關鍵字還有特殊的語義。Final域使得確保初始化安全性(initialization safety)成為可能,初始化安全性讓不可變形物件不需要同步就能自由地被訪問和共享。

2)經過前面的瞭解,下面我們用Happens-Before規則理解一個經典問題:雙重檢測鎖(DCL)為什麼在java中不適用?

public class LazySingleton {
    private int                  someField;
    private static LazySingleton instance;

    private LazySingleton(){
        this.someField = new Random().nextInt(200) + 1; // (1)
    }

    public static LazySingleton getInstance() {
        if (instance == null) {// (2)
            synchronized (LazySingleton.class) { // (3)
              if (instance == null) { // (4)
                instance = new LazySingleton(); // (5)
              }
            }
        }
        return instance; // (6)
    }

    public int getSomeField() {
        return this.someField;  // (7)
    }
}

這裡例子的詳細解釋可以看這裡:http://www.javaeye.com/topic/260515?page=1
他解釋的太詳細了,是基於數學證明來分析的,看似更嚴謹一些,他的證明是因為那幾條語句之間不存在Happens-before約束,所以它們不能保證可見性順序。理解起來有些抽象,對於經驗不多的程式設計師來說缺乏更有效的說服力。

我想簡單的用物件建立期間的實際場景來分析一下:(注意,這種場景是我個人的理解,所看的資料也是非官方的,不完全保證正確。如果發現不對請指出。見附註1)

假設執行緒1執行完(5)時,執行緒2正好執行到了(2);
看看 new LazySingleton(); 這個語句的執行過程: 它不是一個原子操作,實際是由多個步驟,我們從我們關注的角度簡化一下,簡單的認為它主要有2步操作好了:
a) 在記憶體中分配空間,並將引用指向該記憶體空間。
b) 執行物件的初始化的邏輯(和操作),完成物件的構建。

此時因為執行緒1和執行緒2沒有用同步,他們之間不存在“Happens-Before”規則的約束,所以線上程1建立LazySingleton物件的 a),b)這兩個步驟對於執行緒2來說會有可能出現a)可見,b)不可見
造成了執行緒2獲取到了一個未建立完整的lazySingleton物件引用,為後邊埋下隱患。

之所以這裡舉到 DCL這個例子,是因為我們後邊分析ConcurrentHashMap時,也會遇到相似的情況。
對於物件的建立,出於樂觀考慮,兩個執行緒之間沒有用“Happens-Before規則來約束”另一個執行緒可能會得到一個未建立完整的物件,這種情況必須要檢測,後續分析ConcurrentHashMap時再討論。

附註1:
我所定義的場景,是基於對以下資料瞭解的,比較低層,沒有細看。
原文:http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
其中分析一個物件建立過程的部分摘抄如下:

singletons[i].reference = new Singleton();

to the following (note that the Symantec JIT using a handle-based object allocation system).

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; allocate space for
                                             ; Singleton, return result in eax
02061074   mov         dword ptr [ebp],eax       ; EBP is &singletons[i].reference 
                                            ; store the unconstructed object here.
02061077   mov         ecx,dword ptr [eax]       ; dereference the handle to
                                             ; get the raw pointer
02061079   mov         dword ptr [ecx],100h      ; Next 4 lines are
0206107F   mov         dword ptr [ecx+4],200h    ; Singleton's inlined constructor
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

As you can see, the assignment to singletons[i].reference is performed before the constructor for Singleton is called. This is completely legal under the existing Java memory model, and also legal in C and C++ (since neither of them have a memory model).

另外,從JVM建立一個物件的過程來看,分為:“裝載”,“連線”,“初始化”三個步驟。
在連線步驟中包含“驗證”,“準備”,“解析”這幾個環節。
為一個物件分配記憶體的過程是在連線步驟的準備環節,它是先於“初始化”步驟的,而建構函式的執行是在“初始化”步驟中的。

附註2:
Java6 API文件中對於記憶體一致性(Memory Consistency Properties)的描述:

記憶體一致性屬性

Java Language Specification 第 17 章定義了記憶體操作(如共享變數的讀寫)的 happen-before 關係。只有寫入操作 happen-before 讀取操作時,才保證一個執行緒寫入的結果對另一個執行緒的讀取是可視的。synchronized 和 volatile 構造 happen-before 關係,Thread.start() 和 Thread.join() 方法形成 happen-before 關係。尤其是:

1) 執行緒中的每個操作 happen-before 稍後按程式順序傳入的該執行緒中的每個操作。
2) 一個解除鎖監視器的(synchronized 阻塞或方法退出)happen-before 相同監視器的每個後續鎖(synchronized 阻塞或方法進入)。並且因為 happen-before 關係是可傳遞的,所以解除鎖定之前的執行緒的所有操作 happen-before 鎖定該監視器的任何執行緒後續的所有操作。
3) 寫入 volatile 欄位 happen-before 每個後續讀取相同欄位。volatile 欄位的讀取和寫入與進入和退出監視器具有相似的記憶體一致性效果,但不 需要互斥鎖。
4) 線上程上呼叫 start happen-before 已啟動的執行緒中的任何執行緒。
5) 執行緒中的所有操作 happen-before 從該執行緒上的 join 成功返回的任何其他執行緒。
java.util.concurrent 中所有類的方法及其子包擴充套件了這些對更高級別同步的保證。尤其是:
6) 執行緒中將一個物件放入任何併發 collection 之前的操作 happen-before 從另一執行緒中的 collection 訪問或移除該元素的後續操作。
7) 執行緒中向 Executor 提交 Runnable 之前的操作 happen-before 其執行開始。同樣適用於向 ExecutorService 提交 Callables。
8) 非同步計算(由 Future 表示)所採取的操作 happen-before 通過另一執行緒中 Future.get() 獲取結果後續的操作。
9) “釋放”同步儲存方法(如 Lock.unlock、Semaphore.release 和 CountDownLatch.countDown)之前的操作 happen-before 另一執行緒中相同同步儲存物件成功“獲取”方法(如 Lock.lock、Semaphore.acquire、Condition.await 和 CountDownLatch.await)的後續操作。
10) 對於通過 Exchanger 成功交換物件的每個執行緒對,每個執行緒中 exchange() 之前的操作 happen-before 另一執行緒中對應 exchange() 後續的操作。
11) 呼叫 CyclicBarrier.await 之前的操作 happen-before 屏障操作所執行的操作,屏障操作所執行的操作 happen-before 從另一執行緒中對應 await 成功返回的後續操作。

後續補充:
附註一種所引用的文章(Double-Checked Locking is Broken)是一篇比較著名的文章,但也比較早;他所使用的JIT還是Symantec(賽門鐵克)JIT,這是一個很古老的JIT,早已經退出了Java舞臺,不過我瞭解了一下歷史,在SUN的HotSpot JIT出現之前,Symantec JIT曾是市場上編譯最快的JIT。

Symantec的JIT反彙編後證明的邏輯,並不一定證明其他其他JIT也是這樣的,我不清楚用什麼工具能將java執行過程用匯編語言表達出來。沒有去證明其他的編譯器。

所以我所描述的new一個物件的場景不一定是完全正確的(不同的編譯器未必都和Symantec的實現方式一致),但是始終存在reording 優化,即使編譯器沒有做,也有可能在cpu級去做,所以new一個物件的過程對多執行緒訪問始終存在不確定性。