簡介
ConcurrenHashMap 在擴容過程中主要使用 sizeCtl 和 transferIndex 這兩個屬性來協調多線程之間的併發操作,並且在擴容過程中大部分數據依舊可以做到訪問不阻塞,具體是如何實現的,請繼續 。
說明:該源碼來自於 jdk_1.8.0_162 版本 。
特別說明:不想看源碼可直接跳到後面直接看圖解 。
一、sizeCtl 屬性在各個階段的作用
(1)、新建而未初始化時
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
作用:sizeCtl 用於記錄初始容量大小,僅用於記錄集合在實際創建時應該使用的大小的作用 。
(2)、初始化過程中
U.compareAndSwapInt(this, SIZECTL, sc, -1)
作用:將 sizeCtl 值設置爲 -1 表示集合正在初始化中,其他線程發現該值爲 -1 時會讓出CPU資源以便初始化操作儘快完成 。
(3)、初始化完成後
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
sizeCtl = sc;
作用:sizeCtl 用於記錄當前集合的負載容量值,也就是觸發集合擴容的極限值 。
(4)、正在擴容時
//第一條擴容線程設置的某個特定基數
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
//後續線程加入擴容大軍時每次加 1
U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)
//線程擴容完畢退出擴容操作時每次減 1
U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)
作用:sizeCtl 用於記錄當前擴容的併發線程數情況,此時 sizeCtl 的值爲:((rs << RESIZE_STAMP_SHIFT) + 2) + (正在擴容的線程數) ,並且該狀態下 sizeCtl < 0 。
二、什麼時候觸發擴容?
//新增元素時,也就是在調用 putVal 方法後,爲了通用,增加了個 check 入參,用於指定是否可能會出現擴容的情況
//check >= 0 即爲可能出現擴容的情況,例如 putVal方法中的調用
private final void addCount(long x, int check){
... ...
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//檢查當前集合元素個數 s 是否達到擴容閾值 sizeCtl ,擴容時 sizeCtl 爲負數,依舊成立,同時還得滿足數組非空且數組長度不能大於允許的數組最大長度這兩個條件才能繼續
//這個 while 循環除了判斷是否達到閾值從而進行擴容操作之外還有一個作用就是當一條線程完成自己的遷移任務後,如果集合還在擴容,則會繼續循環,繼續加入擴容大軍,申請後面的遷移任務
while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
// sc < 0 說明集合正在擴容當中
if (sc < 0) {
//判斷擴容是否結束或者併發擴容線程數是否已達最大值,如果是的話直接結束while循環
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
break;
//擴容還未結束,並且允許擴容線程加入,此時加入擴容大軍中
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//如果集合還未處於擴容狀態中,則進入擴容方法,並首先初始化 nextTab 數組,也就是新數組
//(rs << RESIZE_STAMP_SHIFT) + 2 爲首個擴容線程所設置的特定值,後面擴容時會根據線程是否爲這個值來確定是否爲最後一個線程
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
//擴容狀態下其他線程對集合進行插入、修改、刪除、合併、compute等操作時遇到 ForwardingNode 節點會調用該幫助擴容方法 (ForwardingNode 後面介紹)
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
//此處的 while 循環是上面 addCount 方法的簡版,可以參考上面的註釋
while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
//putAll批量插入或者插入節點後發現鏈表長度達到8個或以上,但數組長度爲64以下時觸發的擴容會調用到這個方法
private final void tryPresize(int size) {
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1);
int sc;
//如果不滿足條件,也就是 sizeCtl < 0 ,說明有其他線程正在擴容當中,這裏也就不需要自己去擴容了,結束該方法
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
//如果數組初始化則進行初始化,這個選項主要是爲批量插入操作方法 putAll 提供的
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
//初始化時將 sizeCtl 設置爲 -1 ,保證單線程初始化
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
//初始化完成後 sizeCtl 用於記錄當前集合的負載容量值,也就是觸發集合擴容的閾值
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
//插入節點後發現鏈表長度達到8個或以上,但數組長度爲64以下時觸發的擴容會進入到下面這個 else if 分支
else if (tab == table) {
int rs = resizeStamp(n);
//下面的內容基本跟上面 addCount 方法的 while 循環內部一致,可以參考上面的註釋
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
說明:總的來說
(1) 在調用 addCount 方法增加集合元素計數後發現當前集合元素個數到達擴容閾值時就會觸發擴容 。
(2) 擴容狀態下其他線程對集合進行插入、修改、刪除、合併、compute 等操作時遇到 ForwardingNode 節點會觸發擴容 。
(3) putAll 批量插入或者插入節點後發現存在鏈表長度達到 8 個或以上,但數組長度爲 64 以下時會觸發擴容 。
注意:桶上鍊表長度達到 8 個或者以上,並且數組長度爲 64 以下時只會觸發擴容而不會將鏈表轉爲紅黑樹 。
三、擴容代碼詳解
//調用該擴容方法的地方有:
//java.util.concurrent.ConcurrentHashMap#addCount 向集合中插入新數據後更新容量計數時發現到達擴容閾值而觸發的擴容
//java.util.concurrent.ConcurrentHashMap#helpTransfer 擴容狀態下其他線程對集合進行插入、修改、刪除、合併、compute 等操作時遇到 ForwardingNode 節點時觸發的擴容
//java.util.concurrent.ConcurrentHashMap#tryPresize putAll批量插入或者插入後發現鏈表長度達到8個或以上,但數組長度爲64以下時觸發的擴容
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//計算每條線程處理的桶個數,每條線程處理的桶數量一樣,如果CPU爲單核,則使用一條線程處理所有桶
//每條線程至少處理16個桶,如果計算出來的結果少於16,則一條線程處理16個桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // 初始化新數組(原數組長度的2倍)
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
//將 transferIndex 指向最右邊的桶,也就是數組索引下標最大的位置
transferIndex = n;
}
int nextn = nextTab.length;
//新建一個佔位對象,該佔位對象的 hash 值爲 -1 該佔位對象存在時表示集合正在擴容狀態,key、value、next 屬性均爲 null ,nextTable 屬性指向擴容後的數組
//該佔位對象主要有兩個用途:
// 1、佔位作用,用於標識數組該位置的桶已經遷移完畢,處於擴容中的狀態。
// 2、作爲一個轉發的作用,擴容期間如果遇到查詢操作,遇到轉發節點,會把該查詢操作轉發到新的數組上去,不會阻塞查詢操作。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//該標識用於控制是否繼續處理下一個桶,爲 true 則表示已經處理完當前桶,可以繼續遷移下一個桶的數據
boolean advance = true;
//該標識用於控制擴容何時結束,該標識還有一個用途是最後一個擴容線程會負責重新檢查一遍數組查看是否有遺漏的桶
boolean finishing = false; // to ensure sweep before committing nextTab
//這個循環用於處理一個 stride 長度的任務,i 後面會被賦值爲該 stride 內最大的下標,而 bound 後面會被賦值爲該 stride 內最小的下標
//通過循環不斷減小 i 的值,從右往左依次遷移桶上面的數據,直到 i 小於 bound 時結束該次長度爲 stride 的遷移任務
//結束這次的任務後會通過外層 addCount、helpTransfer、tryPresize 方法的 while 循環達到繼續領取其他任務的效果
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//每處理完一個hash桶就將 bound 進行減 1 操作
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
//transferIndex <= 0 說明數組的hash桶已被線程分配完畢,沒有了待分配的hash桶,將 i 設置爲 -1 ,後面的代碼根據這個數值退出當前線的擴容操作
i = -1;
advance = false;
}
//只有首次進入for循環纔會進入這個判斷裏面去,設置 bound 和 i 的值,也就是領取到的遷移任務的數組區間
else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//擴容結束後做後續工作,將 nextTable 設置爲 null,表示擴容已結束,將 table 指向新數組,sizeCtl 設置爲擴容閾值
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//每當一條線程擴容結束就會更新一次 sizeCtl 的值,進行減 1 操作
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT 成立,說明該線程不是擴容大軍裏面的最後一條線程,直接return回到上層while循環
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//(sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT 說明這條線程是最後一條擴容線程
//之所以能用這個來判斷是否是最後一條線程,因爲第一條擴容線程進行了如下操作:
// U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
//除了修改結束標識之外,還得設置 i = n; 以便重新檢查一遍數組,防止有遺漏未成功遷移的桶
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
//遇到數組上空的位置直接放置一個佔位對象,以便查詢操作的轉發和標識當前處於擴容狀態
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
//數組上遇到hash值爲MOVED,也就是 -1 的位置,說明該位置已經被其他線程遷移過了,將 advance 設置爲 true ,以便繼續往下一個桶檢查並進行遷移操作
advance = true; // already processed
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//該節點爲鏈表結構
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
//遍歷整條鏈表,找出 lastRun 節點
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//根據 lastRun 節點的高位標識(0 或 1),首先將 lastRun設置爲 ln 或者 hn 鏈的末尾部分節點,後續的節點使用頭插法拼接
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//使用高位和低位兩條鏈表進行遷移,使用頭插法拼接鏈表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//setTabAt方法調用的是 Unsafe 類的 putObjectVolatile 方法
//使用 volatile 方式的 putObjectVolatile 方法,能夠將數據直接更新回主內存,並使得其他線程工作內存的對應變量失效,達到各線程數據及時同步的效果
//使用 volatile 的方式將 ln 鏈設置到新數組下標爲 i 的位置上
setTabAt(nextTab, i, ln);
//使用 volatile 的方式將 hn 鏈設置到新數組下標爲 i + n(n爲原數組長度) 的位置上
setTabAt(nextTab, i + n, hn);
//遷移完成後使用 volatile 的方式將佔位對象設置到該 hash 桶上,該佔位對象的用途是標識該hash桶已被處理過,以及查詢請求的轉發作用
setTabAt(tab, i, fwd);
//advance 設置爲 true 表示當前 hash 桶已處理完,可以繼續處理下一個 hash 桶
advance = true;
}
//該節點爲紅黑樹結構
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
//lo 爲低位鏈表頭結點,loTail 爲低位鏈表尾結點,hi 和 hiTail 爲高位鏈表頭尾結點
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
//同樣也是使用高位和低位兩條鏈表進行遷移
//使用for循環以鏈表方式遍歷整棵紅黑樹,使用尾插法拼接 ln 和 hn 鏈表
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
//這裏面形成的是以 TreeNode 爲節點的鏈表
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//形成中間鏈表後會先判斷是否需要轉換爲紅黑樹:
//1、如果符合條件則直接將 TreeNode 鏈表轉爲紅黑樹,再設置到新數組中去
//2、如果不符合條件則將 TreeNode 轉換爲普通的 Node 節點,再將該普通鏈表設置到新數組中去
//(hc != 0) ? new TreeBin<K,V>(lo) : t 這行代碼的用意在於,如果原來的紅黑樹沒有被拆分成兩份,那麼遷移後它依舊是紅黑樹,可以直接使用原來的 TreeBin 對象
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
//setTabAt方法調用的是 Unsafe 類的 putObjectVolatile 方法
//使用 volatile 方式的 putObjectVolatile 方法,能夠將數據直接更新回主內存,並使得其他線程工作內存的對應變量失效,達到各線程數據及時同步的效果
//使用 volatile 的方式將 ln 鏈設置到新數組下標爲 i 的位置上
setTabAt(nextTab, i, ln);
//使用 volatile 的方式將 hn 鏈設置到新數組下標爲 i + n(n爲原數組長度) 的位置上
setTabAt(nextTab, i + n, hn);
//遷移完成後使用 volatile 的方式將佔位對象設置到該 hash 桶上,該佔位對象的用途是標識該hash桶已被處理過,以及查詢請求的轉發作用
setTabAt(tab, i, fwd);
//advance 設置爲 true 表示當前 hash 桶已處理完,可以繼續處理下一個 hash 桶
advance = true;
}
}
}
}
}
}
四、擴容過程圖解
觸發擴容的操作
總結一下:
(1) 元素個數達到擴容閾值。
(2) 調用 putAll 方法,但目前容量不足以存放所有元素時。
(3) 某條鏈表長度達到8,但數組長度卻小於64時。
CPU核數與遷移任務hash桶數量分配的關係
單線程下線程的任務分配與遷移操作
多線程如何分配任務?
普通鏈表如何遷移?
什麼是 lastRun 節點?
紅黑樹如何遷移?
hash桶遷移中以及遷移後如何處理存取請求?
多線程遷移任務完成後的操作
擴展問題:
1、爲什麼HashMap的容量會小於數組長度?
答:HashMap是爲了通過hash值計算出index,從而最快速的訪問 。如果容量大於數組很多的話再加上散列算法不是非常優秀的情況下很容易出現鏈表過長的情況,雖然現在出現了紅黑樹,但是速度依舊不如直接定位到某個數組位置直接獲取元素的速度快,所以最理想的情況是數組的每個位置放入一個元素,這樣定位最快,從而訪問也最快,集合容量小於數組長度的原因在於儘量去分散元素的分佈,相當於是拉長了分佈的範圍,儘量減少集中到一起的概率,從而提高訪問的速度,同時,負載因子只要小於 1 ,就不存在容量等於數組長度的情況 。
2、擴容期間在未遷移到的hash桶插入數據會發生什麼?
答:只要插入的位置擴容線程還未遷移到,就可以插入,當遷移到該插入的位置時,就會阻塞等待插入操作完成再繼續遷移 。
3、正在遷移的hash桶遇到 get 操作會發生什麼?
答:在擴容過程期間形成的 hn 和 ln鏈 是使用的類似於複製引用的方式,也就是說 ln 和 hn 鏈是複製出來的,而非原來的鏈表遷移過去的,所以原來 hash 桶上的鏈表並沒有受到影響,因此從遷移開始到遷移結束這段時間都是可以正常訪問原數組 hash 桶上面的鏈表,遷移結束後放置上fwd,往後的訪問請求就直接轉發到擴容後的數組去了 。
4、如果 lastRun 節點正好在一條全部都爲高位或者全部都爲低位的鏈表上,會不會形成死循環?
答:在數組長度爲64之前會導致一直擴容,但是到了64或者以上後就會轉換爲紅黑樹,因此不會一直死循環 。
5、擴容後 ln 和 hn 鏈不用經過 hash 取模運算,分別被直接放置在新數組的 i 和 n + i 的位置上,那麼如何保證這種方式依舊可以用過 h & (n - 1) 正確算出 hash 桶的位置?
答:如果 fh & n-1 = i ,那麼擴容之後的 hash 計算方法應該是 fh & 2n-1 。 因爲 n 是 2 的冪次方數,所以 如果 n=16, n-1 就是 1111(二進制), 那麼 2n-1 就是 11111 (二進制) 。 其實 fh & 2n-1 和 fh & n-1 的值區別就在於多出來的那個 1 => fh & (10000) 這個就是兩個 hash 的區別所在 。而 10000 就是 n 。所以說 如果 fh 的第五 bit 不是 1 的話 fh & n = 0 => fh & 2n-1 == fh & n-1 = i 。 如果第5位是 1 的話 。fh & n = n => fh & 2n-1 = i+n 。
6、我們都知道,併發情況下,各線程中的數據可能不是最新的,那爲什麼 get 方法不需要加鎖?
答:get操作全程不需要加鎖是因爲Node的成員val是用volatile修飾的 。
7、ConcurrentHashMap 的數組上插入節點的操作是否爲原子操作,爲什麼要使用 CAS 的方式?
答:待解決 。
8、擴容完成後爲什麼要再檢查一遍?
答:爲了避免遺漏hash桶,至於爲什麼會遺漏hash桶,有待後續補充 。