ConcuurentHashMap閱讀筆記
文章目錄
問題
1、ConcurrentHashMap 與 HashMap的數據結構是否一樣?
- 是的,一樣的,只是在node上增加了synchronized鎖,且採用分段鎖的思想
2、HashMap 在多線程環境下何時會發生併發安全問題?
- 修改操作會出現數據異常
3、ConcurrentHashMap 是怎麼解決併發問題的?
- 通過對節點加鎖的方式來實現的
4、ConcurrentHashMap 使用了哪些鎖?
- Synchronized 和 Condition
5、ConcurrentHashMap的擴容是怎麼進行的?
- 後序有空的話補充
6、ConcurrentHashMap 是否是強一致性的?
- 不是,只是最終一致性
7、ConcurrentHashMap 不能解決哪些問題?
- 後序有空的話補充
8、ConcurrentHashMap 中有哪些不常見的技術值得學習?
- 分段鎖(第一次接觸會讓你發現分治思想的神奇)
一、簡介
ConcurrentHashMap 是HashMap的線程安全版本,內部也是使用(數組+鏈表+紅黑樹)的結構來存儲元素的。
相比於同樣線程安全的hashTable來說,效率等各個方法都有極大的提高。
分段鎖:是一種鎖的設計思路,它細化了鎖的粒度,主要運用在ConcurrentHashMap中,實現高效的併發操作,當操作不需要更新整個數組時,就只鎖數組中的一項就可以了。
二、繼承關係圖
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-RwEIF5Zo-1578390921773)(C:\Users\86134\AppData\Local\Temp\1578363493264.png)]
三、存儲結構
數組+鏈表/紅黑樹,桶的節點大於等於8則樹化
四、源碼分析
內部類
Node(鏈表節點)
TreeNode(樹節點)
屬性
/** 元素存儲表(key,value 的node數組) */
transient volatile Node<K,V>[] table;
/**
-1,表示有線程正在進行初始化操作
-(1 + nThread),表示有n個線程正在一起擴容
0,默認值,後續在真正初始化的時候使用默認容量
> 0,初始化或擴容完成後下一次的擴容嗎門檻。
*/
private transient volatile int sizeCtl;
/**
這個的思想跟LongAdder類是一模一樣的
把數組的大小存儲根據不同的線程存儲到不同的段上(也就是分段的原理)
並且把有一個baseCount,優先更新baseCount,失敗則更新不同線程對應的段
這樣就可以保證最小化的衝突
*/
private transient volatile CounterCell[] counterCells;
/** counterCells是否在初始化或擴容 */
private transient volatile int cellsBusy;
/** 用於最初的鎖,如果更新失敗,纔會進入線程分段表 */
private transient volatile long baseCount;
構造
- 構造方法與
HashMap
對比是可以發現,沒有HashMap
中搞得threshold
和loadFactor
,而改用sizeCtl
來控制,而且只存儲了容量在裏面- -1,表示有線程正在進行初始化操作
- -(1 + nThread),表示有n個線程正在一起擴容
- 0,默認值,後續在真正初始化的時候使用默認容量
- > 0,初始化或擴容完成後下一次的擴容嗎門檻。
/** 構造方法一:無參構造 */
public ConcurrentHashMap() {}
/** 構造方法二:初始化容量大小 */
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
// 設置下次擴容的門檻
this.sizeCtl = cap;
}
/** 構造方法三:初始化容量爲16,並初始化map元素集合 */
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;// 默認16
// foreach 遍歷entrySet 執行putVal方法
putAll(m);
}
/** 構造方法四:無參構造 */
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 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
// 根據參數加載因子loadFactory 來確定容量大小,然後再推算
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
主要方法
1、put
-
如果桶數組未初始化,則初始化;
-
如果待插入的元素所在的桶爲null,則嘗試把此元素直接插入到桶的第一個位置;
-
如果正在擴容,則當前線程一起加入到擴容的過程中;
-
如果待插入的元素所在的桶不爲空且不在遷移,則鎖住這個桶(分段鎖);
-
如果當前桶中元素以鏈表方式存儲,則在鏈表中個尋找該元素或插入元素;
-
如果當前桶中元素以紅黑樹方式存儲,則在紅黑樹中尋找該元素或插入元素;
-
如果元素存在,直接返回舊值;
-
如果元素不存在,整個Map的元素個數加1,並檢查是否需要擴容
樹化:當桶的數量(binCount)大於等8,則開始樹化,如果本身是樹,永遠不會大於8,固定是2
鎖主要有:自選鎖、CAS、synchronized、分段鎖設計(桶)
/** 外部調用方法 */
public V put(K key, V value) {
return putVal(key, value, false);
}
/** 元素添加具體方法*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 都不可以爲null,不然拋 NullPointerException 異常
if (key == null || value == null) throw new NullPointerException();
// 計算key的hash值
int hash = spread(key.hashCode());
// 要插入的元素所在桶的元素個數
int binCount = 0;
// 死循環,結合CAS使用(如果cas失敗,則會重新取整個桶進行下面的流程)
for (Node<K,V>[] tab = table;;) {
// f : 當前索引i在tab數組中對應的桶
// n : 桶的數量
// i : 通過hash位運算計算出的索引值
// fh: 當前桶的hash值
Node<K,V> f; int n, i, fh;
// 如果桶數組沒有初始化或桶數量爲0,則初始化桶數組
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)))
// 如果CAS插入元素時,發現已有元素了,則進入下一次循環,重新操作
// 如果CAS插入元素成功,則break跳出循環,流程結束
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
// 如果要插入的元素所在的桶的第一個元素的hash是MOVED
// 則當前線程幫忙一起遷移元素
tab = helpTransfer(tab, f);
else {
// 通過上面的流程,說明桶數組已初始化、當前桶也不爲null、當前桶不在遷移
// 那麼就開始鎖住當前桶,然後開始進行邏輯
V oldVal = null;
synchronized (f) {
// 重新獲取當前key對應的桶,然後和上面獲取的桶進行比較
// 如果不相等,則重新循環(此時binCount 還是爲0,沒有變化的)
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;//桶中元素的數量,每次+1
for (Node<K,V> e = f;; ++binCount) {
// 當前循環的中節點的key
K ek;
// 每次都需要判斷的hash是否和插入元素的hash一致
// 用 == 或用equals比較爲true則找到了節點
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 節點中存在和插入元素相同的key
// 取出節點的值賦值給oldVal
oldVal = e.val;
if (!onlyIfAbsent)
// 賦予新值(onlyIfAbset=false)
e.val = value;
// 退出循環(進入if (binCount != 0)判斷)
break;
}
// 當前節點的key 和 插入元素的key不一致,則向下尋找
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;
// 桶中元素個數賦值爲2
binCount = 2;
// 調用紅黑樹的插入方法插入新元素
// 如果成功返回null
// 否則返回尋找到的節點
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
// 節點中存在和插入元素相同的key
// 賦予新值(onlyIfAbset=false)
oldVal = p.val;
if (!onlyIfAbsent)
// 賦予新值(onlyIfAbset=false)
p.val = value;
}
}
}
}
//如果binCount 不爲0,說明元素插入成功,或找到元素並進行了修改
if (binCount != 0) {
// 如果插入節點是位置大於等於8 則開始樹化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果插入的元素已經存在了,則直接返回 舊值 給調用方
if (oldVal != null)
return oldVal;
// 說明元素是新加元素,則開始進行元素數量+1 操作
break;
}
}
}
// 成功插入元素,元素個數加1,(是否要擴容在這裏)
addCount(1L, binCount);
// 成功插入元素,返回null
return null;
}
/** 桶數組初始化 */
private final Node<K,V>[] initTable()
/** 當前線程幫忙擴容 */
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f)
/** 紅黑樹的方式插入元素,插入成功返回null,否則返回找到的節點 */
final TreeNode<K,V> putTreeVal(int h, K k, V v)
/** 計算元素數量 */
private final void addCount(long x, int check)
2、initTable
- 使用CAS控制只有一個線程初始化桶數組
- sizeCtl在初始化後存儲的是擴容門檻
- 擴容門檻是寫死的,是桶數組大小的0.75倍,桶數組大小即map的容量,也就是最多存儲多少個元素
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 判斷是否未初始化
while ((tab = table) == null || tab.length == 0) {
// 小於0,說明正在初始化或擴容,就釋放cpu資源
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 如果把sizeCtl原子更新爲-1,則當前線程進行初始化
// 如果原子更新失敗則說明有其它線程先一步進入初始化了,則進入下一次循環
// 如果下一次循環時還沒初始化完成,則sizeCtl<0,讓出cpu資源
// 如果下一次循環更新完畢了,則table.length!=0退出循環
try {
// 再次檢查table是否爲null,防止ABA問題
if ((tab = table) == null || tab.length == 0) {
// 如果sc等於0,則使用默認16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 新建數組
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 把新建數組賦值給table
table = tab = nt;
// n - (n >>> 2) = n - n/4 = 0.75n
// 由此可見,裝在因子和擴容門檻都是寫死了的。
// 這有是沒有threshold和loadFactor屬性的原因
sc = n - (n >>> 2);
}
} finally {
// 把sc賦值給sizeCtl,這也是存儲的擴容門檻
sizeCtl = sc;
}
break;
}
}
return tab;
}
3、addCount
- 元素個數的存儲方式類似於Striped64類,存儲在不同的線段上,減少不同線程更新size會的衝突
- 計算元素個數把這些段的值及baseCount相加算出總的元素個數
- 正常情況下sizeCtl存儲着擴容的門檻,擴容門檻爲容量的0.75倍
- 擴容時sizeCtl高位16存儲擴容郵戳(resizeStamp),低16位存儲擴容線程數加1(1 + nThread)
- 其他線程添加元素後如果發生擴容,也會加入到擴容的行列中來。
private final void addCount(long x, int check) {
// as :線程鎖表counterCells
// b :baseCount的值
// s : 元素個數總計(第一次是baseCount更新後的值,其實這時候線程分段表本身就是null,所以他也能代表是元素個數)
CounterCell[] as; long b, s;
// 如果
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// 如果counterCell不爲null,
// 或者CAS更新baseCount失敗,所以直接加入到counterCells
// a : 當前線程hash對應的CounterCell
// v : 對應CounterCell的value
// m :counterCells的數量
CounterCell a; long v; int m;
boolean uncontended = true;// 默認無競爭
// 如果as表未null
// 或者長度爲 0
// 或者當前線程所在的段爲null
// 或者當前線程的段上加鎖失敗
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 強制增加數量(無論如何數量是一定要加上)
// 和Striped64.longAccumulate添加值是一樣的
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 計算元素個數
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 如果元素達到了擴容門檻,則進行擴容
// 注意,正常情況下sizeCtl存儲的是擴容門檻,即容量的0.75倍
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)
// 擴容完成了,退出循環
// 正常應該只會觸發nextTable == null 這個條件,
// 其他條件沒看出來何時觸發
break;
// 擴容未完成。則當前線程加入遷移元素中
// 並把擴容線程加 1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 這裏是觸發擴容的那個線程進入的地方
// sizeCtl 的高16位存儲着rs這個擴容郵戳
// sizeCtl 的低16位存儲擴容線程數加 1,即(1 + nThread)
// 《所以官方說的擴容時sizeCtl的值爲 -(1 + nThread)是錯誤的》
// 進入遷移元素
transfer(tab, null);
// 重新計算元素個數
s = sumCount();
}
}
}
4、helpTransfer
- 線程添加元素時發現正在擴容且當前元素所在的桶元素已經遷移完成了,則協助遷移其它桶的元素
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// 如果桶數組不爲null,並且當前桶第一個元素爲ForwardingNode類型,並且nextTab不爲空
// 說明當前桶已經遷移完畢了,纔去幫忙遷移其它桶的元素
// 擴容時會把舊桶的第一個元素置爲ForwardingNode,並讓其nextTab指向新桶數組
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
// sizeCtl < 0,說明正在擴容
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 擴容線程數加 1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 當前線程幫忙遷移元素
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
5、transfer
- 擴容時,容量變爲兩倍,並把部分元素遷移到其他桶中
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 將 length / 8 然後除以CPU核心數,如果得到的結果小於 16,那麼就是用個16
// 這裏的目的是讓每個CPU處理的桶一樣多,避免出現轉移任務不均勻的現象,如果桶較少的話,默認一個CPU(一個線程)處理16個桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 新的 table 尚未初始化
if (nextTab == null) { // initiating
try {
// 擴容兩倍
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
// 更新
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
// 擴容兩倍失敗(n << 1 溢出變爲負數了),直接用 int 最大值
sizeCtl = Integer.MAX_VALUE;
return;// 結束
}
// 更新成員變量
nextTable = nextTab;
// 更新轉移下標,就是 老的 tab 的 length
transferIndex = n;
}
int nextn = nextTab.length;
// 創建一個標識類用於佔位,當其他線程掃描到這個類的時候就會跳過
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 數組一層層推進的標識符
boolean advance = true;
// 擴容結束的表示符,true表示完成
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
// 每一個線程進入這裏,先獲取自己需要處理桶區間,第一次進入因爲--i,會直接跳到else if 中的,對nextIndex進行賦值操作
// 這裏設置了一個i = -1
// 如果當前線程可以向後推進;這個循環就是控制 i 遞減。同時每個線程都會進入這裏取得自己需要轉移的桶區間
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
// 防止在沒有成功處理一個桶的情況下缺進行了推進
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
// 防止在沒有成功處理一個桶的情況下卻進行了推進
advance = false;
}
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;
if (finishing) {// 如果完成擴容
nextTable = null; // 刪除成員變量
table = nextTab; // 更新table
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下標位置的變量,如果是 null ,就是用fwd佔位
else if ((f = tabAt(tab, i)) == null)
// 如果寫進fwd,則推進
advance = casTabAt(tab, i, null, fwd);
// 如果當前位置不是null。且hash值爲 -1,
// 說明其他線程已處理過這個桶,繼續推進
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 鎖住首節點
synchronized (f) {
// 二次判斷地址偏移量鎖指向位置是否與f對象相等
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// fg > 0 爲鏈表數據轉移
if (fh >= 0) {
// 首節點的hash
int runBit = fh & n;
Node<K,V> lastRun = f;//最後一個節點
// 這個地方跟hashMap不同,hashMap是直接推進到鏈表尾
// 這個地方的處理在於想保留鏈表後所有hash值計算相同的點,
// 這些點可以重複利用,不用重新new
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 。說明低位重用
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)
// 注意創建node接地那的最後一個參數ln指代的是next
// 也就是說,我們不再是從頭到尾節點,而是從節點開始向頭節點走
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);
// 講fwd佔位放入舊錶中
setTabAt(tab, i, fwd);
// 向前推進
advance = true;
}
// 如果是紅黑樹
else if (f instanceof TreeBin) {
// 如果第一個節點是樹節點
// 也是一樣,分化成兩顆樹
// 也是根據hash & n 爲0 放在低位樹中
// 不爲0 放在高位樹中
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
// 遍歷整棵樹,根據hash&n是否爲0分化成兩棵樹
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;
// 低位數的位置不變
setTabAt(nextTab, i, ln);
// 高位數的位置是原位置加n
setTabAt(nextTab, i + n, hn);
// 標記該桶已遷移
setTabAt(tab, i, fwd);
// avvance 爲true,返回上面進行--i操作
advance = true;
}
}
}
}
}
}
//參考閱讀:https://www.jianshu.com/p/aaf769fdbd20
補充
累死了。還補充個毛。耗損精力啊。。