深入剖析ConcurrentHashMap(2)

喝水會長肉發表於2021-11-24

經過之前的鋪墊,現在可以進入正題了。
我們關注的操作有:get,put,remove 這3個操作。

對於雜湊表,Java中採用連結串列的方式來解決hash衝突的。
一個HashMap的資料結構看起來類似下圖:

實現了同步的HashTable也是這樣的結構,它的同步使用鎖來保證的,並且所有同步操作使用的是同一個鎖物件。這樣若有n個執行緒同時在get時,這n個執行緒要序列的等待來獲取鎖。


ConcurrentHashMap中對這個資料結構,針對併發稍微做了一點調整。
它把區間按照併發級別(concurrentLevel),分成了若干個segment。預設情況下內部按併發級別為16來建立。對於每個segment的容量,預設情況也是16。當然併發級別(concurrentLevel)和每個段(segment)的初始容量都是可以通過建構函式設定的。

建立好預設的ConcurrentHashMap之後,它的結構大致如下圖:

看起來只是把以前HashTable的一個hash bucket建立了16份而已。有什麼特別的嗎?沒啥特別的。

繼續看每個segment是怎麼定義的:

static final class Segment<K,V> extends ReentrantLock implements Serializable


Segment繼承了ReentrantLock,表明每個segment都可以當做一個鎖。(ReentrantLock前文已經提到,不瞭解的話就把當做synchronized的替代者吧)這樣對每個segment中的資料需要同步操作的話都是使用每個segment容器物件自身的鎖來實現。只有對全域性需要改變時鎖定的是所有的segment。

上面的這種做法,就稱之為 “分離鎖(lock striping)”。有必要對 “分拆鎖”“分離鎖”的概念描述一下:

分拆鎖(lock spliting)就是若原先的程式中多處邏輯都採用同一個鎖,但各個邏輯之間又相互獨立,就可以拆(Spliting)為使用多個鎖,每個鎖守護不同的邏輯。
分拆鎖有時候可以被擴充套件,分成可大可小加鎖塊的集合,並且它們歸屬於相互獨立的物件,這樣的情況就是 分離鎖(lock striping)。(摘自《Java併發程式設計實踐》)

看上去,單是這樣就已經能大大提高多執行緒併發的效能了。還沒完,繼續看我們關注的get,put,remove這三個函式怎麼保證資料同步的。

先看get方法:

public V 
get
(Object key
) 
{

   int hash = hash (key ) ; // throws NullPointerException if key null
    return segmentFor (hash ) . get (key , hash ) ;
}


它沒有使用同步控制,交給segment去找,再看Segment中的get方法:


//java學習交流:737251827  進入可領取學習資源及對十年開發經驗大佬提問,免費解答!

V 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 ;
}


它也沒有使用鎖來同步,只是判斷獲取的entry的value是否為null,為null時才使用加鎖的方式再次去獲取。

這個實現很微妙,沒有鎖同步的話,靠什麼保證同步呢?我們一步步分析。

第一步,先判斷一下 count != 0;count變數表示segment中存在entry的個數。如果為0就不用找了。
假設這個時候恰好另一個執行緒put或者remove了這個segment中的一個entry,會不會導致兩個執行緒看到的count值不一致呢?
看一下count變數的定義:  transient volatile int count;
它使用了volatile來修改。我們前文說過,Java5之後,JMM實現了對volatile的保證:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
所以,每次判斷count變數的時候,即使恰好其他執行緒改變了segment也會體現出來。

第二步,獲取到要該key所在segment中的索引地址,如果該地址有相同的hash物件,順著連結串列一直比較下去找到該entry。當找到entry的時候,先做了一次比較:  if(v != null) 我們用紅色註釋的地方。
這是為何呢?

考慮一下,如果這個時候,另一個執行緒恰好新增/刪除了entry,或者改變了entry的value,會如何?

先看一下HashEntry類結構。

static final class 
HashEntry
<K
,V
> 
{

   final K key ;
   final int hash ;
   volatile V value ;
   final HashEntry <K ,V > next ;
   。。。
}


除了 value,其它成員都是final修飾的,也就是說value可以被改變,其它都不可以改變,包括指向下一個HashEntry的next也不能被改變。(那刪除一個entry時怎麼辦?後續會講到。)

1) 在get程式碼的①和②之間,另一個執行緒新增了一個entry
如果另一個執行緒新增的這個entry又恰好是我們要get的,這事兒就比較微妙了。

下圖大致描述了put 一個新的entry的過程。

因為每個HashEntry中的next也是final的,沒法對連結串列最後一個元素增加一個後續entry
所以新增一個entry的實現方式只能通過頭結點來插入了。

