概述
本文將對JDK8中 ConcurrentHashMap 原始碼進行一定程度的解讀。解讀主要分為六個部分:主要屬性與相關內部類介紹、建構函式、put過程、擴容過程、size過程、get過程、與JDK7實現的簡單對比。希望對讀者學習ConcurrentHashMap有一定的幫助。
閱讀本文前,可能需要讀者對HashMap和紅黑樹等有基本的瞭解。
主要屬性和主要的內部類
主要屬性
常量
ConcurrentHashMap中常量一共分為以下幾個部分:
- 容量相關:MAXIMUM_CAPACITY、DEFAULT_CAPACITY、MAX_ARRAY_SIZE
- 相容JDK 7而保留的部分常量:DEFAULT_CONCURRENCY_LEVEL、LOAD_FACTOR
- 紅黑樹升級和退化相關的常量:TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD、MIN_TREEIFY_CAPACITY
- 擴容相關:MIN_TRANSFER_STRIDE、RESIZE_STAMP_BITS、MAX_RESIZERS、RESIZE_STAMP_SHIFT
- 節點狀態常量:MOVED、TREEBIN、RESERVED
/* ---------------- Constants -------------- */
/**
* HashMap的最大容量
*/
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 預設容量
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 陣列最大長度
*/
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 預設最大併發等級
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 負載因子
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 連結串列升級成紅黑樹的閾值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 紅黑樹退化成連結串列的閾值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 連結串列升級成樹需要滿足的最小容量,若不滿足,則會先擴容
*/
static final int MIN_TREEIFY_CAPACITY = 64;
//最小轉移步長
private static final int MIN_TRANSFER_STRIDE = 16;
//這個常量是用來計算HashMap不同容量有不同的resizeStamp用的
private static int RESIZE_STAMP_BITS = 16;
//最大參與擴容的執行緒數 相當大的一個數 基本上是不會觸及該上線的
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//要對resizeStamp進行位移運算的一個敞亮
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//特殊的節點雜湊值
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
//獲取CPU的數量
static final int NCPU = Runtime.getRuntime().availableProcessors();
其中,因為JDK8中ConcurrentHashMap的實現方式和JDK7的不同,因此DEFAULT_CONCURRENCY_LEVEL已經沒有實際作用了。並且在JDK8中,LOAD_FACTOR也已經固定成了0.75f。
另外,MOVED,TREEBIN,RESERVED是用來表示特殊節點的雜湊值。該類特殊節點均不含實際元素,且其雜湊值被設定為負數和普通節點區分。
剩下的涉及擴容的常量我們在相關的章節中再介紹。
成員變數
通常成員變數都是會負責記錄當前類的狀態的,ConcurrentHashMap也是如此。因此瞭解清除成員變數的作用,對我們後續分析ConcurrentHashMap的操作流程很有感幫助。
/* ---------------- Fields -------------- */
/**
* 底層陣列
*/
transient volatile Node<K,V>[] table;
//擴容時 使用的另一個陣列
private transient volatile Node<K,V>[] nextTable;
//統計size的一部分
private transient volatile long baseCount;
/**
* sizeCtl與table的resize和init有關
* sizeCtl = -1時,表示table正在init
* sizeCtl < 0 且不等於-1時,表示正在resize
* sizeCtl > 0 時,表示下次需要resize的閾值,即capacity * loadfactory
*/
private transient volatile int sizeCtl;
//記錄下一次要transfer對應的Index
private transient volatile int transferIndex;
//表示是否有執行緒正在修改CounterCells
private transient volatile int cellsBusy;
//用來統計size
private transient volatile CounterCell[] counterCells;
// views
private transient KeySetView<K,V> keySet;
private transient ValuesView<K,V> values;
private transient EntrySetView<K,V> entrySet;
同樣的,成員變數也可以按作用分成幾類:
- 用作底層資料結構的實現:Node<K,V>[] table
- 用作統計元素的大小:baseCount、CounterCell[] counterCells、cellsBusy
- 用作記錄ConcurrentHashMap的狀態:sizeCtl
- 用作擴容記錄:Node<K,V>[] nextTable、transferIndex
- 用作轉成其它型別的檢視:KeySetView<K,V> keySet、ValuesView<K,V> values、EntrySetView<K,V> entrySet
table陣列很好理解。因為HashMap的實現是基於陣列的,在衝突時通過鏈地址解決。因此所有的資料都以陣列為入口。
另一個陣列nextTable是在擴容時備用的。 如果瞭解Redis的資料結構的讀者,應該對這個不陌生,redis漸進式rehash就是通過兩個雜湊表加一個index實現的。而JDK8中在resize時,也採取了類似的方式(下文我們會介紹到:按步長逐步transfer)。
另外,比較重要的一個屬性就是sizeCtl。
如果看過Doug Lea老爺子在JUC下的其他類,經常會有一個特殊的變數表示當前物件的狀態。並且已CAS的方式去修改這個變數,實現自旋鎖的功能(例如:AQS中的state)。
這裡的sizeCtl就是一個富有特殊意義的變數。
當sizeCtl大於0時,表示擴容的閾值(沒錯,就是HashMap中threshold變數的作用),而且上文我們也瞭解到在JDK8中由於loadfactor已經被固定為0.75f。因此在正常狀態下(非擴容狀態),sizeCtl = oldCap >> 1 - (oldCap << 2)。 而sizeCtl == -1是一個特殊的狀態標誌,表示ConcurrentHashMap正在初始化底層陣列。
當sizeCtl為其他負數時,表示ConcurrentHashMap正在程式擴容,其中,高16位可以反應出擴容前陣列的大小,而後16位可以反應出此時參與擴容的執行緒數。
內部類
ConcurrentHashMap擁有大量的內部類,但其中大部分都是用來遍歷或是在Fork/Join框架中平行遍歷時使用的。這部分類內部類我們不在過多介紹。主要看CountCell和幾個Node的類。
CounterCell
首先,CounterCell是用來統計ConcurrentHashMap用的,其內部有個value,用來表示元素個數。size()函式就是通過累加countCells陣列中所有CounterCell的value值,再加上BaseCount得到的。相當於ConcurrentHashMap把size這個屬性拆散儲存在了個多個地方。
Node
同HashMap一樣,為了提高連結串列的遍歷速度,ConcurrentHashMap也引用了紅黑樹。而Node就表示連結串列中的節點,並且他還是其他節點的父類。
TreeNode
TreeNode表示紅黑樹中的節點,按照紅黑樹的標準,它還擁有父節點和左右子節點的屬性,此外還需要標識是否為紅節點。
TreeBin
TreeBin是一個特殊的節點,用來指向紅黑樹的根節點,並不儲存真實的元素,因此它的節點的雜湊值是一個固定的特殊值-2。
ForwardingNode
ForwardingNode和TreeBin一樣,並不儲存實際元素,而是指向nextTable,雜湊值也是一個特殊的固定值(-1)。它在擴容中會使用,表示這個桶上的元素已經遷移到新的陣列中去了。
ReservationNode
同樣是一個特殊值,在putIfAbsent時使用。因為put時需要對桶上的元素上物件鎖(ConcurrentHashMap並非是完全無鎖的,只是儘可能少的去使用鎖),這時就會新增一個臨時佔位用的節點ReservationNode。
建構函式
因為建構函式是公有的API,所以必須要和JDK7中保持一致。雖然其中的部分含義可能發生了一些變化。
我們看一下引數最全的建構函式。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
上述方法中的三個引數分別是初始容量initialCapacity,負載因子loadfactor和並行等級concurrenyLevel。
首先,loadfactor負載因子在JDK8的ConcurrentHashMap執行時都已經固定為0.75f,因此這裡的引數只能在建立時,幫助確定初始的陣列容量。
同樣的,由於不在使用JDK7的Segement實現方式,因此這裡的concurrencyLevel不在用來確定Segement的數量。對於JDK8中的ConcurrentHashMap而言,鎖的粒度是對陣列的每個桶(理論上可以對每個桶進行併發操作),因此concurrencyLevel的含義也就是用來確定底層資料的初始容量。
這也正是size = (long)(1.0 + (long)initialCapacity / loadFactor);
這行程式碼的意義(這裡的initialCapacity是取引數中initialCapacity和concurrenyLevel中的最大值)。
另外需要注意的一點是,size並不是最終我們陣列的容量,ConcurrentHashMap會通過tableSizeFor()
方法找出大於等於size的最小2的冪次方數作為容量。(這和HashMap是一樣的,需要保證容量為2的冪次,因為之後的雜湊操作都是基於這一前提)。
最後,在得出了初始容量後,ConcurrentHashMap僅是將容量通過sizeCtl來儲存,而並沒有直接初始化陣列。陣列的初始化會被延遲到第一次put資料時(這樣設計可能是出於節省記憶體的目的)。
put過程
有了前文的鋪墊,我們就可以開始瞭解ConcurrentHashMap的put過程了。
先在這裡做個宣告,本文不會對紅黑樹的部分展開詳細分析,之後用連結串列升級成紅黑樹,紅黑樹退化成連結串列,在紅黑樹中查詢直接概括某些過程。
put()的具體實現都是由putVal()這個函式實現的。因此這裡我們對putVal()函式展開分析。
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迴圈,一直嘗試,直到put成功
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//tab未初始化,先初始化tab
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//對應的bucket上還沒有元素
//採用CAS嘗試PUT元素,如果此時沒有其它執行緒操作,這裡將會PUT成功
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正在擴容
tab = helpTransfer(tab, f);
else {//bucket上已經存在元素
V oldVal = null;
//只針對頭節點同步,不影響其他bucket上的元素,提高效率
synchronized (f) {
//同步塊內在做一次檢查
if (tabAt(tab, i) == f) {//說明頭節點未發生改變,如果發生改變,則直接退出同步塊,並再次嘗試
if (fh >= 0) { //雜湊值大於0 說明是tab[i]上放的是連結串列 因為對於紅黑樹而言 tab[i]上放的是TreeBin一個虛擬的節點 其雜湊值固定為-2
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
//查詢連結串列,如果存在相同key,則更新,否則插入新節點
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;
}
}
}
}
//判斷連結串列是否需要轉成樹
//值得注意的一點是,這段程式碼並未在同步塊中,應該也是出於效率考慮
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
整體瞭解putVal()的流程
先整體的瞭解下putVal(),不對for()迴圈中程式碼具體分析。
第一步是校驗要put的Key/Value不能為null。因此ConcurrentHashMap和HashMap不同,不支援空的鍵值。
第二步是spread(key.hashCode())是對鍵的雜湊值做一個擾動,這裡通過h^(h>>>16)
的演算法實現的,這樣做的目的有兩個,一是避免了設計不好的hashCode函式造成碰撞的概率加大,二是確保了擾動後的雜湊值均為正數(因為負數雜湊值都是一些特殊的節點)。
第三步是for()迴圈,這裡通過CAS+自選保證執行緒安全,暫時先不具體分析。
第四步addCount()應該是表示成功往ConcurrentHashMap新增了元素後,讓更新元素的數量(當然,我們可以猜想對於替換節點的情況,應該是不會執行這一步的)。這個方法的具體分析我們放在擴容的步驟中。
分析for()迴圈中的程式碼
for()迴圈中的程式碼 同樣分成了四個部分:
第一步:如果底層陣列還沒有初始化,通過initTable()初始化陣列
initTable()方法如下:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//同樣 採用不斷重試的方式,而非直接使用鎖
while ((tab = table) == null || tab.length == 0) {
//sizeCtl < 0 表示table正在被初始化或是reszie
if ((sc = sizeCtl) < 0)
//當前執行緒先等待
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { //使用CAS操作更新sizeCtl,標記為正在初始化中
//由於採用了CAS操作,因此該塊的方法可以認為是執行緒安全的
try {
if ((tab = table) == null || tab.length == 0) {//初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//表示下次需要擴容的值 (1 - 1/4) = 0.75f
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
initTable()不算複雜,首先為了避免多個執行緒同時進行初始化,需要通過sizeCtl進行控制。
當執行緒發現sizeCtl<0
時,就知道此時已經有其他執行緒在初始化了,那麼它會主動讓出CPU時間,等待初始化完成。
如果sizeCtl並不是小於0,說明暫時沒有其他執行緒在初始化,這時候要先通過CAS更新sizeCtl的值為-1(相當於搶佔了自旋鎖),然後開始初始化底層陣列並設定為table,然後計算下次擴容的閾值存放在sizeCtl(具體值為n - (n >>>2),即容量的0.75*n)。
第二步:如果已經初始化但是對應桶上元素為null,那麼嘗試CAS更新
首先,這裡確定桶的演算法是通過之前spread()得到的雜湊值h和陣列容量n進行一次h & (n -1)
。
這個方式和HashMap是相同的,因為n是2的冪,換成二進位制,就是高位為1之後的低位全為0的數,那麼這個數減1就成了全為1的一個數。以這樣的方式代替取餘的運算不僅計算更快,也能更好的利用雜湊值雜湊。
如果,這一步CAS失敗,說明此時有其他執行緒也在操作該桶,那麼當前執行緒在下次for()迴圈時會進入下列的第三和第四步中。
第三步:說明已經初始化且桶上有元素,那麼判斷元素是否為ForwardNode
如果執行緒發現自己要操作的桶上的節點是ForwardNode(可以通過其特殊的雜湊值判斷),那麼就說明此時ConcurrentHashMap擴容,執行緒可能會加入幫助擴容。具體的我們放在擴容的部分介紹。
第四步:說明桶上元素是正常元素,那麼就要比對這個桶所有元素,進行更新或插入
這裡說明該桶上存放的是正常的元素(TreeBin雖然是一個特殊節點,但也是正常狀態下存在的節點),為了執行緒安全,這裡需要對桶上的元素進行上鎖synchronized(f)
。然後在遍歷桶上所有的元素,選擇更新或者插入。
第一,需要注意的是,上鎖後的第一件事就是進行double-check的判斷,看上鎖過程中頭節點是否發生了變化。這很重要,如果頭節點發生了變化,那麼對之前的頭節點f上鎖是無法保證執行緒安全的。
第二,對於桶上是連結串列的情況(f.hash > 0
),ConcurrentHashMap會遍歷連結串列,比較連結串列的各個節點,如果之前存在相同的key,那麼替換該節點的value值(儲存節點的舊值用於返回)。如果不存在相同的key,那麼建立新的節點插入連結串列(注意,ConcurrentHashMap用的是尾插發,即插入連結串列尾部)。
第三,針對是TreeBin的節點,說明桶上關聯的是紅黑樹,則通過紅黑樹的方式進行插入或更新。
擴容過程
擴容過程過程可能要比put過程要稍微複雜一些。首先我們從上文提到的addCount()函式開始分析。
addCount()更新元素的容器個數
當ConcurrentHashMap新增了元素之後,需要通過addCount()更新元素的個數。
並且如果發現元素的個數達到了擴容閾值(sizeCtl),那麼將進行resize()操作。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//更新size
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
//resize
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//不斷CAS重試
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {//需要resize
//為每個size生成一個獨特的stamp 這個stamp的第16為必為1 後15位針對每個n都是一個特定的值 表示n最高位的1前面有幾個零
int rs = resizeStamp(n);
//sc會在庫容時變成 rs << RESIZE_STAMP_SHIFT + 2;上面說了rs的第16位為1 因此在左移16位後 該位的1會到達符號位 因此在擴容是sc會成為一個負數
//而後16位用來記錄參與擴容的執行緒數
//此時sc < 0 說明正在擴
if (sc < 0) {
/**
* 分別對五個條件進行說明
* sc >>> RESIZE_STAMP_SHIFT != rs 取sc的高16位 如果!=rs 則說明HashMap底層資料的n已經發生了變化
* sc == rs + 1 此處可能有問題 我先按自己的理解 覺得應該是 sc == rs << RESIZE_STAMP_SHIFT + 1; 因為開始transfer時 sc = rs << RESIZE_STAMP_SHIFT + 2(一條執行緒在擴容,且之後有新執行緒參與擴容sc均會加1,而一條執行緒完成後sc - 1)說明是參與transfer的執行緒已經完成了transfer
* 同理sc == rs + MAX_RESIZERS 這個應該也改為 sc = rs << RESIZE_STAMP_SHIFT + MAX_RESIZERS 表示參與遷移的執行緒已經到達最大數量 本執行緒可以不用參與
* (nt = nextTable) == null 首先nextTable是在擴容中間狀態才使用的陣列(這一點和redis的漸進式擴容方式很像) 當nextTable 重新為null時 說明transfer 已經finish
* transferIndex <= 0 也是同理
* 遇上以上這些情況 說明此執行緒都不需要參與transfer的工作
* PS: 翻了下JDK16的程式碼 這部分已經改掉了 rs = resizeStamp(n) << RESIZE_STAMP_SHIFT 證明我們的猜想應該是正確的
*/
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//否則該執行緒需要一起transfer
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//說明沒有其他執行緒正在擴容 該執行緒會將sizeCtl設定為負數 表示正在擴容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
如上文所說,這個方法有兩個作用,一是更新元素個數,二是判斷是否需要resize()。
更新size()
我們可以單獨看addCount中更新size的部分
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
首先判斷countCells是否已經被初始化,如果沒有被初始化,那麼將嘗試在size的更新操作放在baseCount上。如果此時沒有衝突,那麼CAS修改baseCount就能成功,size的更新就落在了baseCount上。
如果此時已經有countCells了,那麼會根據執行緒的探針隨機落到countCells的某個下標上。對size的更新就是更新對應CountCells的value值。
如果還是不行,將會進入fullAddCount
方法中,自旋重試直到更新成功。這裡不對fullAddCount
展開介紹,具體操作也類似,size的變化要麼累加在對應的CountCell上,要麼累加在baseCount上。
這裡說一下我個人對ConcurrentHashMap採用這麼複雜的方式進行計數的理解。因為ConcurrenthHashMap是出於吞吐量最大的目的設計的,因此,如果單純的用一個size直接記錄元素的個數,那麼每次增刪操作都需要同步size,這會讓ConcurrentHashMap的吞吐量大大降低。
因為,將size分散成多個部分,每次修改只需要對其中的一部分進行修改,可以有效的減少競爭,從而增加吞吐量。
resize()
對於resize()過程,我其實在程式碼的註釋中說明的比較詳細了。
首先,是一個while()迴圈,其中的條件是元素的size(由上一步計算而來)已經大於等於sizeCtl(說明到達了擴容條件,需要進行resize),這是用來配合CAS操作的。
接著,是根據當前陣列的容量計算了resizeStamp(該函式會根據不同的容量得到一個確定的數)。得到的這個數會在之後的擴容過程中被使用。
然後是比較sizeCtl,如果sizeCtl小於0,說明此時已經有執行緒正在擴容,排除了幾種不需要參與擴容的情況(例如,擴容已經完成,或是參與的擴容執行緒數已經到最大值,具體情況程式碼上的註解已經給出了分析),剩下的情況當前執行緒會幫助其他執行緒一起擴容,擴容前需要修改CAS修改sizeCtl(因為在擴容時,sizeCtl的後16位表示參與擴容的執行緒數,每當有一個執行緒參與擴容,需要對sizeCtl加1,當該執行緒完成時,對sizeCtl減1,這樣比對sizeCtl就可以知道是否所有執行緒都完成了擴容)。
另外如果sizeCtl大於0,說明還沒有執行緒參與擴容,此時需要CAS修改sizeCtl為rs << RESIZE_STAMP_SHIFT + 2(其中rs是有resizeStamp(n)得到的),這是一個負數,上文也說了這個數的後16位表示參與擴容的執行緒,當所有執行緒都完成了擴容時,sizeCtl應該為rs << RESIZE_STAMP_SHIFT + 1。這是我們結束擴容的條件,會在後文看到。
transfer()
transfer()方法負責對陣列進行擴容,並將資料rehash到新的節點上。這一過程中會啟用nextTable變數,並在擴容完成後,替換成table變數。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//stride是步長,transfer會依據stride,把table分為若干部分,依次處理,好讓多執行緒能協助transfer
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating //nextTab等於null表示第一個進來擴容的執行緒
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和transferIndex表示擴容的中間狀態
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; // advance 表示是否可以繼續執行下一個stride
boolean finishing = false; // to ensure sweep before committing nextTab finish表示transfer是否已經完成 nextTable已經替換了table
//開始轉移各個槽
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//STEP1 判斷是否可以進入下一個stride 確認i和bound
//通過stride領取一部分的transfer任務,while迴圈就是確認邊界
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing) //認領的部分已經被執行完(一個stride執行完)
advance = false;
else if ((nextIndex = transferIndex) <= 0) { //transfer任務被認領完
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) { //認領一個stride的任務
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
/**
* i < 0 說明要轉移的桶 都已經處理過了
*
*
* 以上條件已經說明 transfer已經完成了
*/
if (i < 0 || i >= n || i + n >= nextn) { //transfer 結束
int sc;
if (finishing) {//如果完成整個 transfer的過程 清空nextTable 讓table等於擴容後的陣列
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); //0.75f * n 重新計算下次擴容的閾值
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {//一個執行緒完成了transfer
//如果還有其他執行緒在transfer 先返回
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//說明這是最後一個在transfer的執行緒 因此finish標誌被置為 true
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null) //如果該節點為null,則對該節點的遷移立馬完成,設定成forwardNode
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else { //開始遷移該節點
synchronized (f) {//同步,保證執行緒安全
if (tabAt(tab, i) == f) { //double-check
Node<K,V> ln, hn; //ln是擴容後依舊保留在原index上的node連結串列;hn是移到index + n 上的node連結串列
if (fh >= 0) { //普通連結串列
int runBit = fh & n;
Node<K,V> lastRun = f;
//這一次遍歷的目的是找到最後一個一個節點,其後的節點hash & N 都不發生改變
//例如 有A->B->C->D,其hash & n 為 0,1,1,1 那就是找到B點
//這樣做的目的是之後對連結串列進行拆分時 C和D不需要單獨處理 維持和B的關係 B移動到新的tab[i]或tab[i+cap]上即可
//還有不理解的可以參考我的測試程式碼:https://github.com/insaneXs/all-mess/blob/master/src/main/java/com/insanexs/mess/collection/TestConHashMapSeq.java
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//如果runBit == 0 說明之前找到的節點應該在tab[i]
if (runBit == 0) {
ln = lastRun;
hn = null;
}
//否則說明之前的節點在tab[i+cap]
else {
hn = lastRun;
ln = null;
}
//上面分析了連結串列的拆分只用遍歷到lastRun的前一節點 因為lastRun及之後的節點已經移動好了
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
//這裡不再繼續使用尾插法而是改用了頭插法 因此連結串列的順序可能會發生顛倒(lastRun及之後的節點不受影響)
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//將新的連結串列移動到nextTab的對應座標中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//tab上對應座標的節點變為ForwardingNode
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) { //紅黑樹節點
TreeBin<K,V> t = (TreeBin<K,V>)f;
//同樣拆成兩棵樹
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
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;
}
}
//是否需要退化成連結串列
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(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
transfer的程式碼比較長,我們也一部分一部分的分析各段程式碼的作用。
首先,最先發起擴容的執行緒需要對陣列進行翻倍,然後將翻倍後得到的新陣列通過nextTable變數儲存。並且啟用了transferIndex變數,初始值為舊陣列的容量n,這個變數會被用來標記已經被認領的桶的下標。
擴容過程是從後往前的,因此transferIndex的初始值才是n。並且整個擴容過程依據步長stride,被拆分成個部分,執行緒從後往前依次領取一個部分,所以每次有執行緒領取任務,transferIndex總是要被減去一個stride。
當執行緒認領的一個步長的任務完成後,繼續去認領下一個步長,直到transferIndex < 0,說明所有資料都被認領完。
當參與擴容的執行緒發現沒有其他任務能被認領,那麼就會更新sizeCtl為 sizeCtl-1 (說明有一條執行緒退出擴容)。最後一條執行緒完成了任務,發現sizeCtl == (resizeStamp(n) << RESIZE_STAMP_SHIFT + 2) ,那麼說明所有的執行緒都完成了擴容任務,此時需要將nextTable替換為table,重置transferIndex,並計算新的sizeCtl表示下一次擴容的閾值。
上面介紹了執行緒每次認領一個步長的桶數負責rehash,這裡介紹下針對每個桶的rehash過程。
首先,如果桶上沒有元素或是桶上的元素是ForwardingNode,說明不用處理該桶,繼續處理上一個桶。
對於桶上存放正常的節點而言,為了執行緒安全,需要對桶的頭節點進行上鎖,然後以連結串列為例,需要將連結串列拆為兩個部分,這兩部分存放的位置是很有規律的,如果舊陣列容量為oldCap,且節點之前在舊陣列的下標為i,那麼rehash連結串列中的所有節點將放在nextTable[i]或者nextTable[i+oldCap]的桶上(這一點可以從之前雜湊值中比n最高位還靠前的一位來考慮,當前一位為0時,就落在nextTable[i]上,而前一位為1時,就落在nextTable[i+oldCap])。
同理紅黑樹也會被rehash()成兩部分,如果新的紅黑樹不滿足成樹條件,將會被退化成連結串列。
當一個桶的元素被transfer完成後,舊陣列相關位置上會被放上ForwardingNode的特殊節點表示該桶已經被遷移過。且ForwardingNode會指向nextTable。
由於不滿足樹化條件而引起的擴容
當一個桶上的連結串列節點數大於8,但是陣列容量又小於64時,ConcurrentHashMap會優先選擇擴容而非樹化,具體的方法在tryPresize()中。整體流程和addCount()方法類似,這裡不再贅述。
後話
如果讀者夠仔細的話,會發現在擴容這一段Doug Lea老爺子其實也留了些BUG下來。
一個是在addCount中判斷rs和sc關係的時候,一部分條件老爺子忘記了加位移操作。這部分程式碼如下:
sc == rs + 1 || sc == rs + MAX_RESIZERS
這一部分的等式均差了一個位移的運算。
另一個是在tryPresize()方法中,while裡的最後一個else if中 sc < 0的條件應該是永遠不成立的,因為while的條件就是sc >=0。
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);
}
上面兩部分程式碼,我在OPENJDK 16版本中確認過,確實已經修改過了。
size()過程
size()
過程其實相對簡單,上文在addCount()
已經介紹過了,為了保證ConcurrentHashMap的吞吐量,元素個數被拆成了多個部分儲存在countCells和baseCount中。那麼求size()其實就是將這幾部分資料累積。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
//counterCells 不為空,說明此時有其他執行緒在更新陣列
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
get過程
相對於put過程,get()可以說十分簡單了。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//?在hash值得基礎上再做一次雜湊,具體目的不明
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//根據雜湊的值 得到tab中的元素,因為tabAt保證了可見性,因此可以認為多執行緒下資料沒有問題
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//雜湊值小於0 說明節點正在遷移或是為樹節點 為ForwardNode或是TreeBin 可以以多型的方式由不同實現根據不同的情況去查詢
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//正常連結串列的查詢
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
和HashMap的get過程基本一直(除了對hash值的擾動方式不一樣)。
整體流程就是計算鍵的雜湊值屬於哪個桶,然後查詢該桶的所有元素,獲取key相等的節點(連結串列直接遍歷,紅黑樹用樹的方式查詢),並返回。
與JDK7實現的簡單對比
文章的最後,我們看一下JDK8中的 ConcurentHashMap 與JDK7版本中的不同,也算是一個總結。
其實,最大的差異就是JDK 8中不在使用Segment。因為其他所有的差異都是為了適應新的方式而做出的調整。
譬如resize()時的不同(JDK7中只用對對應的Segment上鎖,就可以用HashMap的方式進行resize())。
又譬如二者在size()方法上的不同(JDK7中會先累加三次各個段的size(),如果其中資料發生了變化,說明此時有其他執行緒在操作,為了資料強一致性會上全鎖(所有segment上鎖)統計size)。
雖然,JDK8中的ConcurrentHashMap實現上更為複雜, 但這樣的好處也是顯而易見的。那就是讓ConcurrentHashMap的併發等級或者說吞吐量達到了最大話。
更多JDK原始碼分析可以見我的GitHub專案:read-jdk 。
如果文章有錯誤,也歡迎指正。