深入剖析ConcurrentHashMap
原文是09年時寫的,在公司的郵件列表發過,同事一粟 和清英 建立的併發程式設計網 對這方面概念和實戰有更好的文章,貼出來僅供參考。pdf格式在:http://www.slideshare.net/hongjiang/concurrent-hashmap 可以獲取
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一個物件的過程對多執行緒訪問始終存在不確定性。
經過之前的鋪墊,現在可以進入正題了。
我們關注的操作有: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方法:
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)來引導更新操作之間的併發,該引數用作內部調整大小的一個提示。表是在內部進行分割槽的,試圖允許指示無爭用併發更新的數量。因為雜湊表中的位置基本上是隨意的,所以實際的併發將各不相同。理想情況下,應該選擇一個儘可能多地容納併發修改該表的執行緒的值。使用一個比所需要的值高很多的值可能會浪費空間和時間,而使用一個顯然低很多的值可能導致執行緒爭用。對數量級估計過高或估計過低通常都會帶來非常顯著的影響。當僅有一個執行緒將執行修改操作,而其他所有執行緒都只是執行讀取操作時,才認為某個值是合適的。此外,重新調整此類或其他任何種類雜湊表的大小都是一個相對較慢的操作,因此,在可能的時候,提供構造方法中期望表大小的估計值是一個好主意。
參考:
http://www.javaeye.com/topic/344876
本來我的分析已經結束,不過正好看到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:
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是滿足的。