newEntry物件是通過  new HashEntry(K k , V v, HashEntry next) 來建立的。如果另一個執行緒剛好new 這個物件時,當前執行緒來get它。因為沒有同步,就可能會出現當前執行緒得到的newEntry物件是一個沒有完全構造好的物件引用。

回想一下我們之前討論的DCL的問題,這裡也一樣,沒有鎖同步的話,new 一個物件對於多執行緒看到這個物件的狀態是沒有保障的,這裡同樣有可能一個執行緒new這個物件的時候還沒有執行完建構函式就被另一個執行緒得到這個物件引用。
所以才需要判斷一下: if (v != null) 如果確實是一個不完整的物件,則使用鎖的方式再次get一次。

有沒有可能會put進一個value為null的entry? 不會的,已經做了檢查,這種情況會丟擲異常,所以 ②處的判斷完全是出於對多執行緒下訪問一個new出來的物件的狀態檢測。

2) 在get程式碼的①和②之間,另一個執行緒修改了一個entry的value
value是用volitale修飾的,可以保證讀取時獲取到的是修改後的值。

3) 在get程式碼的①之後,另一個執行緒刪除了一個entry

假設我們的連結串列元素是:e1-> e2 -> e3 -> e4 我們要刪除 e3這個entry
因為HashEntry中next的不可變,所以我們無法直接把e2的next指向e4,而是將要刪除的節點之前的節點複製一份,形成新的連結串列。它的實現大致如下圖所示:

如果我們get的也恰巧是e3,可能我們順著連結串列剛找到e1,這時另一個執行緒就執行了刪除e3的操作,而我們執行緒還會繼續沿著舊的連結串列找到e3返回。
這裡沒有辦法實時保證了。

我們第①處就判斷了count變數,它保障了在 ①處能看到其他執行緒修改後的。
①之後到②之間,如果再次發生了其他執行緒再刪除了entry節點,就沒法保證看到最新的了。

不過這也沒什麼關係,即使我們返回e3的時候,它被其他執行緒刪除了,暴漏出去的e3也不會對我們新的連結串列造成影響。

這其實是一種樂觀設計,設計者假設 ①之後到②之間 發生被其它執行緒增、刪、改的操作可能性很小,所以不採用同步設計,而是採用了事後(其它執行緒這期間也來操作,並且可能發生非安全事件)彌補的方式。
而因為其他執行緒的“改”和“刪”對我們的資料都不會造成影響,所以只有對“新增”操作進行了安全檢查,就是②處的非null檢查,如果確認不安全事件發生,則採用加鎖的方式再次get。

這樣做減少了使用互斥鎖對併發效能的影響。可能有人懷疑remove操作中複製連結串列的方式是否代價太大,這裡我沒有深入比較,不過既然Java5中這麼實現,我想new一個物件的代價應該已經沒有早期認為的那麼嚴重。

我們基本分析完了get操作。對於put和remove操作,是使用鎖同步來進行的,不過是用的ReentrantLock而不是synchronized,效能上要更高一些。它們的實現前文都已經提到過,就沒什麼可分析的了。

我們還需要知道一點,ConcurrentHashMap的迭代器不是Fast-Fail的方式,所以在迭代的過程中別其他執行緒新增/刪除了元素,不會丟擲異常,也不能體現出元素的改動。但也沒有關係,因為每個entry的成員除了value都是final修飾的,暴漏出去也不會對其他元素造成影響。

最後,再來看一下Java6文件中對ConcurrentHashMap的描述(我們分析過的地方或者需要注意的使用了紅色字型):

支援獲取的完全併發和更新的所期望可調整併發的雜湊表。此類遵守與 Hashtable 相同的功能規範,並且包括對應於 Hashtable 的每個方法的方法版本。不過, 儘管所有操作都是執行緒安全的,但獲取操作不 必鎖定,並且不 支援以某種防止所有訪問的方式鎖定整個表。此類可以通過程式完全與 Hashtable 進行互操作,這取決於其執行緒安全,而與其同步細節無關。

獲取操作(包括 get)通常不會受阻塞,因此,可能與更新操作交迭(包括 put 和 remove)。獲取會影響最近完成的 更新操作的結果。對於一些聚合操作,比如 putAll 和 clear,併發獲取可能隻影響某些條目的插入和移除。類似地,在建立迭代器/列舉時或自此之後,Iterators 和 Enumerations 返回在某一時間點上影響雜湊表狀態的元素。它們不會 丟擲 ConcurrentModificationException。不過,迭代器被設計成每次僅由一個執行緒使用。

