概述
上篇文章介紹了 HashMap 在多執行緒併發情況下是不安全的,多執行緒併發推薦使用 ConcurrentHashMap ,那麼 ConcurrentHashMap 是什麼?它的設計思想是什麼,原始碼是怎麼實現的?
ConcurrentHashMap是什麼
Concurrent翻譯過來是併發的意思,字面理解它的作用是處理併發情況的 HashMap,在介紹它之前先回顧下之前的知識。通過前面兩篇學習,我們知道多執行緒併發下 HashMap 是不安全的(如死迴圈),更普遍的是多執行緒併發下,由於堆記憶體對於各個執行緒是共享的,而 HashMap 的 put 方法不是原子操作,假設Thread1先 put 值,然後 sleep 2秒(也可以是系統時間片切換失去執行權),在這2秒內值被Thread2改了,Thread1“醒來”再 get 的時候發現已經不是原來的值了,這就容易出問題。
那麼如何避免這種多執行緒“奧迪變奧拓”的情況呢?常規思路就是給 HashMap 的 put 方法加鎖(synchronized),保證同一個時刻只允許一個執行緒擁有對 hashmap 有寫的操作許可權即可。然而假如執行緒1中操作耗時,佔著茅坑半天不出來,其他需要操作該 hashmap 的執行緒就需要在門口排隊半天,嚴重影響使用者體驗(HashTable 就是這麼幹的)。舉個生活中的例子,很多銀行除了存取錢,還支援存取貴重物品,貴重物品都放在保險箱裡,把 HashMap 和 HashTable 比作銀行,結構:
把執行緒比作人,對應的情況如下:
- HashMap牌銀行:我們的服務宗旨是不用排隊,同一時間多人都有機會修改保險櫃裡的東西,你以為你存的是美元?取出來的其實是日元,破產就在一瞬間,刺不刺激。
- HashTable牌銀行:我們的服務宗旨是要排隊,同一時間只有一個人有機會修改保險櫃裡的東西,其餘的人只能看不能動手改,保你存的是美元取得還是美元。什麼?你說如果那人在裡面睡著了不出來怎麼辦?不要著急,來,坐下來打會麻將等他出來。
多執行緒下用 HashMap 不確定性太高,有破產的風險,不能選;用 HashTable 不會破產,但是使用者體驗不太好,那麼怎樣才能做到多人存取既不影響他人存值,又不用排隊呢?有人提議搞個「銀行者聯盟」,多開幾個像HashTable 這種「帶鎖」的銀行就好了,有多少人辦理業務,就開多少個銀行,一對一服務,這個區都是大老闆,開銀行的成本都是小錢,於是「銀行者聯盟」成立了。
接下來的情況是這樣的:比如蓋倫和亞索一起去銀行存他們的大寶劍,這個「銀行者聯盟」一頓操作,然後對蓋倫說,1號銀行現在沒人,你可以去那存,不用排隊,然後蓋倫就去1號銀行存他的大寶劍,1號銀行把蓋倫接進門,馬上拉閘,一頓操作,然後把蓋倫的大寶劍放在第x行第x個保險箱,等蓋倫辦妥離開後,再開閘;同樣「銀行者聯盟」對亞索說,2號銀行現在沒人,你可以去那存,不用排隊,然後亞索去2號銀行存他的大寶劍,2號銀行把亞索接進門,馬上拉閘,一頓操作把亞索的大寶劍放在第x行第x號保險箱,等亞索離開後再開閘,此時不管蓋倫和亞索在各自銀行裡面待多久都不會影響到彼此,不用擔心自己的大寶劍被人偷換了。這就是ConcurrentHashMap的設計思路,用一個圖來理解
從上圖可以看出,此時鎖的是對應的單個銀行,而不是整個「銀行者聯盟」。分析下這種設計的特點:
- 多個銀行組成的「銀行者聯盟」
- 當有人來辦理業務時,「銀行者聯盟」需要確定這個人去哪個銀行
- 當此人去到指定銀行辦理業務後,該銀行上鎖,其他人不能同時執行修改操作,直到此人離開後解鎖
由這幾點基本思想可以引發一些思考,比如:
1.成立「銀行者聯盟」時初識銀行數是多少?怎麼設計合理?
上面這張圖沒有給出是否需要排隊的結論,這是因為需要結合實際情況分析,比如初識化有16個銀行,只有兩個人來辦理業務,那自然不需要排隊;如果現在16個銀行都有人在辦理業務,這時候來了第17個人,那麼他還是需要排隊的。由於「銀行者聯盟」事先無法得知會有多少人來辦理業務,所以在它創立的時候需要制定一個「標準」,即初始銀行數量,人多的情況「銀行者聯盟」應該多開幾家銀行,避免別人排隊;人少的情況應該少開,避免浪費錢(什麼,你說不差錢?那也不行)
2.當有人來辦理業務的時候,「銀行者聯盟」怎麼確定此人去哪個銀行?
正常情況下,如果所有銀行都是未上鎖狀態,那麼有人來辦理業務去哪都不用排隊,當其中有些銀行已經上鎖,那麼後續「銀行者聯盟」給人推薦的時候就不能把客戶往上鎖的銀行引了,否則分分鐘給人錘成麻瓜。因此「銀行者聯盟」需要時刻保持清醒的頭腦,對自己的銀行空閒情況瞭如指掌,每次給使用者推薦都應該是最好的選擇。
3.「銀行者聯盟」怎麼保證同一時間不會有兩個人在同一個銀行擁有存許可權?
通過對指定銀行加鎖/解鎖的方式實現。
原始碼分析
Java7 原始碼分析
通過 Java7 的原始碼分析下程式碼實現,先看下一些重要的成員
//預設的陣列大小16(HashMap裡的那個陣列)
static final int DEFAULT_INITIAL_CAPACITY = 16;
//擴容因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//ConcurrentHashMap中的陣列
final Segment<K,V>[] segments
//預設併發標準16
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//Segment是ReentrantLock子類,因此擁有鎖的操作
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//HashMap的那一套,分別是陣列、鍵值對數量、閾值、負載因子
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int threshold;
final float loadFactor;
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
}
//換了馬甲還是認識你!!!HashEntry物件,存key、value、hash值以及下一個節點
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
//segment中HashEntry[]陣列最小長度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//用於定位在segments陣列中的位置,下面介紹
final int segmentMask;
final int segmentShift;
複製程式碼
上面這些一下出來有點接受不了沒關係,下面都會介紹到。
接下來從最簡單的初識化開始分析
ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap();
複製程式碼
預設建構函式會呼叫帶三個引數的建構函式
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
//步驟① start
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
//步驟① end
//步驟② start
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//步驟② end
// create segments and segments[0]
//步驟③ start
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
//步驟③ end
}
複製程式碼
上面定義了許多臨時變數,註釋寫的又少,第一次看名字根本不知道這鬼東西代表什麼意思,不過我們可以把已知的資料代進去,算出這些變數的值,再分析能不能找出一些貓膩。假設這是第一次預設建立:
- 步驟① concurrencyLevel = 16 ,可以計算出 sshift = 4,ssize = 16,segmentShift = 28,segmentMask = 15;
- 步驟② c = 16/16 = 1,cap = 2;
- 步驟③有句註釋,建立 Segment 陣列 segments 並初始化 segments [0] ,所以 s0 初始化後陣列長度為2,負載因子0.75,閾值為1;再看這裡的ss的初始化(重點,圈起來要考!!!), ssize 此時為16,所以預設陣列長度16,給人一種感覺正好和我們傳的 concurrencyLevel 一樣?看下下面的例子
例子1 | 例子2 |
---|---|
ssize = 1,concurrencyLevel = 10 | ssize = 1,concurrencyLevel = 8 |
ssize <<= 1 —> 2<10 滿足 | ssize <<= 1 —> 2<10 滿足 |
ssize <<= 1 —> 4<10 滿足 | ssize <<= 1 —> 4<10 滿足 |
ssize <<= 1 —> 8<10 滿足 | ssize <<= 1 —> 8<10 不滿足 ssize = 8 |
ssize <<= 1 —> 16<10 不滿足 ssize = 16 |
所以我們傳 concurrencyLevel 不一定就是最後陣列的長度,長度的計算公式:
長度 = 2的n次方(2的n次方 >= concurrencyLevel)
到這裡只是建立了一個長度為16的Segment 陣列,並初始化陣列0號位置,segmentShift和segmentMask還沒派上用場,畫圖存檔:
接著看 put 方法
public V put(K key, V value) {
Segment<K,V> s;
//步驟①注意valus不能為空!!!
if (value == null)
throw new NullPointerException();
//根據key計算hash值,key也不能為null,否則hash(key)報空指標
int hash = hash(key);
//步驟②派上用場了,根據hash值計算在segments陣列中的位置
int j = (hash >>> segmentShift) & segmentMask;
//步驟③檢視當前陣列中指定位置Segment是否為空
//若為空,先建立初始化Segment再put值,不為空,直接put值。
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
複製程式碼
步驟①可以看到和 HashMap 的區別,這裡的 key/value 為空會報空指標異常;步驟②先根據 key 值計算 hash 值,再和前面算出來的兩個變數計算出這個 key 應該放在哪個Segment中(具體怎麼計算的有興趣可以去研究下,先高位運算再取與),假設我們算出來該鍵值對應該放在5號,步驟③判斷5號為空,看下 ensureSegment() 方法
private Segment<K,V> ensureSegment(int k) {
//獲取segments
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//拷貝一份和segment 0一樣的segment
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
//大小和segment 0一致,為2
int cap = proto.table.length;
//負載因子和segment 0一致,為0.75
float lf = proto.loadFactor;
//閾值和segment 0一致,為1
int threshold = (int)(cap * lf);
//根據大小建立HashEntry陣列tab
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//再次檢查
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
根據已有屬性建立指定位置的Segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
複製程式碼
該方法重點在於拷貝了segments[0],因此新建立的Segment與segment[0]的配置相同,由於多個執行緒都會有可能執行該方法,因此這裡通過UNSAFE的一些原子性操作的方法做了多次的檢查,到目前為止畫圖存檔:
現在“舞臺”也有了,請開始你的表演,看下 Segment 的put方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//步驟① start
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
//步驟① end
V oldValue;
try {
//步驟② start
//獲取Segment中的HashEntry[]
HashEntry<K,V>[] tab = table;
//算出在HashEntry[]中的位置
int index = (tab.length - 1) & hash;
//找到HashEntry[]中的指定位置的第一個節點
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
//如果不為空,遍歷這條鏈
if (e != null) {
K k;
//情況① 之前已存過,則替換原值
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
//情況② 另一個執行緒的準備工作
if (node != null)
//連結串列頭插入方式
node.setNext(first);
else //情況③ 該位置為空,則新建一個節點(注意這裡採用連結串列頭插入方式)
node = new HashEntry<K,V>(hash, key, value, first);
//鍵值對數量+1
int c = count + 1;
//如果鍵值對數量超過閾值
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//擴容
rehash(node);
else //未超過閾值,直接放在指定位置
setEntryAt(tab, index, node);
++modCount;
count = c;
//插入成功返回null
oldValue = null;
break;
}
}
//步驟② end
} finally {
//步驟③
//解鎖
unlock();
}
//修改成功,返回原值
return oldValue;
}
複製程式碼
上面的 put 方法其實和 Java7 HashMap裡大致是一樣的,只是多了加鎖/解鎖兩步,也正因為這樣才保證了同一時刻只有一個執行緒擁有修改的許可權。按步驟分析下上面的流程:
- 步驟① 執行 tryLock 方法獲取鎖,拿到鎖返回null,沒拿到鎖執行 scanAndLockForPut 方法;
- 步驟② 和 HashMap 裡的那一套思路是一樣的,不理解可以看下之前的文章介紹(情況②下面介紹);
- 步驟③ 執行 unLock 方法解鎖
假設現在Thread1進來存值,前面沒人來過,它可以成功拿到鎖,根據計算,得出它要存的鍵值對應該放在HashEntry[] 的0號位置,0號位置為空,於是新建一個 HashEntry,並通過 setEntryAt() 方法,放在0號位置,然而還沒等 Thread1 釋放鎖,系統的時間片切到了 Thread2 ,先畫圖存檔
Thread2 也來存值,通過前面的計算,恰好 Thread2 也被定位到 segments[5],接下來 Thread2 嘗試獲取鎖,沒有成功(Thread1 還未釋放),執行 scanAndLockForPut() 方法:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//通過Segment和hash值尋找匹配的HashEntry
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//重試次數
int retries = -1; // negative while locating node
//迴圈嘗試獲取鎖
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//步驟①
if (retries < 0) {
//情況① 沒找到,之前表中不存在
if (e == null) {
if (node == null) // speculatively create node
//新建 HashEntry 備用,retries改成0
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//情況② 找到,剛好第一個節點就是,retries改成0
else if (key.equals(e.key))
retries = 0;
//情況③ 第一個節點不是,移到下一個,retries還是-1,繼續找
else
e = e.next;
}
//步驟②
//嘗試了MAX_SCAN_RETRIES次還沒拿到鎖,簡直B了dog!
else if (++retries > MAX_SCAN_RETRIES) {
//泉水掛機
lock();
break;
}
//步驟③
//在MAX_SCAN_RETRIES次過程中,key對應的entry發生了變化,則從頭開始
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
複製程式碼
通過上面的註釋分析可以看出,Thread2 雖然此刻沒有許可權修改,但是它也沒閒著,利用等鎖的這個時間,把自己要放的鍵值對在陣列中哪個位置計算出來了,這樣當 Thread2 一拿到鎖就可以立馬定位到具體位置操作,節省時間。上面的步驟③稍微解釋下,比如 Thread2 通過查詢得知自己要修改的值在0號位置,但在 Thread1 裡面又把該值改到了1號位置,如果它還去0號操作那肯定出問題了,所以需要重新確定。
假設 Thread2 put 值為(“亞索”,“98”),對應1號位置,那麼在 scanAndLockForPut 方法中對應情況①,畫圖存檔:
再回到 Segment put 方法中的情況②,當 Thread1 釋放鎖後,Thread2 持有鎖,並準備把亞索放在1號位置,然而此時 Segment[5] 裡的鍵值對數量2 > 閾值1,所以呼叫 rehash() 方法擴容,
private void rehash(HashEntry<K,V> node) {
/*
* Reclassify nodes in each list to new table. Because we
* are using power-of-two expansion, the elements from
* each bin must either stay at same index, or move with a
* power of two offset. We eliminate unnecessary node
* creation by catching cases where old nodes can be
* reused because their next fields won`t change.
* Statistically, at the default threshold, only about
* one-sixth of them need cloning when a table
* doubles. The nodes they replace will be garbage
* collectable as soon as they are no longer referenced by
* any reader thread that may be in the midst of
* concurrently traversing table. Entry accesses use plain
* array indexing because they are followed by volatile
* table write.
*/
//舊陣列引用
HashEntry<K,V>[] oldTable = table;
//舊陣列長度
int oldCapacity = oldTable.length;
//新陣列長度為舊陣列的2倍
int newCapacity = oldCapacity << 1;
//修改新的閾值
threshold = (int)(newCapacity * loadFactor);
//建立新表
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
//遍歷舊錶
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
//確定在新表中的位置
int idx = e.hash & sizeMask;
//情況① 連結串列只有一個節點,指定轉移到新表指定位置
if (next == null) // Single node on list
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//情況② 擴容前後位置發生改變
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//將改變的鍵值對放到新表的對應位置
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//情況③ 把連結串列中剩下的節點拷到新表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
//新增新的節點(連結串列頭插入方式)
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
複製程式碼
同樣是擴容轉移,這裡的程式碼比 HashMap 中的 transfer 多了一些操作,在上上篇學習 HashMap 擴容可知,擴容後鍵值對的新位置要麼和原位置一樣,要麼等於原位置+舊陣列的長度,所以畫個圖來理解下上面程式碼這麼寫的原因:
前提:當前 HashEntry[] 長度為8,閾值為 8*0.75 = 6,所以 put 第7個鍵值對需要擴容 ,蓋倫和亞索擴容前後位置不變,妖姬和卡特擴容後位置需要加上原陣列長度,所以執行上面程式碼流程:
上面的程式碼先找出擴容前後需要轉移的節點,先執行轉移,然後再把該條鏈上剩下的節點轉移,之所以這麼寫是起到複用的效果,註釋中也說了,在使用預設閾值的情況下,只有大約 1/6 的節點需要被 clone 。注意到目前為止,可以看到無論是擴容轉移還是新增節點,Java7都是採用的頭插入方式,流程圖如下:
相比之下,get 方法沒有加鎖/解鎖的操作,程式碼比較簡單就不分析了。
稍微說下Java8
Java8 對比Java7有很大的不同,比如取消了Segments陣列,允許併發擴容。
先看下ConcurrentHashMap的初始化
public ConcurrentHashMap() {
}
複製程式碼
和Java7不一樣,這裡是個空方法,那麼它具體的初始化操作呢?直接看下 put 方法
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key/value不能為空!!!
if (key == null || value == null) throw new NullPointerException();
//計算hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//註釋① 表為null則初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//CAS方法判斷指定位置是否為null,為空則通過建立新節點,通過CAS方法設定在指定位置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//當前節點正在擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//指定位置不為空
else {
V oldVal = null;
//註釋② 加鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
//節點是連結串列的情況
if (fh >= 0) {
binCount = 1;
//遍歷整體鏈
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果已存在,替換原值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//如果是新加節點,則以尾部插入實現新增
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//節點是紅黑樹的情況
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
//遍歷紅黑樹
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
//連結串列中節點個數超過8轉成紅黑樹
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//註釋③ 新增節點
addCount(1L, binCount);
return null;
}
複製程式碼
程式碼有點長,第一次看很有可能引起身體不適,主要是因為引入了紅黑樹的判斷和操作,以及執行緒安全的操作。同樣key/value 為空會報空指標異常,這也是和 HashMap 一個明顯的區別。
註釋①
呼叫 initTable 初始化陣列
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// sizeCtl小於0,當前執行緒讓出執行權
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//CAS 操作將 sizeCtl 值改為-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//預設建立大小為16的陣列
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
//初始化完再改回來
sizeCtl = sc;
}
break;
}
}
return tab;
}
複製程式碼
put方法並沒有加鎖,那麼它是如何保證建立新表的時候併發安全呢?答案就是這裡的 sizeCtl ,sizeCtl 預設值為0,當一個執行緒初始化陣列時,會將 sizeCtl 改成 -1,由於被 volatile 修飾,對於其他執行緒來說這個變化是可見的,上面程式碼看到後續執行緒判斷 sizeCtl 小於0 就會讓出執行權。
註釋②
Java8 摒棄了Segment,而是對陣列中單個位置加鎖。當指定位置節點不為 null 時,情況與 Java8 HashMap 操作類似,新節點的新增還是尾部插入方式。
註釋③
不管是連結串列的還是紅黑樹,確定之後總的節點數會加1,可能會引起擴容,Java8 ConcunrrentHashMap 支援併發擴容,之前擴容總是由一個執行緒將舊陣列中的鍵值對轉移到新的陣列中,支援併發的話,轉移所需要的時間就可以縮短了,當然相應的併發處理控制邏輯也就更復雜了,擴容轉移通過 transfer 方法完成,Java8中該方法很長,感興趣的可以看下原始碼。。。
用一個圖來表示 Java8 ConcurrentHashMap的樣子
總結
通過分析原始碼對比了 HashMap 與 ConcurrentHashMap的差別,以及Java7和Java8上 ConcurrentHashMap 設計的不同,當然還有很多坑沒有填,比如其中呼叫了很多UNSAFE的CAS方法,可以減少效能上的消耗,平時很少用,瞭解的比較少;以及紅黑樹的具體原理和實現,後續慢慢填。。。