前言
我們之前分析了Hash的原始碼,主要是 put 方法。同時,我們知道,HashMap 在併發的時候是不安全的,為什麼呢?因為當多個執行緒對 Map 進行擴容會導致連結串列成環。不單單是這個問題,當多個執行緒相同一個槽中插入資料,也是不安全的。而在這之後,我們學習了併發程式設計,而併發程式設計中有一個重要的東西,就是JDK 自帶的併發容器,提供了執行緒安全的特性且比同步容器效能好出很多。一個典型的代表就是 ConcurrentHashMap,對,又是 HashMap ,但是這個 Map 是執行緒安全的,那麼同樣的,我們今天就看看該類的 put 方法是如何實現執行緒安全的。
原始碼加註釋分析 putVal 方法
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
// 死迴圈執行
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 初始化
tab = initTable();
// 獲取對應下標節點,如果是kong,直接插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// CAS 進行插入
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果 hash 衝突了,且 hash 值為 -1,說明是 ForwardingNode 物件(這是一個佔位符物件,儲存了擴容後的容器)
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 如果 hash 衝突了,且 hash 值不為 -1
else {
V oldVal = null;
// 同步 f 節點,防止增加連結串列的時候導致連結串列成環
synchronized (f) {
// 如果對應的下標位置 的節點沒有改變
if (tabAt(tab, i) == f) {
// 並且 f 節點的hash 值 不是大於0
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;
}
}
}
// 如果 f 節點的 hasj 小於0 並且f 是 樹型別
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;
}
}
}
}
// 連結串列長度大於等於8時,將該節點改成紅黑樹樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 判斷是否需要擴容
addCount(1L, binCount);
return null;
}
複製程式碼
樓主在程式碼中寫了很多註釋,但是還是說一下步驟(該方法和HashMap 的高度相似,但是多了很多同步操作)。
- 校驗key value 值,都不能是null。這點和 HashMap 不同。
- 得到 key 的 hash 值。
- 死迴圈並更新 tab 變數的值。
- 如果容器沒有初始化,則初始化。呼叫 initTable 方法。該方法通過一個變數 + CAS 來控制併發。稍後我們分析原始碼。
- 根據 hash 值找到陣列下標,如果對應的位置為空,就建立一個 Node 物件用CAS方式新增到容器。並跳出迴圈。
- 如果 hash 衝突,也就是對應的位置不為 null,則判斷該槽是否被擴容了(-1 表示被擴容了),如果被擴容了,返回新的陣列。
- 如果 hash 衝突 且 hash 值不是 -1,表示沒有被擴容。則進行連結串列操作或者紅黑樹操作,注意,這裡的 f 頭節點被鎖住了,保證了同時只有一個執行緒修改連結串列。防止出現連結串列成環。
- 和 HashMap 一樣,如果連結串列樹超過8,則修改連結串列為紅黑樹。
- 將陣列加1(CAS方式),如果需要擴容,則呼叫 transfer 方法(非常複雜,以後再詳解)進行移動和重新雜湊,該方法中,如果是槽中只有單個節點,則使用CAS直接插入,如果不是,則使用 synchronized 進行同步,防止併發成環。
這裡說一說 initTable 方法:
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 小於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) {
// sc 在初始化的時候使用者可能會自定義,如果沒有自定義,則是預設的
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 建立陣列
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// sizeCtl 計算後作為擴容的閥值
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
複製程式碼
該方法為了在併發環境下的安全,加入了一個 sizeCtl 變數來進行判斷,只有當一個執行緒通過CAS修改該變數成功後(預設為0,改成 -1),該執行緒才能初始化陣列。保證了初始化陣列時的安全性。
總結
ConcurrentHashMap 是併發大師 Doug Lea 的傑作,可以說鬼斧神工,總的來說,使用了 CAS 加 synchronized 來保證了 put 操作併發時的危險(特別是連結串列),相比 同步容器 hashTable 來說,如果容器大小是16,併發的效能是他的16倍,注意,讀的時候是沒有鎖的,完全併發,而 HashTable 在 get 方法上直接加上了 synchronized 關鍵字,效能差距不言而喻。
當然,樓主這篇文章可能之寫到了 ConcurrentHashMap 的皮毛,關於如何擴容,樓主沒有詳細介紹,而樓主在閱讀原始碼的收穫也很多,發現了很多有趣的東西,比如 ThreadLocalRandom 類在 addCount 方法中的應用,大家可以看看該類,非常的實用。
注意:這篇文章僅僅是 ConcurrentHashMap 的開頭,關於 ConcurrentHashMap 裡面的精華太多,值得我們好好學習。
good luck !!!!!