人只應當忘卻自己而愛別人,這樣人才能安靜、幸福高尚。
——托爾斯泰《安娜•卡列尼娜》
0 前言
線程安全的 Map - ConcurrentHashMap,讓我們一起研究和 HashMap 相比有何差異,爲何能保證線程安全呢.
1 繼承體系
與 HashMap 很相似,數組、鏈表結構幾乎相同,都實現了 Map 接口,繼承了 AbstractMap 抽象類,大多數的方法也都是相同的,ConcurrentHashMap 幾乎包含 HashMap所有方法.
2 屬性
-
bin數組.第一次插入時才延遲初始化.大小始終是2的冪.由迭代器直接訪問.
-
下一個要用的 table;僅在擴容時非null
-
基本計數器值,主要在沒有爭用時使用,也用作table初始化競爭期間的反饋.通過CAS更新
-
table 初始化和擴容的控制
如果爲負,則表將被初始化或擴容:
-1用於初始化
-N 活動的擴容線程數
否則,當table爲null時,保留創建時要使用的初始表大小,或者默認爲0.
初始化後,保留下一個要擴容表的元素計數值.
-
擴容時要拆分的下一個表索引(加1)
-
擴容和/或創建 CounterCell 時使用的自旋鎖(通過CAS鎖定)
-
Table of counter cells。 如果爲非null,則大小爲2的冪.
- Node節點:保存key,value及key的hash值的數據結構,其中value和next都用volatile修飾,保證可見性
-
一個特殊的Node節點,轉移節點的 hash 值都是 MOVED,-1.其中存儲nextTable的引用.在transfer期間插入bin head的節點.只有table發生擴容的時候,ForwardingNode纔會發揮作用,作爲一個佔位符放在table中表示當前節點爲null或則已經被移動,
3 構造方法
3.1 無參
-
使用默認的初始表大小(16)創建一個新的空map
3.2 有參
-
創建一個新的空map,其初始表大小可容納指定數量的元素,而無需動態調整大小。
-創建一個與給定map具有相同映射的新map
注意 sizeCtl 會暫先維護一個2的冪次方的值的容量.
實例化ConcurrentHashMap時帶參數時,會根據參數調整table的大小,假設參數爲100,最終會調整成256,確保table的大小總是2的冪次方
tableSizeFor
-
對於給定的所需容量,返回2的冪的表大小
table 的延遲初始化
ConcurrentHashMap在構造函數中只會初始化sizeCtl值,並不會直接初始化table,而是延緩到第一次put操作table初始化.但put是可以併發執行的,是如何保證 table 只初始化一次呢?
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 進入自旋
while ((tab = table) == null || tab.length == 0) {
// 若某線程發現sizeCtl<0,意味着其他線程正在初始化,當前線程讓出CPU時間片
if ((sc = sizeCtl) < 0)
Thread.yield(); // 失去初始化的競爭機會; 直接自旋
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 有可能執行至此時,table 已經非空,所以做雙重檢驗
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;
}
執行第一次put操作的線程會執行Unsafe.compareAndSwapInt方法修改sizeCtl爲-1,有且只有一個線程能夠修改成功,而其它線程只能通過Thread.yield()讓出CPU時間片等待table初始化完成。
4 put
table已經初始化完成,put操作採用CAS+synchronized實現併發插入或更新操作.
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 計算hash
int hash = spread(key.hashCode());
int binCount = 0;
// 自旋保證可以新增成功
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// step1. table 爲 null或空時進行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// step 2. 若當前數組索引無值,直接創建
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// CAS 在索引 i 處創建新的節點,當索引 i 爲 null 時,即能創建成功,結束循環,否則繼續自旋
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// step3. 若當前桶爲轉移節點,表明該桶的點正在擴容,一直等待擴容完成
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// step4. 當前索引位置有值
else {
V oldVal = null;
// 鎖定當前槽點,保證只會有一個線程能對槽點進行修改
synchronized (f) {
// 這裏再次判斷 i 位置數據有無被修改
// binCount 被賦值,說明走到了修改表的過程
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;
}
}
}
// 紅黑樹,這裏沒有使用 TreeNode,使用的是 TreeBin,TreeNode 只是紅黑樹的一個節點
// TreeBin 持有紅黑樹的引用,並且會對其加鎖,保證其操作的線程安全
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 滿足if的話,把老的值給oldVal
// 在putTreeVal方法裏面,在給紅黑樹重新着色旋轉的時候
// 會鎖住紅黑樹的根節點
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// binCount不爲空,並且 oldVal 有值的情況,說明已新增成功
if (binCount != 0) {
// 鏈表是否需要轉化成紅黑樹
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
// 槽點已經上鎖,只有在紅黑樹或者鏈表新增失敗的時候
// 纔會走到這裏,這兩者新增都是自旋的,幾乎不會失敗
break;
}
}
}
// step5. check 容器是否需要擴容,如果需要去擴容,調用 transfer 方法擴容
// 如果已經在擴容中了,check有無完成
addCount(1L, binCount);
return null;
}
4.2 執行流程
- 若數組空,則初始化,完成之後,轉2
- 計算當前桶位是否有值
- 無,則 CAS 創建,失敗後繼續自旋,直到成功
- 有,轉3
- 判斷桶位是否爲轉移節點(擴容ing)
- 是,則一直自旋等待擴容完成,之後再新增
- 否,轉4
- 桶位有值,對當前桶位加synchronize鎖
- 鏈表,新增節點到鏈尾
- 紅黑樹,紅黑樹版方法新增
- 新增完成之後,檢驗是否需要擴容
通過自旋 + CAS + synchronize 鎖三板斧的實現很巧妙,給我們設計併發代碼提供了最佳實踐!
5 transfer - 擴容
在 put 方法最後檢查是否需要擴容,從 put 方法的 addCount 方法進入transfer 方法.
主要就是新建新的空數組,然後移動拷貝每個元素到新數組.
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
// 如果新數組爲空,初始化,大小爲原數組的兩倍,n << 1
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
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
// 自旋,i 值會從原數組的最大值遞減到 0
for (int i = 0, bound = 0;;) {
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;
}
// 每次減少 i 的值
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// if 任意條件滿足說明拷貝結束了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 拷貝結束,直接賦值,因爲每次拷貝完一個節點,都在原數組上放轉移節點,所以拷貝完成的節點的數據一定不會再發生變化
// 原數組發現是轉移節點,是不會操作的,會一直等待轉移節點消失之後在進行操作
// 也就是說數組節點一旦被標記爲轉移節點,是不會再發生任何變動的,所以不會有任何線程安全的問題
// 所以此處直接賦值,沒有任何問題。
if (finishing) {
nextTable = null;
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
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
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) {
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;
}
}
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);
// 在老數組位置上放上 ForwardingNode 節點
// put 時,發現是 ForwardingNode 節點,就不會再動這個節點的數據了
setTabAt(tab, i, fwd);
advance = true;
}
// 紅黑樹的拷貝
else if (f instanceof TreeBin) {
// 紅黑樹的拷貝工作,同 HashMap 的內容,代碼忽略
...
// 在老數組位置上放上 ForwardingNode 節點
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
執行流程
- 首先把原數組的值全部拷貝到擴容之後的新數組,先從數組的隊尾開始拷貝
- 拷貝數組的槽點時,先把原數組槽點鎖住,成功拷貝到新數組時,把原數組槽點賦值爲轉移節點
- 這時如果有新數據正好需要 put 到該槽點時,發現槽點爲轉移節點,就會一直等待,所以在擴容完成之前,該槽點對應的數據是不會發生變化的
- 從數組的尾部拷貝到頭部,每拷貝成功一次,就把原數組中的節點設置成轉移節點
直到所有數組數據都拷貝到新數組時,直接把新數組整個賦值給數組容器,拷貝完成。
6 總結
ConcurrentHashMap 作爲一個併發 map,是面試必問點,也是工作中必須掌握的併發容器.