1. 程式人生 > >ConcurrentHashMap原理(2)之用分離鎖實現多個執行緒間的併發寫操作

ConcurrentHashMap原理(2)之用分離鎖實現多個執行緒間的併發寫操作

ConcurrentHashMap 類

ConcurrentHashMap 在預設併發級別會建立包含 16 個 Segment 物件的陣列。每個 Segment 的成員物件 table 包含若干個散列表的桶。每個桶是由 HashEntry 連結起來的一個連結串列。如果鍵能均勻雜湊,每個 Segment 大約守護整個散列表中桶總數的 1/16。

下面是 ConcurrentHashMap 的結構示意圖。

圖 3.ConcurrentHashMap 的結構示意圖:


用分離鎖實現多個執行緒間的併發寫操作

在 ConcurrentHashMap 中,執行緒對對映表做讀操作時,一般情況下不需要加鎖就可以完成,對容器做結構性修改的操作才需要加鎖。下面以 put 操作為例說明對 ConcurrentHashMap 做結構性修改的過程。

首先,根據 key 計算出對應的hash 值:

然後,根據 hash 值找到對應的Segment物件:

最後,在這個 Segment 中執行具體的 put 操作:

注意:這裡的加鎖操作是針對(鍵的 hash 值對應的)某個具體的 Segment,鎖定的是該 Segment 而不是整個 ConcurrentHashMap。因為插入鍵 / 值對操作只是在這個 Segment 包含的某個桶中完成,不需要鎖定整個ConcurrentHashMap。此時,其他寫執行緒對另外 15 個Segment 的加鎖並不會因為當前執行緒對這個 Segment 的加鎖而阻塞。同時,所有讀執行緒幾乎不會因本執行緒的加鎖而阻塞(除非讀執行緒剛好讀到這個 Segment 中某個 HashEntry 的 value 域的值為 null,此時需要加鎖後重新讀取該值)。

相比較於 HashTable 和由同步包裝器包裝的 HashMap每次只能有一個執行緒執行讀或寫操作,ConcurrentHashMap 在併發訪問效能上有了質的提高。在理想狀態下,ConcurrentHashMap可以支援 16 個執行緒執行併發寫操作(如果併發級別設定為 16),及任意數量執行緒的讀操作。

用 HashEntery 物件的不變性來降低讀操作對加鎖的需求

在程式碼清單“HashEntry 類的定義”中我們可以看到,HashEntry 中的 key,hash,next 都宣告為 final 型。這意味著,不能把節點新增到連結的中間和尾部,也不能在連結的中間和尾部刪除節點。這個特性可以保證:在訪問某個節點時,這個節點之後的連結不會被改變。這個特性可以大大降低處理連結串列時的複雜性。

同時,HashEntry 類的value 域被宣告為 Volatile 型,Java 的記憶體模型可以保證:某個寫執行緒對 value 域的寫入馬上可以被後續的某個讀執行緒“看”到。在ConcurrentHashMap 中,不允許用 unll 作為鍵和值,當讀執行緒讀到某個 HashEntry 的 value 域的值為 null 時,便知道產生了衝突——發生了重排序現象,需要加鎖後重新讀入這個 value值。這些特性互相配合,使得讀執行緒即使在不加鎖狀態下,也能正確訪問 ConcurrentHashMap。

下面我們分別來分析執行緒寫入的兩種情形:對散列表做非結構性修改的操作和對散列表做結構性修改的操作。

非結構性修改操作只是更改某個 HashEntry 的 value 域的值。由於對 Volatile 變數的寫入操作將與隨後對這個變數的讀操作進行同步。當一個寫執行緒修改了某個 HashEntry 的 value 域後,另一個讀執行緒讀這個值域,Java 記憶體模型能夠保證讀執行緒讀取的一定是更新後的值。所以,寫執行緒對連結串列的非結構性修改能夠被後續不加鎖的讀執行緒“看到”。

對 ConcurrentHashMap 做結構性修改,實質上是對某個桶指向的連結串列做結構性修改。如果能夠確保:在讀執行緒遍歷一個連結串列期間,寫執行緒對這個連結串列所做的結構性修改不影響讀執行緒繼續正常遍歷這個連結串列。那麼讀 / 寫執行緒之間就可以安全併發訪問這個 ConcurrentHashMap。

結構性修改操作包括 put,remove,clear。下面我們分別分析這三個操作。

clear 操作只是把 ConcurrentHashMap 中所有的桶“置空”,每個桶之前引用的連結串列依然存在,只是桶不再引用到這些連結串列(所有連結串列的結構並沒有被修改)。正在遍歷某個連結串列的讀執行緒依然可以正常執行對該連結串列的遍歷。

從上面的程式碼清單“在 Segment 中執行具體的 put 操作”中,我們可以看出:put 操作如果需要插入一個新節點到連結串列中時 , 會在連結串列頭部插入這個新節點。此時,連結串列中的原有節點的連結並沒有被修改。也就是說:插入新健 / 值對到連結串列中的操作不會影響讀執行緒正常遍歷這個連結串列。

下面來分析 remove 操作,先讓我們來看看 remove 操作的原始碼實現。

清單 7.remove 操作


和 get 操作一樣,首先根據雜湊碼找到具體的連結串列;然後遍歷這個連結串列找到要刪除的節點;最後把待刪除節點之後的所有節點原樣保留在新連結串列中,把待刪除節點之前的每個節點克隆到新連結串列中。下面通過圖例來說明 remove 操作。假設寫執行緒執行 remove 操作,要刪除連結串列的 C 節點,另一個讀執行緒同時正在遍歷這個連結串列。

圖 4. 執行刪除之前的原連結串列:


圖 5. 執行刪除之後的新連結串列


