HashMap是我們用的比較多的數據結構,但是它在高併發下面進行put操作時,很有可能會引起死循環,這主要是在它擴容的情況下,導致鏈表頭尾可能存在重複節點,而這時候解決的辦法有很多,如Hashtable和Collections.synchronizedMap(hashMap),但是這倆貨的性能是存在缺陷的,因爲都是鎖整個對象。
這時候ConcurrentHashMap出現了,他很好的彌補了HashMap的併發缺陷,也兼顧了上兩個方案的高性能讀寫。
Question :
- 它在高併發下是如何做到的?
- 如何做到高性能寫入?
- 如何避免HashMap的擴容引發的血案[多線程下擴容會出現鏈表死循環]?
相關概念介紹
// 數組節點 , 初始化是16
transient volatile Node<K,V>[] table;
// 默認爲null,擴容時新生成的數組,其大小爲原數組的兩倍。可以理解爲爲擴容所做的臨時變量,臨時用來做數據交換的,擴容完畢則設置爲null
private transient volatile Node<K,V>[] nextTable;
// 一個基礎計數器,用於統計ConcurrentHashMap的計算次數
private transient volatile long baseCount;
/* 默認爲0,用來控制table的初始化和擴容操作,具體應用在後續會體現出來。
-1 代表table正在初始化
-N 表示有N-1個線程正在進行擴容操作
其餘情況:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默認是table大小的0.75倍,居然用這個公式算0.75(n - (n >>> 2))。
*/
private transient volatile int sizeCtl;
// 擴容時候需要用到的下標計數值,需要通過cas去設置的下標值
private transient volatile int transferIndex;
put 方法
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 通過Hash算法得到要存入Key的HashCode碼
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,讓下一次循環繼續
tab = initTable();
// 判斷內存中的對象是否爲null,如果爲空則新創建一個鏈表,把該對象作爲首節點插入
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
}
// 表示正在擴容的情況下,這裏出現的場景是,正在擴容,將老的table數據遷移到新的table數據,而同時有線程在獲取老的數據裏面的值
else if ((fh = f.hash) == MOVED)
// 這裏據說是爲了未完成擴容的情況下,這裏會幫助另一個線程加速擴容
/**
這是一個協助擴容的方法。這個方法被調用的時候,當前ConcurrentHashMap一定已
經有了nextTable對象,首先拿到這個nextTable對象,調用transfer方法。回看上面的
transfer方法 可以看到,當本線程進入擴容方法的時候會直接進入複製階段。
/*
tab = helpTransfer(tab, f);
else {
// 能夠進入到這裏的情況有以下幾種:
// 1 它Hash到的下標鏈表已經有值了,有值了,也可能存在兩個條件
// 1.存在重複的Hash值,需要覆蓋,2.不存在重複的值,則需要將它添加到尾節點
V oldVal = null;
// 注意了,這裏使用了synchronized ,
//猜想是因爲f是node鏈表,這裏是爲了防止這個鏈表在更新時出現數據不一致的問題....
//這裏也就是在插入的時候會進行鏈表的鎖定,這時候就可以放心的對鏈表做操作了
synchronized (f) {
// 通過CAS去獲取內存中的node節點對象
if (tabAt(tab, i) == f) {
// fh是當前key的hashCode
if (fh >= 0) {
// 表示計數
binCount = 1;
// 下面是循環這個鏈表節點,取出鏈表中的hash碼與當前key做比較
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 判斷鏈表中的Hash碼是否存在,存在則替換,不存在則添加到尾節點
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 如果存在,則將老的值取出來,作爲返回出去的結果
oldVal = e.val;
// 在這個值爲false的情況下,進行替換,表示是否覆蓋
if (!onlyIfAbsent)
// 將新的值賦給這個鏈表
e.val = value;
break;
}
Node<K,V> pred = e;
//如果鏈表中不存在這個key相關的節點,則默認插入這個鏈表的尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 這裏會判斷當前節點是否是Tree節點,
// 這一種情況會出現在鏈表大小達到8個的時候,會將node轉化成TreeBin。
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;
}
// 初始化table的方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//
while ((tab = table) == null || tab.length == 0) {
// 如果當前sizeCtl標識小於0(-1表示正在初始化)時,則線程
if ((sc = sizeCtl) < 0)
// 表示讓出CPU,處於就緒狀態。
Thread.yield(); // lost initialization race; just spin
// compareAndSwapInt -> CAS 原子性操作,通過原子操作將當前表格設置爲初始化
//這個方法有四個參數,其中第一個參數爲需要改變的對象,第二個爲偏移量(即之前求出來的valueOffset的值),
//第三個參數爲期待的值(這裏默認爲0),第四個爲更新後的值(-1上面概念中提到-1表示table正在初始化)
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 這裏會先判斷table是否爲null,因爲害怕其他線程先一步已經創建好了.
if ((tab = table) == null || tab.length == 0) {
// 默認初始化table大小,DEFAULT_CAPACITY = 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 構建一個上面指定的數組大小
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 將這個變量賦給一個全局變量,就是爲了避免上面 if ((tab = table) == null)的情況
table = tab = nt;
// 這裏會設定一個閥值,就是當前的0.75,可以這麼理解
sc = n - (n >>> 2);
}
} finally {
// 初始化完成之後.將這個閥值賦給全局變量
sizeCtl = sc;
}
break;
}
}
// 返回創建的table
return tab;
}
// 下面 4 個原子性操作
// 獲取內存中的地址
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
//getObjectVolatile 獲取obj對象中offset偏移地址對應的object型field的值,支持volatile load語義。
// 第一個參數是讀取節點對象
// 第二個參數是內存中的偏移量,也就是說位置
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//compareAndSwapObject
/**
* 在obj的offset位置比較object field和期望的值,如果相同則更新。這個方法
* 的操作應該是原子的,因此提供了一種不可中斷的方式更新object field。
*
* @param obj the object containing the field to modify.
* 包含要修改field的對象
* @param offset the offset of the object field within <code>obj</code>.
* <code>obj</code>中object型field的偏移量
* @param expect the expected value of the field.
* 希望field中存在的值
* @param update the new value of the field if it equals <code>expect</code>.
* 如果期望值expect與field的當前值相同,設置filed的值爲這個新值
* @return true if the field was changed.
* 如果field的值被更改
*/
// public native boolean compareAndSwapObject(Object obj, long offset,Object expect, Object update);
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
// 這裏傳入的第一個參數 是數組table
// 第二個參數傳入的是數組的位置下標
// 第三個參數是節點本身對象
// 第四個是期望更新後的節點對象
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
擴容方法的實現 :
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;
// s 表示當前數組大小.sizeCtl 表示達到閥值大小也就是初次的值 12 ,一旦滿足擴容條件
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);
}
// 通過cas設置SIZECTL的值,一旦設置成功,則滿足下列方法
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 數據遷移
transfer(tab, null);
// 計算總數
s = sumCount();
}
}
}
// 數據遷移方法 , 也包括擴容
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; // subdivide range
// 表示擴容操作
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
// n << 1 可以理解爲當前數組長度的兩倍遞增
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 = 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
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 這裏能夠觸發的情況是在下兩個條件執行完成之後,會爲i賦值一個默認的
if (--i >= bound || finishing)
advance = false; // while不需要再循環了,已經得到了下標值了
// 這裏是表示已經到最後一個的標誌
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 通過CAS去爲這個TRANSFERINDEX變量賦值
// TRANSFERINDEX 擴容後的大小值
// nextBound
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
// 將下一個座標值賦i,然外面的for循環根據這個下標去table中遷移數據
i = nextIndex - 1;
// 停止while的循環
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 這裏有點繞.何時會滿足這個條件?
// 1. 當老數組全部數據遷移完畢之後,這時候會將finishing設置爲true
// 2.會執行一次數據檢查,就是說再遍歷一次.看是否還有沒有遷移的值,直到檢查完畢之後,則會滿足這個條件,
// 通俗一點來說,這個標記位表示所有遷移工作全部完成..
if (finishing) {
// 將這個臨時變量設置爲null,下一次擴容再用
nextTable = null;
// 將新的數組賦值給老的
table = nextTab;
// 這裏是設置新數組大小的閥值,比如擴容到32了,他的閥值是32 * 75% 則是擴容條件
// (n >>> 1) 理解爲 0.75 ,總的理解就是上面的,實際上是32 - 8 ;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 當執行到最後一個節點完成之後,將SIZECTL設置爲-1 表示正在初始化
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
// 這裏將i重新設置爲老數組的長度,是爲了檢查是否還有沒有需要提交的數據(PS:我也不是特別理解這一步的意義.. 重複檢查 ? )
i = n; // recheck before commit
}
}
// 獲取table中的[i]下標鏈表,如果該鏈表爲空,則給他賦予默認值
else if ((f = tabAt(tab, i)) == null)
// 如果獲取到的節點鏈表爲空的情況,那就好辦了,直接賦值爲null,
//新的數組也不用遷移 , 需要注意的是賦值的null對象是一個自定義的ForwardingNode節點
// 他使用這個節點的意義應該是能夠快速標識出目前正處於擴容階段
// 其他線程如果也在執行擴容的話,如果標識出該鏈表爲fwd類型的表示該鏈表已經遷移完成
advance = casTabAt(tab, i, null, fwd);
// 如果上面獲取到的鏈表的Hash碼爲-1,表示已經處理過
// 這裏就表示取出來的鏈表節點爲ForwardingNode節點,表示遷移完成
else if ((fh = f.hash) == MOVED)
// 這裏是爲了重讀檢查設置的,爲null的節點,不做任何處理,只是爲了檢查一下
advance = true; // already processed
else {
// 這裏開始遷移數據了.用的還是同步,防止鏈表出現更改的情況
synchronized (f) {
// 這裏還是獲取i的下標節點
if (tabAt(tab, i) == f) {
// 這倆變量是用來做數據遷移的
// ln表示不遷移的數據鏈表,hn表示遷移的數據鏈表
Node<K,V> ln, hn;
// hash碼不爲0的時候
if (fh >= 0) {
// 這裏會將你的hashcode與老的數組大小做一次運算
// 這裏的運算決定了你的數據是需要遷移
// 如果運算出來得到的值爲0表示不遷移,如果不等於0 則默認遷移到新的數組那邊去
// 舉例 : 運算得到 16 這時候 i 是 15 ,因爲不爲0表示遷移到 16+15 = 31 的數組下標中去
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 = p;
}
}
// 這裏就是爲0的表示不遷移,還是重新放入到當前下標i中
if (runBit == 0) {
ln = lastRun;
hn = null;
}
// 不爲0的時候
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;
}
}
}
}
}
}
// 計算當前數組中的總數
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
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插入值的時候,遇到失敗的情況下,通過自旋的方式,一直嘗試插入,直到成功爲止。
-
get方法裏爲什麼需要用tabAt方法去讀取table[i],而不是直接用table[i]?
雖然table是用volatile方式修飾的,在多線程的環境之下都能保持可見,但table是一個數組。 不能確保數組裏面的節點內容也是最新的,也可能出現CPU緩存或者副本的情況, 所以每次更新也是通過CAS去內存裏面直接更新,獲取也是直接從內存中直接獲取..
-
爲什麼擴容一定要按照2倍的方式?
這樣做的好處就是方便數據遷移,也就是說在該下標值中的鏈表只要劃分出一半的數據出去 (其實就是說通過Key的hashCode運算爲0的放入原來的位置,不等於0的劃分到當前下標+老的數組長度的位置), 不用做過多的複雜計算就能夠完成擴容。
-
高併發下擴容是如何實現的?
1. 在擴容的時候,會將當前鏈表進行鎖定,這樣可以避免HashMap中一旦滿足擴容條件,多個線程都會出現擴容競爭的情況, 而ConcurrentHashMap則是會讓另一個線程幫助加速擴容這方面來, 2. 爲了保證鏈表的一致性,採用了cas和synchronized進行加鎖的操作,保證每個鏈表都是原子性的操作. 3.在進行老的table複製到新的table的時候,老的table會將已經清空鏈表設置爲 ForwardingNode對象,很巧妙的實現了節點的併發移動。當多個線程同時擴容的時候, 只要發現有節點中有ForwardingNode對象表示正在擴容, 則會加入到幫助擴容裏面,而不是重新擴容,在已經擴容的基礎上,再去幫助未複製的節點進行擴容.
解答
-
如何做到高性能寫入?
1. 藉助使用CAS來實現非阻塞無鎖的特點來實現線程安全的高效插入 2. 基於鏈表的操作還是用了synchronized來保證線程安全,不過目前1.8的synchronized已經效率很高了. 3. 其實也就是引入分段的概念.高併發下不會鎖住整個table數組,而是單個鏈表的頭節點,來保證安全,
-
如何避免HashMap的擴容引發的血案?
1. 採用synchronized加鎖來保證了鏈表節點的線程安全操作 2. 併發下擴容,多個線程擴容,並不會重複的擴容。只會幫助它繼續未完成擴容的節點,例如helpTransfer()方法。 它利用ForwardingNode節點來標識當前鏈表是否已經遷移完畢,其他線程可以根據這個節點來幫助加速擴容。