這允許通過可選的 concurrencyLevel 構造方法引數(預設值為 16)來引導更新操作之間的併發,該引數用作內部調整大小的一個提示。表是在內部進行分割槽的,試圖允許指示無爭用併發更新的數量。因為雜湊表中的位置基本上是隨意的,所以實際的併發將各不相同。理想情況下,應該選擇一個儘可能多地容納併發修改該表的執行緒的值。使用一個比所需要的值高很多的值可能會浪費空間和時間,而使用一個顯然低很多的值可能導致執行緒爭用。 對數量級估計過高或估計過低通常都會帶來非常顯著的影響。當僅有一個執行緒將執行修改操作,而其他所有執行緒都只是執行讀取操作時,才認為某個值是合適的。此外,重新調整此類或其他任何種類雜湊表的大小都是一個相對較慢的操作,因此,在可能的時候,提供構造方法中期望表大小的估計值是一個好主意。

參考:

本來我的分析已經結束,不過正好看到Concurrency-interest郵件組中的一個問題,可以加深一下我們隊ConcurrentHashMap的理解,同時也反駁了我剛開始所說的“ConcurrentHashMap完全可以替代HashTable”,我必須糾正一下。先看例子:

ConcurrentHashMap
<String
, Boolean
> map 
= 
new 

.
.
.
;

Thread a = new Thread {
   void run ( ) {
       map . put ( "first" , true ) ;
       map . put ( "second" , true ) ;
    }
} ;

Thread b = new Thread {
   void run ( ) {
       map . clear ( ) ;
    }
} ;

a . start ( ) ;
b . start ( ) ;
a . join ( ) ;
b . join ( ) ;


I would expect that one of the following scenarios to be true (for the contents of the map) after this code runs:

Map("first" -> true, "second" -> true) Map("second" -> true) Map()

However, upon inspection of ConcurrentHashMap, it seems to me that the following scenario might also be true:

Map("first" -> true) ???

This seems surprising because “first” gets put before “second”, so if “second” is cleared, then surely “first” should be cleared too.

Likewise, consider the following code:


//java學習交流:737251827  進入可領取學習資源及對十年開發經驗大佬提問,免費解答!

ConcurrentHashMap <String , Boolean > map = new . . . ;
List <String > myKeys = new . . . ;

Thread a = new Thread {
   void run ( ) {
       map . put ( "first" , true ) ;
        // more stuff
       map . remove ( "first" ) ;
       map . put ( "second" , true ) ;
    }
} ;

Thread b = new Thread {
   void run ( ) {
       Set <String > keys = map . keySet ( ) ;
        for (String key : keys ) {
           myKeys . add (key ) ;
        }
    }
} ;

a . start ( ) ;
b . start ( ) ;
a . join ( ) ;
b . join ( ) ;


I would expect one of the following scenarios to be true for the contents of myKeys after this code has run:

List() List("first") List("second")

However, upon closer inspection, it seems that the following scenario might also be true:

List("first", "second") ???

This is surprising because “second” is only ever put into the map after “first” is removed. They should never be in the map simultaneously, but an iterator might perceive them to be so.

對於這兩個現象的解釋: ConcurrentHashMap中的 clear方法:

public void 
clear
(
) 
{

    for (int i = 0 ; i < segments .length ; ++i )
       segments [i ] . clear ( ) ;
}


如果執行緒b先執行了clear,清空了一部分segment的時候,執行緒a執行了put且正好把“first”放入了“清空過”的segment中,而把“second”放到了還沒有清空過的segment中,就會出現上面的情況。

第二段程式碼,如果執行緒b執行了迭代遍歷到first,而此時執行緒a還沒有remove掉first,那麼即使後續刪除了first,迭代器裡不會反應出來,也不丟擲異常,這種迭代器被稱為“弱一致性”(weakly consistent)迭代器。

Doug Lea 對這個問題的回覆中提到:

We leave the tradeoff of consistency-strength versus scalability
as a user decision, so offer both synchronized and concurrent versions
of most collections, as discussed in the j.u.c package docs
http://java.sun.com/javase/6/docs/api/java/util/concurrent/package-summary.html

大意是我們將“一致性強度”和“擴充套件性”之間的對比交給使用者來權衡,所以大多數集合都提供了synchronized和concurrent兩個版本。

通過他的說法,我必須糾正我一開始以為ConcurrentHashMap完全可以代替HashTable的說法,如果你的環境要求“強一致性”的話,就不能用ConcurrentHashMap了,它的get,clear方法和迭代器都是“弱一致性”的。不過真正需要“強一致性”的場景可能非常少,我們大多應用中ConcurrentHashMap是滿足的。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70010294/viewspace-2843843/,如需轉載,請註明出處,否則將追究法律責任。

相關文章