數據結構
使用數組+鏈表+紅黑樹來實現,利用 CAS + synchronized 來保證併發更新的安全
源碼分析
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;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
源碼分析:
public V put(K key, V value) {
return putVal(key, value, false);
}
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;
首先判斷key和value是否爲空,如果爲空的話直接拋出空指針異常。注意:ConcurrentHashMap的鍵和值不能爲空。
== 面試題:ConcurrentHashMap的鍵值對爲什麼不能爲null,而HashMap卻可以?==
當你通過get(k)獲取對應的value時,如果獲取到的是null時,你無法判斷,它是put(k,v)的時候value爲null,還是這個key從來沒有做過映射。HashMap是非併發的,可以通過contains(key)來做這個判斷。而支持併發的Map在調用m.contains(key)和m.get(key)的時候,m可能已經不同了。
接下來計算hash值,這裏的hash值沒有直接用Object類中的hashCode()方法,而是經過了下面的變換:
static final int HASH_BITS = 0x7fffffff;
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
這個原理和HashMap一樣。
>>>
的基本操作就是右移,然後高位補0,這裏的右移表示連符號位都要跟着左移;而>>只是右移數值位,不移動符號位。
這裏是將原來的hashcode自身與右移16位之後進行異或運算,這樣因爲hash要和(length-1)進行與運算之後得到索引,(length-1)一般不會太大,所以hash的高位一般用不上。將原來hashcode的高16位和低16位做異或運算,這樣新的hash也保留了高位的部分信息,會較少哈希衝突。
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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 {
聲明一個Node<K,V>類型的數組tab,接下來有一個循環,循環內首先判斷tab數組是否存在,如果不存在就初始化這個tab,一會說何如初始化。
如果tab不爲空,並且 計算出一個數組的下標i = (n - 1) & hash,然後查看tab數組中的 i 位置是爲null。就直接casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)) 使用CAS向tab中的第i個位置存入一個node類型的鍵值對然後退出循環。如果casTabAt失敗了,後面還會再循環到這一步,也就是使用CAS自旋的往這個位置放入節點。
如果tab不爲空,並且 f.hash == MOVED
,f 就是前面tab中的第i個位置上的值,該值的哈希值如果==MOVED,MOVED爲常量-1,代表ConcurrentHashMap還有其他正在進行擴容。接下來執行helpTransfer就是幫助他進行擴容。
如果tab不爲空,並且當前沒有其他線程正在擴容:
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;
}
}
}
對鏈表(紅黑樹)中的根節點加了synchronized 鎖,那麼在同一時間內,只能有一個線程對這條鏈表(紅黑樹)進行操作。拿到鎖之後再判斷一下根節點是否發生了變化,發生變化的話就要重新進入循環。沒有發生變化的話, 接下來就是在鏈表中新增加一個節點。
fh是根節點的哈希值,如果這個值大於等於0就代表是鏈表,否則就是紅黑樹。如果是鏈表的話,就循環的比較與當前鏈表中每個節點的hash值和equals是否相等,相等的話就覆蓋,不相等的話就在鏈表的尾部插入新的節點。插入或覆蓋結束之後代碼繼續往下運行。
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;
}
}
如果 f 是紅黑樹 ,那麼就是已經樹化了,數據的插入就是紅黑樹的插入。要注意的是,這裏是紅黑樹的話 f 是一個TreeBin 對象而不直接是紅黑樹的根節點,因爲在紅黑樹的插入操作時有可能紅黑樹的根節點發生變化。如果對紅黑樹的根節點進行加鎖,put之後根節點發生了變化,其他線程獲得這個就可以獲得新節點並加鎖,這樣就會出錯。如果是TreeBin 對象的話,鎖住的就是這個對象,根節點發生變化不會應該加鎖。
在ConcurrentHashMap中不是直接存儲TreeNode來實現的,而是用TreeBin
來包裝TreeNode來實現的。也就是說在實際的ConcurrentHashMap桶中,存放的是TreeBin
對象,而不是TreeNode對象。之所以TreeNode繼承自Node是爲了附帶next指針,而這個next指針可以在TreeBin中尋找下一個TreeNode,這裏也是與HashMap之間比較大的區別。
if (binCount != 0) {
//static final int TREEIFY_THRESHOLD = 8;
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
在數據插入之後,要判斷鏈表的值是否大於等於8,如果大於等於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)
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));
}
}
}
}
}
在進行樹化操作時會先判斷table數組的長度是否小於MIN_TREEIFY_CAPACITY),如果小於的話會使用tryPresize方法將容量擴大2倍。
initTable()
轉而開一下putVal()方法開頭的 initTable() 方法:
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(); // 讓出線程
//正在初始化時將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")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc; //擴容保護
}
break;
}
}
return tab;
}
initTable()用於裏面table數組的初始化,值得一提的是table初始化是沒有加鎖的,那麼如何處理併發呢?
由上面代碼可以看到,當要初始化時會通過CAS操作將sizeCtl置爲-1,而sizeCtl由volatile修飾,保證修改對後面線程可見。
這之後如果再有線程執行到此方法時檢測到sizeCtl爲負數,說明已經有線程在給擴容了,這個線程就會調用Thread.yield()讓出一次CPU執行時間。
對put方法的簡單總結
①先傳入一個k和v的鍵值對,不可爲空(HashMap是可以爲空的),如果爲空就直接報錯。
②接着去判斷table是否爲空,如果爲空就進入初始化階段。
③如果判斷數組中某個指定的桶是空的,那就直接把鍵值對插入到這個桶中作爲頭節點,而且這個操作不用加鎖。
④如果這個要插入的桶中的hash值爲-1,也就是MOVED狀態(也就是這個節點是forwordingNode),那就是說明有線程正在進行擴容操作,那麼當前線程就進入協助擴容階段。
⑤需要把數據插入到鏈表或者樹中,如果這個節點是一個鏈表節點,那麼就遍歷這個鏈表,如果發現有相同的key值就更新value值,如果遍歷完了都沒有發現相同的key值,就需要在鏈表的尾部插入該數據。插入結束之後判斷該鏈表節點個數是否大於8,如果大於就需要把鏈表轉化爲紅黑樹存儲。
⑥如果這個節點是一個紅黑樹節點,那就需要按照樹的插入規則進行插入。
⑦put結束之後,需要給map已存儲的數量+1,在addCount方法中判斷是否需要擴容
擴容實現
擴容是在面試中常考的點。
什麼時候會擴容?
當往hashMap中成功插入一個key/value節點時,有兩種情況可能觸發擴容動作:
1、如果新增節點之後,所在鏈表的元素個數達到了閾值 8,則會調用treeifyBin方法把鏈表轉換成紅黑樹,不過在結構轉換之前,會對數組長度進行判斷,實現如下:如果數組長度n小於閾值MIN_TREEIFY_CAPACITY,默認是64,則會調用tryPresize方法把數組長度擴大到原來的兩倍,並觸發transfer方法,重新調整節點的位置。
2、調用put方法新增節點時,在結尾會調用addCount方法記錄元素個數,並檢查是否需要進行擴容,當數組元素個數達到閾值時,會觸發transfer方法,重新調整節點的位置。
擴容狀態下其他線程對集合進行插入、修改、刪除、合併、compute 等操作時遇到 ForwardingNode 節點會觸發擴容 。
putAll 批量插入或者插入節點後發現存在鏈表長度達到 8 個或以上,但數組長度爲 64 以下時會觸發擴容 。
注意:桶上鍊表長度達到 8 個或者以上,並且數組長度爲 64 以下時只會觸發擴容而不會將鏈表轉爲紅黑樹 。
如何擴容
addCount
在putVal方法的循環執行完畢之後,一個Node肯定放入進去了,此時就需要調用addCount方法來更新baseCount變量:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
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();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
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);
s = sumCount();
}
}
}
其中
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
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();
}
有一個名爲CounterCell的數組,CounterCell是一個簡單的內部靜態類,每個CounterCell都是一個用於記錄元素個數的的單元。像一般的集合記錄集合大小,直接定義一個size的成員變量即可,當出現改變的時候只要更新這個變量就行。爲什麼ConcurrentHashMap要用這種形式來處理呢? 問題還是處在併發上,ConcurrentHashMap是併發集合,如果用一個成員變量來統計元素個數的話,爲了保證併發情況下共享變量的的安全,勢必會需要通過加鎖或者自旋來實現,如果競爭比較激烈的情況下,size的設置上會出現比較大的衝突反而影響了性能,所以在ConcurrentHashMap採用了分片的方法來記錄大小。
先判斷CounterCell數組是否爲空,不爲空的話直接進入if代碼執行,不再進行後面那個CAS操作,如果CounterCell爲空的話就使用CAS的方式修改baseCount,將其加上x。如果CAS修改成功,上面代碼也不會執行。如果CAS也失敗,就進入if的代碼塊,對CounterCell數組進行初始化。
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//s是當前元素的個數
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
//sc<0表示已經有線程在進行擴容工作或者在進行初始化
if (sc < 0) {
//條件1:檢查是對容量n的擴容,保證sizeCtl與n是一塊修改好的
//條件2與條件3:進行sc的最小值或最大值判斷。
//條件4與條件5: 確保tranfer()中的nextTable相關初始化邏輯已走完。
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//有新線程參與擴容則sizeCtl加1
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);
s = sumCount();
}
}
上面這段代碼是擴容的關鍵代碼:
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//s是當前元素的個數
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
if (check >= 0)
判斷put傳入的binCount(也就是check),check爲-1的時候代表刪除操作。通過check的正負判斷是否進行擴容操作。
sizeCtl爲擴容的閾值。sizeCtl默認的情況下等於0,對ConcurrentHashMap進行初始化的時候會對sizeCtl減1,初始化成功後將sizeCtl改爲閾值(最大長度*0.75)。
s >= (long)(sc = sizeCtl)
是進行判斷當前的元素個數是否大於閾值,(tab = table) != null
數組的長度不爲空,(n = tab.length) < MAXIMUM_CAPACITY
數組長度小於最大的容量
也就是噹噹前容量大於擴容閾值並且小於最大擴容值才擴容,如果tab=null說明正在初始化,死循環等待初始化完成。
resizeStamp()
private static int RESIZE_STAMP_BITS = 16;
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
Integer.numberOfLeadingZeros(n)用於計算n轉換成二進制後前面有幾個0。這個有什麼作用呢?
首先ConcurrentHashMap的容量必定是2的冪次方,所以不同的容量n前面0的個數必然不同,這樣可以保證是在原容量爲n的情況下進行擴容。
(1 << (RESIZE_STAMP_BITS - 1)即是1<<15,表示爲二進制即是高16位爲0,低16位爲1:
0000 0000 0000 0000 1000 0000 0000 0000
所以resizeStamp(n)的返回值爲:高16位置0,第16位爲1,低15位存放當前容量n,用於表示是對n的擴容。
rs與RESIZE_STAMP_SHIFT配合可以求出新的sizeCtl的值,分情況如下:
- sc < 0
已經有線程在擴容,將sizeCtl+1並調用transfer()讓當前線程參與擴容。 - sc >= 0
表示沒有線程在擴容,使用CAS將sizeCtl的值改爲(rs << RESIZE_STAMP_SHIFT) + 2)。
rs即resizeStamp(n),記temp=rs << RESIZE_STAMP_SHIFT。如當前容量爲8時rs的值:
//rs
0000 0000 0000 0000 1000 0000 0000 1000
//temp = rs << RESIZE_STAMP_SHIFT,即 temp = rs << 16,左移16後temp最高位爲1,所以temp成了一個負數。
1000 0000 0000 1000 0000 0000 0000 0000
//sc = (rs << RESIZE_STAMP_SHIFT) + 2)
1000 0000 0000 1000 0000 0000 0000 0010
那麼在擴容時sizeCtl值的意義:高15位爲容量n ,低16位爲並行擴容線程數+1
transfer() 重要
jdk1.8版本的ConcurrentHashMap支持併發擴容,transfer方法是真正進行擴容的函數。
調用該擴容方法的地方有:
- 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; //stride 主要和CPU相關
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //每個線程處理桶的最小數目,可以看出核數越高步長越小,最小16個。
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; //擴容到2倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE; //擴容保護
return;
}
nextTable = nextTab;
transferIndex = n; //擴容總進度,>=transferIndex的桶都已分配出去。
}
int nextn = nextTab.length;
//擴容時的特殊節點,標明此節點正在進行遷移,擴容期間的元素查找要調用其find()方法在nextTable中查找元素。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//當前線程是否需要繼續尋找下一個可處理的節點
boolean advance = true;
boolean finishing = false; //所有桶是否都已遷移完成。
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//此循環的作用是確定當前線程要遷移的桶的範圍或通過更新i的值確定當前範圍內下一個要處理的節點。
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing) //每次循環都檢查結束條件
advance = false;
//遷移總進度<=0,表示所有桶都已遷移完成。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) { //transferIndex減去已分配出去的桶。
//確定當前線程每次分配的待遷移桶的範圍爲[bound, nextIndex)
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//當前線程自己的活已經做完或所有線程的活都已做完,第二與第三個條件應該是下面讓"i = n"後,再次進入循環時要做的邊界檢查。
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { //所有線程已幹完活,最後才走這裏。
nextTable = null;
table = nextTab; //替換新table
sizeCtl = (n << 1) - (n >>> 1); //調sizeCtl爲新容量0.75倍。
return;
}
//當前線程已結束擴容,sizeCtl-1表示參與擴容線程數-1。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//還記得addCount()處給sizeCtl賦的初值嗎?相等時說明沒有線程在參與擴容了,置finishing=advance=true,爲保險讓i=n再檢查一次。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd); //如果i處是ForwardingNode表示第i個桶已經有線程在負責遷移了。
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) { //桶內元素遷移需要加鎖。
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//鏈表的處理
if (fh >= 0) { //>=0表示是鏈表結點
//由於n是2的冪次方(所有二進制位中只有一個1),如n=16(0001 0000),第4位爲1,那麼hash&n後的值第4位只能爲0或1。所以可以根據hash&n的結果將所有結點分爲兩部分。
int runBit = fh & n;
Node<K,V> lastRun = f;
//找出最後一段完整的fh&n不變的鏈表,這樣最後這一段鏈表就不用重新創建新結點了。
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//lastRun之前的結點因爲fh&n不確定,所以全部需要重新遷移。
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);
}
//低位鏈表放在i處
setTabAt(nextTab, i, ln);
//高位鏈表放在i+n處
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd); //在原table中設置ForwardingNode節點以提示該桶擴容完成。
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;
}
}
//如果拆分後的樹的節點數量已經少於6個就需要重新轉化爲鏈表
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;
//CAS存儲在nextTable的i位置上
setTabAt(nextTab, i, ln);
//CAS存儲在nextTable的i+n位置上
setTabAt(nextTab, i + n, hn);
//CAS在原table的i處設置forwordingNode節點,表示這個這個節點已經處理完畢
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
看第一部分:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride; //stride 主要和CPU相關
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //每個線程處理桶的最小數目,可以看出核數越高步長越小,最小步長16個。
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; //擴容到2倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE; //擴容保護
return;
}
nextTable = nextTab;
transferIndex = n; //擴容總進度,>=transferIndex的桶都已分配出去。
int nextn = nextTab.length;
}
n爲擴容之前數組的長度,stride 主要和CPU相關,含義爲步長,每個線程在擴容時拿到的長度,最小爲16。
nextTab爲新的table,如果nextTab爲空就新建一個table,大小爲原來的2倍,代表雙倍擴容。
transferIndex=n; n爲擴容前的大小。
int nextn = nextTab.length; nextn爲擴容後數組的大小
//構造一個ForwardingNode用於多線程之間的共同擴容情況
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; //繼續遍歷的確認標誌
boolean finishing = false; //所有桶是否都已遷移完成標誌
fwd是一個標誌,標明此節點正在進行遷移。當其他線程進行操作的時候發現這個位置存放的是fwd就知道正在進行擴容。、
advance是遍歷的確認標誌,是否再往前進行遍歷。 finishing 是所有桶是否都已遷移完成標誌。
//遍歷每個節點
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh; //定義一個節點和一個節點狀態判斷標誌fh
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing) ////每次循環都檢查結束條件
advance = false;
//遷移總進度<=0,表示所有桶都已遷移完成
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
接下來開始遍歷每一個節點,bound是當前步長結尾的位置。初始化一個節點f和一個節點狀態判斷標誌fh
while循環的作用是確定當前線程要遷移的桶的範圍或通過更新i的值確定當前範圍內下一個要處理的節點。
當前步長內元素轉移完成後 i = -1
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) { //transferIndex減去已分配出去的桶。
//確定當前線程每次分配的待遷移桶的範圍爲[bound, nextIndex)
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
這裏是一個CAS的計算來修改TRANSFERINDEX(轉移到的下標),配合上面一段代碼計算出當前線程操作數組的具體區域。
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//如果原table已經複製結束
if (finishing) {
nextTable = null; //可以看出在擴容的時候nextTable只是類似於一個temp用完會丟掉
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); //修改擴容後的閥值,應該是現在容量的0.75倍
return;//結束循環
}
//採用CAS算法更新SizeCtl。
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
}
}
i < 0 || i >= n || i + n >= nextn
一共三個條件。1.當前線程自己的活已經做完或所有線程的活都已做完,第二與第三個條件應該是下面讓"i = n"後,再次進入循環時要做的邊界檢查。
如果當前線程的工作做完後發現此時已經finialing了,就可以把sizeCtl 改爲新的值結束循環。
//CAS算法獲取數組第i的節點,爲空就設爲forwordingNode
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//如果這個節點的hash值是MOVED,就表示這個節點是forwordingNode節點,就表示這個節點已經被處理過了,直接跳過
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
如註釋所示,CAS算法獲取數組第i個位置的值,爲空就設爲fwd標誌位。如果這個節點的hash值是MOVED,代表內容已經是fwd,被其他線程處理過了,直接跳過,繼續前進。
//處理鏈表
if (tabAt(tab, i) == f) {//再做一次校驗
//一個高位節點,一個低位節點
Node<K, V> ln, hn;//1n表示低位,hn表示高位;接下來這段代碼的作用是把鏈表拆分成兩部分,0在低位,1在高位
*/爲什麼要分成高位和低位兩種節點?
1.如果 一個一個節點遷移,需要計算很多次節點的hash值。有了高低位鏈表,只需要在這裏進行一次計算,且遷移也是一次(lastRun)
2.對於一個節點遷移到新的數組中,其對應的位置只有兩種可能,一種是下標不變,另一種是原始下標+數組的長度,所以這裏把高位鏈添加到i+n的地方
下次獲取的時候相當於一下就獲取到了低位鏈和高位鏈的數據
*/
if (fh >= 0) { //當前節點的哈希值
/*由於n是2的冪次方(所有二進制位中只有一個1),如n=16(0001 0000),第4位爲1,那麼hash&n後的值第4位只能爲0或1。
所以可以根據hash&n的結果將所有結點分爲兩部分。就是尾結點的第x位等於0? 0->低位 否則->高位
低位節點哈希值的第x位等於0(fh & n)
高位節點哈希值的第x位不等於0(fh & n) */
int runBit = fh & n; //fh是當前節點的哈希值,n是tab長度,runBit是fh的第n位的值(0或1)
//lastRun是最終要處理的節點,這裏先讓指向根節點,後面還會尋找真正的lastRun
Node<K, V> lastRun = f;
//一條鏈表上的hash值相等嗎? 不一定相等,下標相等只是 (n-1)&hash相等。
//這個for循環找到lastRun,從lastRun開始後面的要在低位都在低位,要在高位都在高位
for (Node<K, V> p = f.next; p != null; p = p.next) {
// 取於桶中每個節點的 hash 值
//下一個節點的hash&n 當前節點的hash&n,因爲一直循環所以就是尾結點和頭結點的hash&n比較
int b = p.hash & n;
// 如果節點的 hash 值和首節點的 hash 值取於結果不同
if (b != runBit) {
runBit = b;// 更新 runBit爲尾結點的,用於下面判斷 lastRun 該賦值給 ln 還是 hn。
lastRun = p;// 這個 lastRun 保證後面的節點與自己的取於值相同,避免後面沒有必要的循環,因爲上面是從p開始循環的。
// 所以這裏到lastrun就不循環了
}
}
if (runBit == 0) {//如果最後更新的 runBit,設置低位節點
ln = lastRun;
hn = null;
} else {//否則,設置高位節點
hn = lastRun;// 如果最後更新的 runBit 是 1, 設置高位節點
ln = null;
}
//構造高位以及低位的鏈表
// 再次循環,生成兩個鏈表,lastRun 作爲停止條件,這樣就是避免無謂的循環(lastRun 後面都是相同的取於結果)
// 將原本的一個鏈表根據hash&n分爲2個鏈表,構建新鏈表採用頭插法
// 無法概括兩個新鏈表相對舊鏈表的順序,有很多可能,並不是一個正序,一個倒序
// 這個for循環,把lastRun前面裝配到高位節點或者低位節點
for (Node<K, V> p = f; p != lastRun; p = p.next) {
int ph = p.hash;
K pk = p.key;
V pv = p.val;
// 如果與運算結果是 0,那麼就還在低位
if ((ph & n) == 0)// 如果是0 ,那麼創建低位節點
ln = new Node<K, V>(ph, pk, pv, ln);
else // 1 則創建高位
hn = new Node<K, V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);//將低位的鏈表放在i位置也就是不動 低位鏈不需要變
setTabAt(nextTab, i + n, hn);//將高位鏈表放在i+n位置,n是數組的長度,是假如當前14 14+16=30
setTabAt(tab, i, fwd);//把舊 table的hash桶中放置轉發節點,表明此hash桶已經被處理
advance = true;
}
這裏先根據 fh 判斷頭的位置是鏈表的頭節點還是樹的根節點,如果是鏈表的話就執行鏈表轉移,在轉移過程中使用的是CAS算法。使用頭插法進行轉移。
如果是紅黑樹的話轉移的方法和HashMap1.8中對紅黑樹的轉移是一樣的,是使用了兩個鏈表,一個是高位鏈表一個是低位鏈表:
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;
}
helpTransfer()
這個方法是幫助其他線程進行擴容。添加、刪除節點之處都會檢測到table的第i個桶是ForwardingNode的話會調用helpTransfer()方法。
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 (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;
}
tryPresize()
putAll批量插入或者插入節點後發現鏈表長度達到8個或以上,但數組長度爲64以下時觸發的擴容會調用到這個方法.
private final void tryPresize(int size) {
//根據傳入的size計算出真正的新容量,因爲新容量需要是2的冪次方。
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; //table未初始化則給一個初始容量
//初始化時將 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);
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);
}
}
}
併發擴容總結
單線程新建nextTable,新容量一般爲原table容量的兩倍。
每個線程想增/刪元素時,如果訪問的桶是ForwardingNode節點,則表明當前正處於擴容狀態,協助一起擴容完成後再完成相應的數據更改操作。
擴容時將原table的所有桶倒序分配,每個線程每次最小分配16個桶,防止資源競爭導致的效率下降。單個桶內元素的遷移是加鎖的,但桶範圍處理分配可以多線程,在沒有遷移完成所有桶之前每個線程需要重複獲取遷移桶範圍,直至所有桶遷移完成。
一箇舊桶內的數據遷移完成但不是所有桶都遷移完成時,查詢數據委託給ForwardingNode結點查詢nextTable完成(這個後面看find()分析)。
遷移過程中sizeCtl用於記錄參與擴容線程的數量,全部遷移完成後sizeCtl更新爲新table容量的0.75倍。
tabAt()/casTabAt()/setTabAt()
// 獲取 Node[] 中第 i 個 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)
// cas 修改 Node[] 中第 i 個 Node 的值, c 爲舊值, v 爲新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)
// 直接修改 Node[] 中第 i 個 Node 的值, v 爲新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
北
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 。
get 流程
public V get( Object key )
{
Node<K, V>[] tab; Node<K, V> e, p; int n, eh; K ek;
/* spread 方法能確保返回結果是正數 */
int h = spread( key.hashCode() );
if ( (tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt( tab, (n - 1) & h ) ) != null )
{
/* 如果頭結點已經是要查找的 key */
if ( (eh = e.hash) == h )
{
if ( (ek = e.key) == key || (ek != null && key.equals( ek ) ) )
return(e.val);
}
/* hash 爲負數表示該 bin 在擴容中或是 treebin, 這時調用 find 方法來查找 */
else if ( eh < 0 )
return( (p = e.find( h, key ) ) != null ? p.val : null);
/* 正常遍歷鏈表, 用 equals 比較 */
while ( (e = e.next) != null )
{
if ( e.hash == h &&
( (ek = e.key) == key || (ek != null && key.equals( ek ) ) ) )
return(e.val);
}
}
return(null);
}
size 計算流程
size 計算實際發生在 put,remove 改變集合元素的操作之中
- 沒有競爭發生,向 baseCount 累加計數
- 有競爭發生,新建 counterCells,向其中的一個 cell 累加計數
- counterCells 初始有兩個 cell
- 如果計數競爭比較激烈,會創建新的 cell 來累加計數
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
// 將 baseCount 計數與所有 cell 計數累加
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
- 初始化,使用 cas 來保證併發安全,懶惰初始化 table
- 樹化,當 table.length < 64 時,先嚐試擴容,超過 64 時,並且 bin.length > 8 時,會將鏈表樹化,樹化過程會用 synchronized 鎖住鏈表頭
- put,如果該 bin 尚未創建,只需要使用 cas 創建 bin;如果已經有了,鎖住鏈表頭進行後續 put 操作,元素添加至 bin 的尾部
- get,無鎖操作僅需要保證可見性,擴容過程中 get 操作拿到的是 ForwardingNode 它會讓 get 操作在新table 進行搜索
- 擴容,擴容時以 bin 爲單位進行,需要對 bin 進行 synchronized,但這時妙的是其它競爭線程也不是無事可做,它們會幫助把其它 bin 進行擴容,擴容時平均只有 1/6 的節點會把複製到新 table 中
- size,元素個數保存在 baseCount 中,併發時的個數變動保存在 CounterCell[] 當中。最後統計數量時累加即可。
圖解擴容
觸發擴容的操作:
假設目前數組長度爲8,數組的元素的個數爲5。再放入一個元素就會觸發擴容操作。
總結一下擴容條件:
(1) 元素個數達到擴容閾值。
(2) 調用 putAll 方法,但目前容量不足以存放所有元素時。
(3) 某條鏈表長度達到8,但數組長度卻小於64時。
CPU核數與遷移任務hash桶數量分配(步長)的關係
單線程下線程的任務分配與遷移操作
多線程如何分配任務?
普通鏈表如何遷移?
首先鎖住數組上的Node節點,然後和HashMap1.8中一樣,將鏈表拆分爲高位鏈表和低位鏈表兩個部分,然後複製到新的數組中。
什麼是 lastRun 節點?
紅黑樹如何遷移?
hash桶遷移中以及遷移後如何處理存取請求?
多線程遷移任務完成後的操作
面試
1. JDK1.8中的ConcurrentHashMap是如何保證線程安全的?
模板2:
- 儲存Map數據的數組時被volatile關鍵字修飾,一旦被修改,其他線程就可見修改。因爲是數組存儲,所以只有改變數組內存值是纔會觸發volatile的可見性
- 如果put操作時hash計算出的槽點內沒有值,採用自旋+CAS保證put一定成功,且不會覆蓋其他線程put的值
- 如果put操作時節點正在擴容,即發現槽點爲轉移節點,會等待擴容完成後再進行put操作,保證擴容時老數組不會變化
- 對槽點進行操作時會鎖住槽點,保證只有當前線程能對槽點上的鏈表或紅黑樹進行操作
- 紅黑樹旋轉時會鎖住根節點,保證旋轉時線程安全
2. JDK7和JDK8中的ConcurrentHashMap不同點。
3. 擴容期間在未遷移到的hash桶插入數據會發生什麼?
答:只要插入的位置擴容線程還未遷移到,就可以插入,當遷移到該插入的位置時,就會阻塞等待插入操作完成再繼續遷移 。
4.1 正在遷移的hash桶遇到 get 操作會發生什麼?
答:在擴容過程期間形成的 hn 和 ln鏈 是使用的類似於複製引用的方式,也就是說 ln 和 hn 鏈是複製出來的,而非原來的鏈表遷移過去的,所以原來 hash 桶上的鏈表並沒有受到影響,因此如果當前節點有數據,還沒遷移完成,此時不影響讀,能夠正常進行。
如果當前鏈表已經遷移完成,那麼頭節點會被設置成fwd節點,此時get線程會幫助擴容。
4.2 正在遷移的hash桶遇到 put/remove 操作會發生什麼?
如果當前鏈表已經遷移完成,那麼頭節點會被設置成fwd節點,此時寫線程會幫助擴容,如果擴容沒有完成,當前鏈表的頭節點會被鎖住,所以寫線程會被阻塞,直到擴容完成。
5. 如果 lastRun 節點正好在一條全部都爲高位或者全部都爲低位的鏈表上,會不會形成死循環?
答:在數組長度爲64之前會導致一直擴容,但是到了64或者以上後就會轉換爲紅黑樹,因此不會一直死循環 。
6. 擴容後 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 。
7. 併發情況下,各線程中的數據可能不是最新的,那爲什麼 get 方法不需要加鎖?
答:get操作全程不需要加鎖是因爲Node的成員val是用volatile修飾的,在多線程環境下線程A修改結點的val或者新增節點的時候是對線程B可見的。。
8.1 ConcurrentHashMap 和 Hashtable 的區別?
ConcurrentHashMap 和 Hashtable 的區別主要體現在實現線程安全的方式上不同。
底層數據結構:
JDK1.7的 ConcurrentHashMap 底層採用 分段的數組+鏈表 實現,JDK1.8 採用的數據結構跟HashMap1.8的結構一樣,數組+鏈表/紅黑二叉樹。Hashtable是採用 數組+鏈表 的形式。
實現線程安全的方式(重要): ① 在JDK1.7的時候,ConcurrentHashMap(分段鎖) 對整個桶數組進行了分割分段(Segment),每一把鎖只鎖容器其中一部分數據,多線程訪問容器裏不同數據段的數據,就不會存在鎖競爭,提高併發訪問率。 到了 JDK1.8 的時候已經摒棄了Segment的概念,而是直接用 Node 數組+鏈表+紅黑樹的數據結構來實現,併發控制使用 synchronized 和 CAS 來操作。② Hashtable(同一把鎖) :使用 synchronized 來保證線程安全,效率非常低下。當一個線程訪問同步方法時,其他線程也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 添加元素,另一個線程不能使用 put 添加元素,也不能使用 get,競爭會越來越激烈效率越低。
8.2 ConcurrentHashMap 和 HashMap 的相同點和不同點
相同之處:
- 都是數組 +鏈表+紅黑樹的數據結構(JDK8之後),所以基本操作的思想一致
- 都實現了Map接口,繼承了AbstractMap 操作類,所以方法大都相似,可以相互切換
不同之處: - ConcurrentHashMap 是線程安全的,多線程環境下,無需加鎖直接使用
- ConcurrentHashMap 多了轉移節點,主要用戶保證擴容時的線程安全
9. 擴容過程中,讀訪問能否訪問的到數據?怎麼實現的?
可以的。當數組在擴容的時候,會對當前操作節點進行判斷,如果當前節點還沒有被設置成fwd節點,那就可以進行讀寫操作,如果該節點已經被處理了,那麼當前線程也會加入到擴容的操作中去。
10.爲什麼超過沖突超過8纔將鏈表轉爲紅黑樹而不直接用紅黑樹?
- 默認使用鏈表, 鏈表佔用的內存更小
- 正常情況下,想要達到衝突爲8的機率非常小,如果真的發生了轉爲紅黑樹可以保證極端情況下的效率
11. ConcurrentHashMap 和HashMap的擴容有什麼不同?
- HashMap的擴容是創建一個新數組,將值直接放入新數組中,JDK7採用頭鏈接法,會出現死循環,JDK8採用尾鏈接法,不會造成死循環
- ConcurrentHashMap 擴容是從數組隊尾開始拷貝,拷貝槽點時會鎖住槽點,拷貝完成後將槽點設置爲轉移節點。所以槽點拷貝完成後將新數組賦值給容器
12. ConcurrentHashMap 是如何發現當前槽點正在擴容的?
ConcurrentHashMap 新增了一個節點類型,叫做轉移節點,當我們發現當前槽點是轉移節點時(轉移節點的 hash 值是 -1),即表示 Map 正在進行擴容.
13. 描述一下 CAS 算法在 ConcurrentHashMap 中的應用
- CAS是一種樂觀鎖,在執行操作時會判斷內存中的值是否和準備修改前獲取的值相同,如果相同,把新值賦值給對象,否則賦值失敗,整個過程都是原子性操作,無線程安全問題
- ConcurrentHashMap 的put操作是結合自旋用到了CAS,如果hash計算出的位置的槽點值爲空,就採用CAS+自旋進行賦值,如果賦值是檢查值爲空,就賦值,如果不爲空說明有其他線程先賦值了,放棄本次操作,進入下一輪循環
ConcurrentHashMap1.8 - 擴容詳解
關於jdk1.8中ConcurrentHashMap的方方面面
ConcurrentHashMap擴容?lastRun到底是個啥?
JDK1.8逐字逐句帶你理解ConcurrentHashMap