[20220318聯考] 數顏色
ConcurrentHashMap
HashMap通常的實現方式是“陣列+連結串列”,這種方式被稱為“拉鍊法”。ConcurrentHashMap在這個 基本原理之上進行了各種優化
首先是所有資料都放在一個大的HashMap中;其次是引入了紅黑樹,原理如下:
- 如果頭節點是Node型別,則尾隨它的就是一個普通的連結串列;如果頭節點是TreeNode型別,它的後 面就是一顆紅黑樹,TreeNode是Node的子類
- 連結串列和紅黑樹之間可以相互轉換:初始的時候是連結串列,當連結串列中的元素超過某個閾值時,把連結串列轉 換成紅黑樹;反之,當紅黑樹中的元素個數小於某個閾值時,再轉換為連結串列。
- 這種設計優化:
- 使用紅黑樹,當一個槽裡有很多元素時,其查詢和更新速度會比連結串列快很多,Hash衝突的問 題由此得到較好的解決。
- 加鎖的粒度,並非整個ConcurrentHashMap,而是對每個頭節點分別加鎖,即併發度,就是 Node陣列的長度,初始長度為16
- 併發擴容,這是難度最大的。當一個執行緒要擴容Node陣列的時候,其他執行緒還要讀寫,因此 處理過程很複雜,後面會詳細分析
- 所以這種設計一方面降低了Hash衝突,另一方面也提升了併發度
原始碼解析
1、構造方法
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; }
- 引數:初始容量,載入因子,併發級別
- 如果初始化大小小於併發級別,則讓他們相等
- 判斷size大小
- cap代表數字長度的值
- 變數cap就是Node陣列的長度,保持為2的整數次方。tableSizeFor(...)方法是根 據傳入的初始容量,計算出一個合適的陣列長度。具體而言:1.5倍的初始容量+1,再往上取最接近的2 的整數次方,作為陣列長度cap的初始值
- 這裡的 sizeCtl,其含義是用於控制在初始化或者併發擴容時候的執行緒數,只不過其初始值設定成 cap。
2、初始化
構造方法裡只計算了陣列的初始大小,並沒有對陣列進行初始化。當多個執行緒都往裡面放 入元素的時候,再進行初始化。這就存在一個問題:多個執行緒重複初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 自旋等待
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {// 重點:將sizeCtl設定為-1
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;
// sizeCtl不是陣列長度,因此初始化成功後,就不再等於陣列長度,而是n-(n>>>2)=0.75n,表示下一次擴容的閾值:n-n/4
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;// 設定sizeCtl的值為sc。
}
break;
}
}
return tab;
}
- 首先需要初始化一個table,當sizeCtl等於0時,自旋等待
- 若條件發生變化,則將sizeCtl設定為-1(併發,那個執行緒獲取-1,就可以進行下面的操作)
- 初始化建立陣列,長度為n
- sizeCtl不是陣列長度,因此初始化成功後,就不再等於陣列長度,而是n-(n>>>2)=0.75n,表示下一次擴容的閾值:n-n/4
- 設定sizeCtl的值為sc,返回Node陣列
通過上面的程式碼可以看到,多個執行緒的競爭是通過對sizeCtl進行CAS操作實現的。如果某個執行緒成 功地把 sizeCtl 設定為-1,它就擁有了初始化的權利,進入初始化的程式碼模組,等到初始化完成,再把 sizeCtl設定回去;其他執行緒則一直執行while迴圈,自旋等待,直到陣列不為null,即當初始化結束時, 退出整個方法
3、put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
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;
// 分支1:整個陣列初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 分支2:第i個元素初始化
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
}
// 分支3:擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 分支4:放入元素
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;
}
}
}
}
// 如果是連結串列,上面的binCount會一直累加
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);// 超出閾值,轉換為紅黑樹
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);// 總元素個數累加1
return null;
}
- onlyIfAbsent:表示不存在的時候才向陣列中放
- 若等於分支1.則呼叫初始化陣列方法initTable()
- 若為分支2,第i個元素初始化(表示這個槽點沒有資料,所以直接將該元素放到頭節點返回)
- 若為分支3,說明這個槽點正在擴容,helpTransfer方法幫助擴容
- 最後到分支4,就是把元素放入槽內。槽內可能是一個連結串列,也可能是一棵紅黑樹,通過頭節點的型別 可以判斷是哪一種。第4個分支是包裹在synchronized (f)裡面的,f對應的陣列下標位置的頭節點, 意味著每個陣列元素有一把鎖,併發度等於陣列的長度
- 上面的binCount表示連結串列的元素個數,當這個數目超過TREEIFY_THRESHOLD=8時,把連結串列轉換成 紅黑樹,也就是 treeifyBin(tab,i)方法。但在這個方法內部,不一定需要進行紅黑樹轉換,可能只做 擴容操作。
4、擴容
擴容是ConcurrentHashMap中實現最複雜的一環
從上面的put方法原始碼中,可知當binCount大於等於閾值8,變成紅黑樹,所以擴容從treeifyBin方法讀起
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//陣列長度小於閾值64,不做紅黑樹轉換,直接擴容
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 連結串列轉換為紅黑樹
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
// 遍歷連結串列,初始化紅黑樹
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
- 判斷tab不等於null,說明槽點資料已經有了
- 陣列長度小於閾值64,不做紅黑樹轉換,直接擴容
- MIN_TREEIFY_CAPACITY=64(陣列的長度):儲存箱可樹化的最小表容量,這個值至少是4被閾值(TREEIFY_THRESHOLD=8)大小,以避免調整大小和樹化閾值之間的衝突
- 若陣列長度大於閾值64時,則轉換為紅黑樹
在treeifyBin方法中,內部呼叫了方法tryPresize方法
private final void tryPresize(int size) {
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
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 = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
int rs = resizeStamp(n);
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);
}
}
}
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 計算步長
if (nextTab == null) { // 初始化新的HashMap
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為舊HashMap的陣列長度
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 此處,i為遍歷下標,bound為邊界
// 如果成功獲取一個任務,則i=nextIndex-1
//bound=nextIndex-stride;
//如果獲取不到,則i=0,bound=0
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// advance表示在從i=transferIndex-1遍歷到bound位置的過程中,是否一直繼續
while (advance) {
int nextIndex, nextBound;
// 以下是哪個分支中的advance都是false,表示如果三個分支都不執行,才可以一直while迴圈
// 目的在於當對transferIndex執行CAS操作不成功的時候,需要自旋,以期獲取一個stride的遷移任務。
if (--i >= bound || finishing)
// 對陣列遍歷,通過這裡的--i進行。如果成功執行了--i,就不需要繼續while迴圈了,因為advance只能進一步。
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 對transferIndex執行CAS操作,即為當前執行緒分配1個stride。
// CAS操作成功,執行緒成功獲取到一個stride的遷移任務;
// CAS操作不成功,執行緒沒有搶到任務,會繼續執行while迴圈,自旋。
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// i越界,整個HashMap遍歷完成
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// finishing表示整個HashMap擴容完成
if (finishing) {
nextTable = null;
// 將nextTab賦值給當前table
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
// tab[i]遷移完畢,賦值一個ForwardingNode
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// tab[i]的位置已經在遷移過程中
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 對tab[i]進行遷移操作,tab[i]可能是一個連結串列或者紅黑樹
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 連結串列
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
// 表示lastRun之後的所有元素,hash值都是一樣的
// 記錄下這個最後的位置
lastRun = p;
}
}
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(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
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;
}
}
}
}
}
}
-
可以看到,擴容首先是先嚐試擴容,呼叫第一個方法tryPresize,也就是嘗試預先調整表的大小以容納給定數量的元素
-
具體擴容的方法在末尾呼叫的第二個方法transfer
-
擴容的基本原理:
-
首先建立一個新的HashMap,其陣列長度是原來陣列的倆倍
-
將old的元素逐個遷移過來,這點從引數中可以看出,tab是擴容前的HashMap,nextTab是擴容後的hashMap,當nextTab==null時,就會對nextTab進行初始化,容量為2倍
-
上述步驟就會遇到一個問題,併發問題,如下圖:
-
舊的的陣列長度為N,每個執行緒擴容一段,一段的長度用量用變數stride(步長)來表示,transferIndex表示整個陣列擴容的進度
-
stride(步長)的計算公式:在單核模式下直接等於0,因為單核模式下沒有辦 法多個執行緒並行擴容,只需要1個執行緒來擴容整個陣列;在多核模式下為 (n>>> 3)/NCPU,並且保證步長的最小值是 16。顯然,需要的執行緒個數約為n/stride
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
-
transferIndex是ConcurrentHashMap的一個成員變數,記錄了擴容的進度。初始值為n,從大到 小擴容,每次減stride個位置,最終減至n<=0,表示整個擴容完成。因此,從[0,transferIndex-1]的 位置表示還沒有分配到執行緒擴容的部分,從[transfexIndex,n-1]的位置表示已經分配給某個執行緒進行擴 容,當前正在擴容中,或者已經擴容成功。
-
因為transferIndex會被多個執行緒併發修改,每次減stride,所以需要通過CAS進行操作,如下面的程式碼 所示
else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0)))
-
在擴容未完成之前,有的陣列下標對應的槽已經遷移到了新的HashMap裡面,有的還在舊的 HashMap 裡面。這個時候,所有呼叫 get(k,v)的執行緒還是會訪問舊 HashMap,怎麼處理 呢?
-
解決方案:當Node[0]已經遷移成功,而其他Node還在遷移過程中時, 如果有執行緒要讀取Node[0]的資料,就會訪問失敗。為此,新建一個ForwardingNode,即轉 發節點,在這個節點裡面記錄的是新的 ConcurrentHashMap 的引用。這樣,當執行緒訪問到 ForwardingNode之後,會去查詢新的ConcurrentHashMap
-
因為陣列的長度 tab.length 是2的整數次方,每次擴容又是2倍。而 Hash 函式是 hashCode%tab.length,等價於hashCode&(tab.length-1)。這意味著:處於第i個位置的 元素,在新的Hash表的陣列中一定處於第i個或者第i+n個位置,舉個簡單的例 子:假設陣列長度是8,擴容之後是16
-
若hashCode=5,5&8=0,擴容後,5&16=0,位置保持不變;
-
若hashCode=24,24&8=0,擴容後,24&16=8,後移8個位置;
-
若hashCode=25,25&8=1,擴容後,25&16=9,後移8個位置;
-
若hashCode=39,39&8=7,擴容後,39&8=7,位置保持不變;
-
正因為有這樣的規律,所以如下有程式碼:
setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd);
-
也就是把tab[i]位置的連結串列或紅黑樹重新組裝成兩部分,一部分連結到nextTab[i]的位置,一部分鏈 接到nextTab[i+n]的位置。然後把tab[i]的位置指向一個ForwardingNode節點
-
同時,當tab[i]後面是連結串列時,使用類似於JDK 7中在擴容時的優化方法,從lastRun往後的所有節 點,不需依次拷貝,而是直接連結到新的連結串列頭部。從lastRun往前的所有節點,需要依次拷貝
-
瞭解了核心的遷移函式transfer(tab,nextTab),再回頭看tryPresize(int size)函式。這個函 數的輸入是整個Hash表的元素個數,在函式裡面,根據需要對整個Hash表進行擴容。想要看明白這個 函式,需要透徹地理解sizeCtl變數,下面這段註釋摘自原始碼
/** * Table initialization and resizing control. When negative, the * table is being initialized or resized: -1 for initialization, * else -(1 + the number of active resizing threads). Otherwise, * when table is null, holds the initial table size to use upon * creation, or 0 for default. After initialization, holds the * next element count value upon which to resize the table. */
- 當sizeCtl=-1時,表示整個HashMap正在初始化;
- 當sizeCtl=某個其他負數時,表示多個執行緒在對HashMap做併發擴容;
- 當sizeCtl=cap時,tab=null,表示未初始之前的初始容量(如上面的建構函式所示);
- 擴容成功之後,sizeCtl儲存的是下一次要擴容的閾值,即上面初始化程式碼中的n-(n>>>2) =0.75n。
- 所以,sizeCtl變數在Hash表處於不同狀態時,表達不同的含義。明白了這個道理,再來看上面的 tryPresize(int size)函式。
-
tryPresize(int size)是根據期望的元素個數對整個Hash表進行擴容,核心是呼叫transfer函式。 在第一次擴容的時候,sizeCtl會被設定成一個很大的負數U.compareAndSwapInt(this,SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT)+2);之後每一個執行緒擴容的時候,sizeCtl 就加 1, U.compareAndSwapInt(this,SIZECTL,sc,sc+1),待擴容完成之後,sizeCtl減1。
-
-
-
-