從上圖可以看出,刪除節點 C 之後的所有節點原樣保留到新連結串列中;刪除節點 C 之前的每個節點被克隆到新連結串列中,注意:它們在新連結串列中的連結順序被反轉了

在執行 remove 操作時,原始連結串列並沒有被修改,也就是說:讀執行緒不會受同時執行 remove 操作的併發寫執行緒的干擾。

綜合上面的分析我們可以看出,寫執行緒對某個連結串列的結構性修改不會影響其他的併發讀執行緒對這個連結串列的遍歷訪問。

用 Volatile 變數協調讀寫執行緒間的記憶體可見性

由於記憶體可見性問題,未正確同步的情況下,寫執行緒寫入的值可能並不為後續的讀執行緒可見。

下面以寫執行緒 M 和讀執行緒 N 來說明 ConcurrentHashMap 如何協調讀 / 寫執行緒間的記憶體可見性問題。

圖 6. 協調讀 – 寫執行緒間的記憶體可見性的示意圖:


假設執行緒 M 在寫入了volatile 型變數 count 後,執行緒 N 讀取了這個 volatile 型變數 count。

根據 happens-before 關係法則中的程式次序法則,A appens-before 於 B,Chappens-before D。

根據 Volatile 變數法則,B happens-before C。

根據傳遞性,連線上面三個 happens-before 關係得到:A appens-before 於 B;B appens-before C;C happens-before D。也就是說:寫執行緒 M 對連結串列做的結構性修改,在讀執行緒 N 讀取了同一個 volatile 變數後,對執行緒 N 也是可見的了。

雖然執行緒 N 是在未加鎖的情況下訪問連結串列。Java 的記憶體模型可以保證:只要之前對連結串列做結構性修改操作的寫執行緒 M 在退出寫方法前寫 volatile 型變數 count,讀執行緒 N 在讀取這個 volatile 型變數 count 後,就一定能“看到”這些修改。

ConcurrentHashMap 中,每個 Segment 都有一個變數 count。它用來統計 Segment 中的 HashEntry 的個數。這個變數被宣告為 volatile。

清單 8.Count 變數的宣告

1

transient volatile int count;

所有不加鎖讀方法,在進入讀方法時,首先都會去讀這個 count 變數。

在 ConcurrentHashMap 中,所有執行寫操作的方法(put, remove, clear),在對連結串列做結構性修改之後,在退出寫方法前都會去寫這個 count 變數。所有未加鎖的讀操作(get, contains,containsKey)在讀方法中,都會首先去讀取這個 count 變數。

根據 Java 記憶體模型,對 同一個 volatile 變數的寫 / 讀操作可以確保:寫執行緒寫入的值,能夠被之後未加鎖的讀執行緒“看到”。

這個特性和前面介紹的 HashEntry 物件的不變性相結合,使得在 ConcurrentHashMap 中,讀執行緒在讀取散列表時,基本不需要加鎖就能成功獲得需要的值。這兩個特性相配合,不僅減少了請求同一個鎖的頻率(讀操作一般不需要加鎖就能夠成功獲得值),也減少了持有同一個鎖的時間(只有讀到 value 域的值為 null 時 ,讀執行緒才需要加鎖後重讀)。

ConcurrentHashMap 實現高併發的總結

基於通常情形而優化

在實際的應用中,散列表一般的應用場景是:除了少數插入操作和刪除操作外,絕大多數都是讀取操作,而且讀操作在大多數時候都是成功的。正是基於這個前提,ConcurrentHashMap 針對讀操作做了大量的優化。通過 HashEntry 物件的不變性和用 volatile 型變數協調執行緒間的記憶體可見性,使得 大多數時候,讀操作不需要加鎖就可以正確獲得值。這個特性使得 ConcurrentHashMap 的併發效能在分離鎖的基礎上又有了近一步的提高。

總結

ConcurrentHashMap 是一個併發雜湊對映表的實現,它允許完全併發的讀取,並且支援給定數量的併發更新。相比於 HashTable 和用同步包裝器包裝的 HashMap(Collections.synchronizedMap(newHashMap())),ConcurrentHashMap 擁有更高的併發性。在 HashTable 和由同步包裝器包裝的 HashMap 中,使用一個全域性的鎖來同步不同執行緒間的併發訪問。同一時間點,只能有一個執行緒持有鎖,也就是說在同一時間點,只能有一個執行緒能訪問容器。這雖然保證多執行緒間的安全併發訪問,但同時也導致對容器的訪問變成序列化的了。

在使用鎖來協調多執行緒間併發訪問的模式下,減小對鎖的競爭可以有效提高併發性。有兩種方式可以減小對鎖的競爭:

1.  減小請求 同一個鎖的 頻率。

2.  減少持有鎖的 時間。

ConcurrentHashMap 的高併發性主要來自於三個方面:

1.  用分離鎖實現多個執行緒間的更深層次的共享訪問。

2.  用 HashEntery 物件的不變性來降低執行讀操作的執行緒在遍歷連結串列期間對加鎖的需求。

3.  通過對同一個 Volatile 變數的寫 / 讀訪問,協調不同執行緒間讀 / 寫操作的記憶體可見性。

使用分離鎖,減小了請求 同一個鎖的頻率。

通過 HashEntery 物件的不變性及對同一個 Volatile 變數的讀 / 寫來協調記憶體可見性,使得 讀操作大多數時候不需要加鎖就能成功獲取到需要的值。由於雜湊對映表在實際應用中大多數操作都是成功的讀操作,所以 2 和 3 既可以減少請求同一個鎖的頻率,也可以有效減少持有鎖的時間。

通過減小請求同一個鎖的頻率和儘量減少持有鎖的時間 ,使得 ConcurrentHashMap 的併發性相對於 HashTable 和用同步包裝器包裝的 HashMap有了質的提高。