Java ConcurrentHashMap 高併發安全實現原理解析
本文首發於 vivo網際網路技術 微信公眾號
連結:https://mp.weixin.qq.com/s/4sz6sTPvBigR_1g8piFxug
作者:vivo遊戲技術團隊
一、概述
ConcurrentHashMap (以下簡稱C13Map) 是併發程式設計出場率最高的資料結構之一,大量的併發CASE背後都有C13Map的支援,同時也是JUC包中程式碼量最大的元件(6000多行),自JDK8開始Oracle對其進行了大量優化工作。
本文從 HashMap 的基礎知識開始,嘗試逐一分析C13Map中各個元件的實現和安全性保證。
二、HashMap基礎知識
分析C13MAP前,需要了解以下的HashMap知識或者約定:
- 雜湊表的長度永遠都是2的冪次方,原因是hashcode%tableSize==hashcode&(tableSize-1),也就是雜湊槽位的確定可以用一次與運算來替代取餘運算。
- 會對hashcode呼叫若干次擾動函式,將高16位與低16位做異或運算,因為高16位的隨機性更強。
- 當表中的元素總數超過tableSize * 0.75時,雜湊表會發生擴容操作,每次擴容的tableSize是原先的兩倍。
- 下文提到的槽位(bucket)、雜湊分桶、BIN均表示同一個概念,即雜湊table上的某一列。
- 舊錶在做搬運時i槽位的node可以根據其雜湊值的第tableSize位的bit決定在新表上的槽位是i還是i+tableSize。
- 每個槽位上有可能會出現雜湊衝突,在未達到某個閾值時它是一個連結串列結構,達到閾值後會升級到紅黑樹結構。
- HashMap本身並非為多執行緒環境設計,永遠不要嘗試在併發環境下直接使用HashMap,C13Map不存在這個安全問題。
三、C13Map的欄位定義
C13Map的欄位定義
//最大容量 private static final int MAXIMUM_CAPACITY = 1 << 30; //預設初始容量 private static final int DEFAULT_CAPACITY = 16; //陣列的最大容量,防止丟擲OOM static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; //最大並行度,僅用於相容JDK1.7以前版本 private static final int DEFAULT_CONCURRENCY_LEVEL = 16; //擴容因子 private static final float LOAD_FACTOR = 0.75f; //連結串列轉紅黑樹的閾值 static final int TREEIFY_THRESHOLD = 8; //紅黑樹退化閾值 static final int UNTREEIFY_THRESHOLD = 6; //連結串列轉紅黑樹的最小總量 static final int MIN_TREEIFY_CAPACITY = 64; //擴容搬運時批量搬運的最小槽位數 private static final int MIN_TRANSFER_STRIDE = 16; //當前待擴容table的郵戳位,通常是高16位 private static final int RESIZE_STAMP_BITS = 16; //同時搬運的執行緒數自增的最大值 private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; //搬運執行緒數的標識位,通常是低16位 private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; static final int MOVED = -1; // 說明是forwardingNode static final int TREEBIN = -2; // 紅黑樹 static final int RESERVED = -3; // 原子計算的佔位Node static final int HASH_BITS = 0x7fffffff; // 保證hashcode擾動計算結果為正數 //當前雜湊表 transient volatile Node<K,V>[] table; //下一個雜湊表 private transient volatile Node<K,V>[] nextTable; //計數的基準值 private transient volatile long baseCount; //控制變數,不同場景有不同用途,參考下文 private transient volatile int sizeCtl; //併發搬運過程中CAS獲取區段的下限值 private transient volatile int transferIndex; //計數cell初始化或者擴容時基於此欄位使用自旋鎖 private transient volatile int cellsBusy; //加速多核CPU計數的cell陣列 private transient volatile CounterCell[] counterCells;
四、安全操作Node<K,V>陣列
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) { return (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE); } static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { return U.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v); } static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) { U.putReferenceRelease(tab, ((long)i << ASHIFT) + ABASE, v); }
對Node<K,V>[] 上任意一個index的讀取和寫入都使用了Unsafe輔助類,table本身是volatile型別的並不能保證其下的每個元素的記憶體語義也是volatile型別;
需要藉助於Unsafe來保證Node<K,V>[]元素的“讀/寫/CAS”操作在多核併發環境下的原子或者可見性。
五、讀操作get為什麼是執行緒安全的
首先需要明確的是,C13Map的讀操作一般是不加鎖的(TreeBin的讀寫鎖除外),而讀操作與寫操作有可能並行;可以保證的是,因為C13Map的寫操作都要獲取bin頭部的syncronized互斥鎖,能保證最多隻有一個執行緒在做更新,這其實是一個單執行緒寫、多執行緒讀的併發安全性的問題。
C13Map的get方法
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; //執行擾動函式 int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }
1、如果當前雜湊表table為null
雜湊表未初始化或者正在初始化未完成,直接返回null;雖然line5和line18之間其它執行緒可能經歷了千山萬水,至少在判斷tab==null的時間點key肯定是不存在的,返回null符合某一時刻的客觀事實。
2、如果讀取的bin頭節點為null
說明該槽位尚未有節點,直接返回null。
3、如果讀取的bin是一個連結串列
說明頭節點是個普通Node。
(1)如果正在發生連結串列向紅黑樹的treeify工作,因為treeify本身並不破壞舊的連結串列bin的結構,只是在全部treeify完成後將頭節點一次性替換為新建立的TreeBin,可以放心讀取。
(2)如果正在發生resize且當前bin正在被transfer,因為transfer本身並不破壞舊的連結串列bin的結構,只是在全部transfer完成後將頭節點一次性替換為ForwardingNode,可以放心讀取。
(3)如果其它執行緒正在操作連結串列,在當前執行緒遍歷連結串列的任意一個時間點,都有可能同時在發生add/replace/remove操作。
- 如果是add操作,因為連結串列的節點新增從JDK8以後都採用了後入式,無非是多遍歷或者少遍歷一個tailNode。
- 如果是remove操作,存在遍歷到某個Node時,正好有其它執行緒將其remove,導致其孤立於整個連結串列之外;但因為其next引用未發生變更,整個連結串列並沒有斷開,還是可以照常遍歷連結串列直到tailNode。
- 如果是replace操作,連結串列的結構未變,只是某個Node的value發生了變化,沒有安全問題。
結論:對於連結串列這種線性資料結構,單執行緒寫且插入操作保證是後入式的前提下,併發讀取是安全的;不會存在誤讀、連結串列斷開導致的漏讀、讀到環狀連結串列等問題。
4、如果讀取的bin是一個紅黑樹
說明頭節點是個TreeBin節點。
(1)如果正在發生紅黑樹向連結串列的untreeify操作,因為untreeify本身並不破壞舊的紅黑樹結構,只是在全部untreeify完成後將頭節點一次性替換為新建立的普通Node,可以放心讀取。
(2)如果正在發生resize且當前bin正在被transfer,因為transfer本身並不破壞舊的紅黑樹結構,只是在全部transfer完成後將頭節點一次性替換為ForwardingNode,可以放心讀取。
(3)如果其他執行緒在操作紅黑樹,在當前執行緒遍歷紅黑樹的任意一個時間點,都可能有單個的其它執行緒發生add/replace/remove/紅黑樹的翻轉等操作,參考下面的紅黑樹的讀寫鎖實現。
TreeBin中的讀寫鎖實現
TreeNode<K,V> root; volatile TreeNode<K,V> first; volatile Thread waiter; volatile int lockState; // values for lockState static final int WRITER = 1; // set while holding write lock static final int WAITER = 2; // set when waiting for write lock static final int READER = 4; // increment value for setting read lock private final void lockRoot() { //如果一次性獲取寫鎖失敗,進入contendedLock迴圈體,迴圈獲取寫鎖或者休眠等待 if (!U.compareAndSetInt(this, LOCKSTATE, 0, WRITER)) contendedLock(); // offload to separate method } private final void unlockRoot() { lockState = 0; } //對紅黑樹加互斥鎖,也就是寫鎖 private final void contendedLock() { boolean waiting = false; for (int s;;) { //如果lockState除了第二位外其它位上都為0,表示紅黑樹當前既沒有上讀鎖,又沒有上寫鎖,僅有可能存在waiter,可以嘗試直接獲取寫鎖 if (((s = lockState) & ~WAITER) == 0) { if (U.compareAndSetInt(this, LOCKSTATE, s, WRITER)) { if (waiting) waiter = null; return; } } //如果lockState第二位是0,表示當前沒有執行緒在等待寫鎖 else if ((s & WAITER) == 0) { //將lockState的第二位設定為1,相當於打上了waiter的標記,表示有執行緒在等待寫鎖 if (U.compareAndSetInt(this, LOCKSTATE, s, s | WAITER)) { waiting = true; waiter = Thread.currentThread(); } } //休眠當前執行緒 else if (waiting) LockSupport.park(this); } } //查詢紅黑樹中的某個節點 final Node<K,V> find(int h, Object k) { if (k != null) { for (Node<K,V> e = first; e != null; ) { int s; K ek; //如果當前有waiter或者有寫鎖,走線性檢索,因為紅黑樹雖然替代了連結串列,但其內部依然保留了連結串列的結構,雖然連結串列的查詢效能一般,但根據先前的分析其讀取的安全性有保證。 //發現有寫鎖改走線性檢索,是為了避免等待寫鎖釋放花去太久時間; 而發現有waiter改走線性檢索,是為了避免讀鎖疊加的太多,導致寫鎖執行緒需要等待太長的時間; 本質上都是為了減少讀寫碰撞 //線性遍歷的過程中,每遍歷到下一個節點都做一次判斷,一旦發現鎖競爭的可能性減少就改走tree檢索以提高效能 if (((s = lockState) & (WAITER|WRITER)) != 0) { if (e.hash == h && ((ek = e.key) == k || (ek != null && k.equals(ek)))) return e; e = e.next; } //對紅黑樹加共享鎖,也就是讀鎖,CAS一次性增加4,也就是增加的只是3~32位 else if (U.compareAndSetInt(this, LOCKSTATE, s, s + READER)) { TreeNode<K,V> r, p; try { p = ((r = root) == null ? null : r.findTreeNode(h, k, null)); } finally { Thread w; //釋放讀鎖,如果釋放完畢且有waiter,則將其喚醒 if (U.getAndAddInt(this, LOCKSTATE, -READER) == (READER|WAITER) && (w = waiter) != null) LockSupport.unpark(w); } return p; } } } return null; } //更新紅黑樹中的某個節點 final TreeNode<K,V> putTreeVal(int h, K k, V v) { Class<?> kc = null; boolean searched = false; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk; //...省略處理紅黑樹資料結構的程式碼若干 else { //寫操作前加互斥鎖 lockRoot(); try { root = balanceInsertion(root, x); } finally { //釋放互斥鎖 unlockRoot(); } } break; } } assert checkInvariants(root); return null; } }
紅黑樹內建了一套讀寫鎖的邏輯,其內部定義了32位的int型變數lockState,第1位是寫鎖標誌位,第2位是寫鎖等待標誌位,從3~32位則是共享鎖標誌位。
讀寫操作是互斥的,允許多個執行緒同時讀取,但不允許讀寫操作並行,同一時刻只允許一個執行緒進行寫操作;這樣任意時間點讀取的都是一個合法的紅黑樹,整體上是安全的。
有的同學會產生疑惑,寫鎖釋放時為何沒有將waiter喚醒的操作呢?是否有可能A執行緒進入了等待區,B執行緒獲取了寫鎖,釋放寫鎖時僅做了lockState=0的操作。
那麼A執行緒是否就沒有機會被喚醒了,只有等待下一個讀鎖釋放時的喚醒了呢 ?
顯然這種情況違背常理,C13Map不會出現這樣的疏漏,再進一步觀察,紅黑樹的變更操作的外圍,也就是在putValue/replaceNode那一層,都是對BIN的頭節點加了synchornized互斥鎖的,同一時刻只能有一個寫執行緒進入TreeBin的方法範圍內,當寫執行緒發現當前waiter不為空,其實此waiter只能是當前執行緒自己,可以放心的獲取寫鎖,不用擔心無法被喚醒的問題。
TreeBin在find讀操作檢索時,在linearSearch(線性檢索)和treeSearch(樹檢索)間做了折衷,前者效能差但併發安全,後者效能佳但要做併發控制,可能導致鎖競爭;設計者使用線性檢索來儘量避免讀寫碰撞導致的鎖競爭,但評估到race condition已消失時,又立即趨向於改用樹檢索來提高效能,在安全和效能之間做到了極佳的平衡。具體的折衷策略請參考find方法及註釋。
由於有線性檢索這樣一個抄底方案,以及入口處bin頭節點的synchornized機制,保證了進入到TreeBin整體程式碼塊的寫執行緒只有一個;TreeBin中讀寫鎖的整體設計與ReentrantReadWriteLock相比還是簡單了不少,比如並未定義用於存放待喚醒執行緒的threadQueue,以及讀執行緒僅會自旋而不會阻塞等等, 可以看做是特定條件下ReadWriteLock的簡化版本。
5、如果讀取的bin是一個ForwardingNode
說明當前bin已遷移,呼叫其find方法到nextTable讀取資料。
forwardingNode的find方法
static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; ForwardingNode(Node<K,V>[] tab) { super(MOVED, null, null); this.nextTable = tab; } //遞迴檢索雜湊錶鏈 Node<K,V> find(int h, Object k) { // loop to avoid arbitrarily deep recursion on forwarding nodes outer: for (Node<K,V>[] tab = nextTable;;) { Node<K,V> e; int n; if (k == null || tab == null || (n = tab.length) == 0 || (e = tabAt(tab, (n - 1) & h)) == null) return null; for (;;) { int eh; K ek; if ((eh = e.hash) == h && ((ek = e.key) == k || (ek != null && k.equals(ek)))) return e; if (eh < 0) { if (e instanceof ForwardingNode) { tab = ((ForwardingNode<K,V>)e).nextTable; continue outer; } else return e.find(h, k); } if ((e = e.next) == null) return null; } } } }
ForwardingNode中儲存了nextTable的引用,會轉向下一個雜湊表進行檢索,但並不能保證nextTable就一定是currentTable,因為在高併發插入的情況下,極短時間內就可以導致雜湊表的多次擴容,記憶體中極有可能駐留一條雜湊錶鏈,彼此以bin的頭節點上的ForwardingNode相連,執行緒剛讀取時拿到的是table1,遍歷時卻有可能經歷了雜湊表的鏈條。
eh<0有三種情況:
- 如果是ForwardingNode繼續遍歷下一個雜湊表。
- 如果是TreeBin,呼叫其find方法進入TreeBin讀寫鎖的保護區讀取資料。
- 如果是ReserveNode,說明當前有compute計算中,整條bin還是一個空結構,直接返回null。
6、如果讀取的bin是一個ReserveNode
ReserveNode用於compute/computeIfAbsent原子計算的方法,在BIN的頭節點為null且計算尚未完成時,先在bin的頭節點打上一個ReserveNode的佔位標記。
讀操作發現ReserveNode直接返回null,寫操作會因為爭奪ReserveNode的互斥鎖而進入阻塞態,在compute完成後被喚醒後迴圈重試。
六、寫操作putValue/replaceNode為什麼是執行緒安全的
典型的程式設計正規化如下:
C13Map的putValue方法
Node<K,V>[] tab = table; //將堆中的table變數賦給執行緒堆疊中的區域性變數 Node f = tabAt(tab, i ); if(f==null){ //當前槽位沒有頭節點,直接CAS寫入 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; }else if(f.hash == MOVED){ //加入協助搬執行列 helpTransfer(tab,f); } //不是forwardingNode else if(f.hash != MOVED){ //先鎖住I槽位上的頭節點 synchronized (f) { //再doubleCheck看此槽位上的頭節點是否還是f if (tabAt(tab, i) == f) { ...各種寫操作 } } }
1、當前槽位如果頭節點為null時,直接CAS寫入
有人也許會質疑,如果寫入時resize操作已完成,發生了table向nextTable的轉變,是否會存在寫入的是舊錶的bin導致資料丟失的可能 ?
這種可能性是不存在的,因為一個table在resize完成後所有的BIN都會被打上ForwardingNode的標記,可以形象的理解為所有槽位上都插滿了紅旗,而此處在CAS時的compare的變數null,能夠保證至少在CAS原子操作發生的時間點table並未發生變更。
2、當前槽位如果頭節點不為null
這裡採用了一個小技巧:先鎖住I槽位上的頭節點,進入同步程式碼塊後,再doubleCheck看此槽位上的頭節點是否有變化。
進入同步塊後還需要doubleCheck的原因:雖然一開始獲取到的頭節點f並非ForwardingNode,但在獲取到f的同步鎖之前,可能有其它執行緒提前獲取了f的同步鎖並完成了transfer工作,並將I槽位上的頭節點標記為ForwardingNode,此時的f就成了一個過時的bin的頭節點。
然而因為標記操作與transfer作為一個整體在同步的程式碼塊中執行,如果doubleCheck的結果是此槽位上的頭節點還是f,則表明至少在當前時間點該槽位還沒有被transfer到新表(假如當前有transfer in progress的話),可以放心的對該bin進行put/remove/replace等寫操作。
只要未發生transfer或者treeify操作,連結串列的新增操作都是採取後入式,頭節點一旦確定不會輕易改變,這種後入式的更新方式保證了鎖定頭節點就等於鎖住了整個bin。
如果不作doubleCheck判斷,則有可能當前槽位已被transfer,寫入的還是舊錶的BIN,從而導致寫入資料的丟失;也有可能在獲取到f的同步鎖之前,其它執行緒對該BIN做了treeify操作,並將頭節點替換成了TreeBin, 導致寫入的是舊的連結串列,而非新的紅黑樹;
3、doubleCheck是否有ABA問題
也許有人會質疑,如果有其它執行緒提前對當前bin進行了的remove/put的操作,引入了新的頭節點,並且恰好發生了JVM的記憶體釋放和重新分配,導致新的Node的引用地址恰好跟舊的相同,也就是存在所謂的ABA問題。
這個可以通過反證法來推翻,在帶有GC機制的語言環境下通常不會發生ABA問題,因為當前執行緒包含了對頭節點f的引用,當前執行緒並未消亡,不可能存在f節點的記憶體被GC回收的可能性。
還有人會質疑,如果在寫入過程中主雜湊表發生了變化,是否可能寫入的是舊錶的bin導致資料丟失,這個也可以通過反證法來推翻,因為table向nextTable的轉化(也就是將resize後的新雜湊表正式commit)只有在所有的槽位都已經transfer成功後才會進行,只要有一個bin未transfer成功,則說明當前的table未發生變化,在當前的時間點可以放心的向table的bin內寫入資料。
4、如何操作才安全
可以總結出規律,在對table的槽位成功進行了CAS操作且compare值為null,或者對槽位的非forwardingNode的頭節點加鎖後,doubleCheck頭節點未發生變化,對bin的寫操作都是安全的。
七、原子計算相關方法
原子計算主要包括:computeIfAbsent、computeIfPresent、compute、merge四個方法。
1、幾個方法的比較
主要區別如下:
(1)computeIfAbsent只會在判斷到key不存在時才會插入,判空與插入是一個原子操作,提供的FunctionalInterface是一個二元的Function, 接受key引數,返回value結果;如果計算結果為null則不做插入。
(2)computeIfPresent只會在判讀單到Key非空時才會做更新,判斷非空與插入是一個原子操作,提供的FunctionalInterface是一個三元的BiFunction,接受key,value兩個引數,返回新的value結果;如果新的value為null則刪除key對應節點。
(3)compute則不加key是否存在的限制,提供的FunctionalInterface是一個三元的BiFunction,接受key,value兩個引數,返回新的value結果;如果舊的value不存在則以null替代進行計算;如果新的value為null則保證key對應節點不會存在。
(4)merge不加key是否存在的限制,提供的FunctionalInterface是一個三元的BiFunction,接受oldValue, newVALUE兩個引數,返回merge後的value;如果舊的value不存在,直接以newVALUE作為最終結果,存在則返回merge後的結果;如果最終結果為null,則保證key對應節點不會存在。
2、何時會使用ReserveNode佔位
如果目標bin的頭節點為null,需要寫入的話有兩種手段:一種是生成好新的節點r後使用casTabAt(tab, i, null, r)原子操作,因為compare的值為null可以保證併發的安全;
另外一種方式是建立一個佔位的ReserveNode,鎖住該節點並將其CAS設定到bin的頭節點,再進行進一步的原子計算操作;這兩種辦法都有可能在CAS的時候失敗,需要自旋反覆嘗試。
(1)為什麼只有computeIfAbsent/compute方法使用佔位符的方式
computeIfPresent只有在BIN結構非空的情況下才會展開原子計算,自然不存在需要ReserveNode佔位的情況;鎖住已有的頭節點即可。
computeIfAbsent/compute方法在BIN結構為空時,需要展開Function或者BiFunction的運算,這個操作是外部引入的需要耗時多久無法準確評估;這種情況下如果採用先計算,再casTabAt(tab, i, null, r)的方式,如果有其它執行緒提前更新了這個BIN,那麼就需要重新鎖定新加入的頭節點,並重復一次原子計算(C13Map無法幫你快取上次計算的結果,因為計算的入參有可能會變化),這個開銷是比較大的。
而使用ReserveNode佔位的方式無需等到原子計算出結果,可以第一時間先搶佔BIN的所有權,使其他併發的寫執行緒阻塞。
(2)merge方法為何不需要佔位
原因是如果BIN結構為空時,根據merge的處理策略,老的value為空則直接使用新的value替代,這樣就省去了BiFunction中新老value進行merge的計算,這個消耗幾乎是沒有的;因此可以使用casTabAt(tab, i, null, r)的方式直接修改,避免了使用ReserveNode佔位,鎖定該佔位ReserveNode後再進行CAS修改的兩次CAS無謂的開銷。
C13Map的compute方法
public V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) { if (key == null || remappingFunction == null) throw new nullPointerException(); int h = spread(key.hashCode()); V val = null; int delta = 0; 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) & h)) == null) { //建立佔位Node Node<K, V> r = new ReservationNode<K, V>(); //先鎖定該佔位Node synchronized (r) { //將其設定到BIN的頭節點 if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K, V> node = null; try { //開始原子計算 if ((val = remappingFunction.apply(key, null)) != null) { delta = 1; node = new Node<K, V>(h, key, val, null); } } finally { //設定計算後的最終節點 setTabAt(tab, i, node); } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { //此處省略對普通連結串列的變更操作 } else if (f instanceof TreeBin) { //此處省略對紅黑樹的變更操作 } } } } } if (delta != 0) addCount((long) delta, binCount); return val; }
3、如何保證原子性
computeIfAbsent/computeIfPresent中判空與計算是原子操作,根據上述分析主要是通過casTabAt(tab, i, null, r)原子操作,或者使用ReserveNode佔位並鎖定的方式,或者鎖住bin的頭節點的方式來實現的。
也就是說整個bin一直處於鎖定狀態,在獲取到目標KEY的value是否為空以後,其它執行緒無法變更目標KEY的值,判空與計算自然是原子的。
而casTabAt(tab, i, null, r)是由硬體層面的原子指令來保證的,能夠保證同一個記憶體區域在compare和set操作之間不會有任何其它指令對其進行變更。
八、resize過程中的併發transfer
C13Map中總共有三處地方會觸發transfer方法的呼叫,分別是addCount、tryPresize、helpTransfer三個函式。
- addCount用於寫操作完成後檢驗元素數量,如果超過了sizeCtl中的閾值,則觸發resize擴容和舊錶向新表的transfer。
- tryPresize是putAll一次性插入一個集合前的自檢,如果集合數目較大,則預先觸發一次resize擴容和舊錶向新表的transfer。
- helpTransfer是寫操作過程中發現bin的頭節點是ForwardingNode, 則呼叫helpTransfer加入協助搬運的行列。
1、開始transfer前的檢查工作
以addCount中的檢查邏輯為例:
addCount中的transfer檢查
Node<K, V>[] tab, nt; int n, sc; //當前的tableSize已經超過sizeCtl閾值,且小於最大值 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)) //尚未搬運,當前執行緒是本次resize工作的第一個執行緒,設定初始值為2,非常巧妙的設計 transfer(tab, null); s = sumCount(); }
多處應用了對變數sizeCtl的CAS操作,sizeCtl是一個全域性控制變數。
參考下此變數的定義:private transient volatile int sizeCtl;
- 初始值是0表示雜湊表尚未初始化
- 如果是-1表示正在初始化,只允許一個執行緒進入初始化程式碼塊
- 初始化或者reSize成功後,sizeCtl=loadFactor * tableSize也就是觸發再次擴容的閾值,是一個正整數
- 在擴容過程中,sizeCtrl是一個負整數,其高16位是與當前的tableSize關聯的郵戳resizeStamp,其低16位是當前從事搬運工作的執行緒數加1
在方法的迴圈體中每次都將table、sizeCtrl、nextTable賦給區域性變數以保證讀到的是當前的最新值,且保證邏輯計算過程中變數的穩定。
如果sizeCtrl中高16位的郵戳與當前tableSize不匹配,或者搬運執行緒數達到了最大值,或者所有搬運的執行緒都已經退出(只有在遍歷完所有槽位後才會退出,否則會一直迴圈),或者nextTable已經被清空,跳過搬運操作。
如果滿足搬運條件,則對sizeCtrl做CAS操作,sizeCtrl>=0時設定初始執行緒數為2,sizeCtrl<0時將其值加1,CAS成功後開始搬運操作,失敗則進入下一次迴圈重新判斷。
首個執行緒設定初始值為2的原因是:執行緒退出時會通過CAS操作將參與搬運的匯流排程數-1,如果初始值按照常規做法設定成1,那麼減1後就會變為0。
此時其它執行緒發現執行緒數為0時,無法區分是沒有任何執行緒做過搬運,還是有執行緒做完搬運但都退出了,也就無法判斷要不要加入搬運的行列。
值得注意的是,程式碼中的“sc == rs + 1 || sc == rs + MAX_RESIZERS“是JDK8中的明顯的BUG,少了rs無符號左移16位的操作;JDK12已經修復了此問題。
2、併發搬運過程和退出機制
C13Map的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; if (nextTab == null) { try { //首個搬運執行緒,負責初始化nextTable Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1]; nextTab = nt; } catch (Throwable ex) { sizeCtl = Integer.MAX_VALUE; return; } nextTable = nextTab; //初始化當前搬運索引 transferIndex = n; } int nextn = nextTab.length; //公共的forwardingNode ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab); boolean advance = true; boolean finishing = false; // 保證提交nextTable之前已遍歷舊錶的所有槽位 for (int i = 0, bound = 0; ; ) { Node<K, V> f; int fh; //迴圈CAS獲取下一個搬運區段 while (advance) { int nextIndex, nextBound; //搬運已結束,或者當前區段尚未完成,退出迴圈體;最後一次抄底掃描時,僅輔助做i減一的運算 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; 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回退到最高位,等於是強制做最後一次的全表掃描;程式直接執行後續的else if程式碼,看有沒有哪個槽位漏掉了,或者說是否全部是forwardingNode標記; //可以視為抄底邏輯,雖然檢測到漏掉槽位的概率基本是0 i = n; } } else if ((f = tabAt(tab, i)) == null) //空槽位直接打上forwardingNode標記,CAS失敗下一次迴圈繼續搬運該槽位,成功則進入下一個槽位 advance = casTabAt(tab, i, null, fwd); else if ((fh = f.hash) == MOVED) advance = true; //最後一次抄底遍歷時,正常情況下所有的槽位應該都被打上forwardingNode標記 else { //鎖定頭節點 synchronized (f) { if (tabAt(tab, i) == f) { Node<K, V> ln, hn; if (fh >= 0) { //......此處省略連結串列搬運程式碼:職責是將連結串列拆成兩份,搬運到nextTable的i和i+n槽位 setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); //設定舊錶對應槽位的頭節點為forwardingNode setTabAt(tab, i, fwd); advance = true; } else if (f instanceof TreeBin) { //......此處省略紅黑樹搬運程式碼:職責是將紅黑樹拆成兩份,搬運到nextTable的i和i+n槽位,如果滿足紅黑樹的退化條件,順便將其退化為連結串列 setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); //設定舊錶對應槽位的頭節點為forwardingNode setTabAt(tab, i, fwd); advance = true; } } } } } }
多個執行緒併發搬運時,如果是首個搬運執行緒,負責nextTable的初始化工作;然後藉助於全域性的transferIndex變數從當前table的n-1槽位開始依次向低位掃描搬運,通過對transferIndex的CAS操作一次獲取一個區段(預設是16),當transferIndex達到最低位時,不再能夠獲取到新的區段,執行緒開始退出,退出時會在sizeCtl上將總的執行緒數減一,最後一個退出的執行緒將掃描座標i回退到最高位,強迫做一次抄底的全域性掃描。
3、transfer過程中的讀寫安全性分析
(1)首先是transfer過程中是否有可能全域性的雜湊表table發生多次resize,或者說存在過期的風險?
觀察nextTable提交到table的程式碼,發現只有在所有執行緒均搬運完畢退出後才會commit,所以但凡有一個執行緒在transfer程式碼塊中,table都不可能被替換;所以不存在table過期的風險。
(2)有併發的寫操作時,是否存在安全風險?
因為transfer操作與寫操作都要競爭bin的頭節點的syncronized鎖,兩者是互斥序列的;當寫執行緒得到鎖後,還要做doubleCheck,發現不是一開始的頭節點時什麼事情都不會做,發現是forwardingNode,就會加入搬執行列直到新表被提交,然後去直接操作新表。
nextTable的提交總是在所有的槽位都已經搬運完畢,插上ForwardingNode的標識之後的,因此只要新表已提交,舊錶必定無法寫入;這樣就能夠有效的避免資料寫入舊錶。
推理:獲取到bin頭節點的同步鎖開始寫操作----------> transfer必然未完成--------->新表必然未提交-------→寫入的必然是當前表。
也就說永遠不可能存在新舊兩張表同時被寫入的情況,table被寫入時nextTable永遠都只能被讀取。
(3)有併發的讀操作時,是否存在安全風險?
transfer操作並不破壞舊的bin結構,如果尚未開始搬運,將會照常遍歷舊的BIN結構;如果已搬運完畢,會呼叫到forwadingNode的find方法到新表中遞迴查詢,參考上文中的forwadingNode介紹。
九、Traverser遍歷器
因為iterator或containsValue等通用API的存在,以及某些業務場景確實需要遍歷整個Map,設計一種安全且有效能保證的遍歷機制顯得理所當然。
C13Map遍歷器實現的難點在於讀操作與transfer可能並行,在掃描各個bin時如果遇到forwadingNode該如何處理的問題。
由於併發transfer機制的存在,在某個槽位上遇到了forwadingNode,僅表明當前槽位已被搬運,並不能代表其後的槽位一定被搬運或者尚未被搬運;也就是說其後的若干槽位是一個不可控的狀態。
解決辦法是引入了類似於方法呼叫堆疊的機制,在跳轉到nextTable時記錄下當前table和已經抵達的槽位並進行入棧操作,然後開始遍歷下一個table的i和i+n槽位,如果遇到forwadingNode再一次入棧,周而復始迴圈往復;
每次如果i+n槽位如果到了右半段快要溢位的話就會遵循原來的入棧規則進行出棧,也就是回到上一個上下文節點,最終會回到初始的table也就是initialTable中的節點。
C13Map的Traverser元件
static class Traverser<K,V> { Node<K,V>[] tab; // current table; updated if resized Node<K,V> next; // the next entry to use TableStack<K,V> stack, spare; // to save/restore on ForwardingNodes int index; // index of bin to use next int baseIndex; // current index of initial table int baseLimit; // index bound for initial table final int baseSize; // initial table size Traverser(Node<K,V>[] tab, int size, int index, int limit) { this.tab = tab; this.baseSize = size; this.baseIndex = this.index = index; this.baseLimit = limit; this.next = null; } /** * 返回下一個節點 */ final Node<K,V> advance() { Node<K,V> e; if ((e = next) != null) e = e.next; for (;;) { Node<K,V>[] t; int i, n; // 區域性變數保證穩定性 if (e != null) return next = e; if (baseIndex >= baseLimit || (t = tab) == null || (n = t.length) <= (i = index) || i < 0) return next = null; if ((e = tabAt(t, i)) != null && e.hash < 0) { if (e instanceof ForwardingNode) { tab = ((ForwardingNode<K,V>)e).nextTable; e = null; pushState(t, i, n); continue; } else if (e instanceof TreeBin) e = ((TreeBin<K,V>)e).first; else e = null; } //當前如果有跳轉堆疊直接回放 if (stack != null) recoverState(n); //沒有跳轉堆疊說明已經到initalTable else if ((index = i + baseSize) >= n) index = ++baseIndex; // visit upper slots if present } } /** * 遇到ForwardingNode時儲存當前上下文 */ private void pushState(Node<K,V>[] t, int i, int n) { TableStack<K,V> s = spare; // reuse if possible if (s != null) spare = s.next; else s = new TableStack<K,V>(); s.tab = t; s.length = n; s.index = i; s.next = stack; stack = s; } /** * 彈出上下文 * */ private void recoverState(int n) { TableStack<K,V> s; int len; //如果當前有堆疊,且index已經到達右半段後溢位當前table,說明該回去了 //如果index還在左半段,則只輔助做index+=s.length操作 while ((s = stack) != null && (index += (len = s.length)) >= n) { n = len; index = s.index; tab = s.tab; s.tab = null; TableStack<K,V> next = s.next; s.next = spare; // save for reuse stack = next; spare = s; } //已經到initialTable,索引自增 if (s == null && (index += baseSize) >= n) index = ++baseIndex; } }
假設在整個遍歷過程中初始表initalTable=table1,遍歷到結束時最大的表為table5,也就是在遍歷過程中經歷了四次擴容,屬於一邊遍歷一邊擴容的最複雜場景;
那麼整個遍歷過程就是一個以初始化表initalTable為基準表,以下一張表的i和i+n槽位為forwadingNode的跳轉目標,類似於粒子裂變一般的從最低表向最高表放射的過程;
traverser並不能保證一定遍歷某張表的所有的槽位,但如果假設低階表的某個槽位在最高階表總是有相應的投影,比如table1的一個節點在table5中就會對應16個投影;
traverser能夠保證一次遍歷的所有槽位在最高階表上的投影,可以佈滿整張最高階表,而不會有任何遺漏。
十、併發計數
與HashMap中直接定義了size欄位類似,獲取元素的totalCount在C13MAP中肯定不會去遍歷完整的資料結構;那樣元素較多時效能會非常差,C13MAP設計了CounterCell[]陣列來解決併發計數的問題。
CounterCell[]機制並不理會新舊table的更迭,不管是操作的新表還是舊錶,對於計數而言沒有本質的差異,CounterCell[]只關注總量的增加或減少。
1、從LongAdder到CounterCell記憶體對齊
C13MAP借鑑了JUC中LongAdder和Striped64的計數機制,有大量程式碼與LongAdder和Striped64是重複的,其核心思想是多核環境下對於64位long型資料的計數操作,雖然藉助於volatile和CAS操作能夠保證併發的安全性,但是因為多核操作的是同一記憶體區域,而每個CPU又有自己的本地cache,例如LV1 Cache,LVL2 Cache,暫存器等。
由於記憶體一致性協議MESI的存在,會導致本地Cache的頻繁重新整理影響效能,一個比較好的解決思路是每個CPU只操作固定的一塊記憶體對齊區域,最終採用求和的方式來計數。
這種方式能提高效能,但是並非所有場景都適用,因為其最終的value是求和估算出來的,CounterCell累加求和的過程並非原子,不能代表某個時刻的精準value,所以像compareAndSet這樣的原子操作就無法支援。
2、CounterCell[] 、cellBusy、baseCount的作用
CounterCell[]中存放2的指數冪個CounterCell,併發操作期間有可能會擴容,每次擴容都是原有size的兩倍,一旦超過了CPU的核數即不再擴容,因為CPU的總數通常也是2的指數冪,所以其size往往等於CPU的核數CounterCell[]初始化、擴容、填充元素時,藉助cellBusy其進行spinLock控制baseCount是基礎資料。
在併發量不那麼大,CAS沒有出現失敗時直接基於baseCount變數做計數;一旦出現CAS失敗,說明有併發衝突,就開始考慮CounterCell[]的初始化或者擴容操作,但在初始化未完成時,還是會將其視為抄底方案進行計數。
所以最終的技術總和=baseCount+所有CounterCell中的value。
C13Map的addCount方法
private final void addCount(long x, int check) { CounterCell[] cs; long b, s; //初始時總是直接對baseCount計數,直到出現第一次失敗,或者已經有現成的CounterCell[]陣列可用 if ((cs = counterCells) != null || !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell c; long v; int m; //是否存在競態,為true時表示無競態 boolean uncontended = true; if (cs == null || (m = cs.length - 1) < 0 || //先生成隨機數再對CounterCell[]陣列size求餘,也就是隨機分配到其中某個槽位 (c = cs[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) { //該槽位尚未初始化或者CAS操作又出現競態 fullAddCount(x, uncontended); return; } if (check <= 1) return; s = sumCount(); } //檢測元素總數是否超過sizeCtl閾值 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) << RESIZE_STAMP_SHIFT; if (sc < 0) { if (sc == rs + MAX_RESIZERS || sc == rs + 1 || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2)) transfer(tab, null); s = sumCount(); } } }
其中ThreadLocalRandom是執行緒上下文內的隨機數生成器,可以不受其它執行緒的影響,提高隨機數生成的效能;總是在CAS失敗以後,也就是明確感知到存在多執行緒的競爭的前提下,才會對CounterCell[]進行初始化或者擴容操作。
C13Map的fullAddCount方法
//完整的計數,與LongAdder的程式碼基本雷同 private final void fullAddCount(long x, boolean wasUncontended) { int h; if ((h = ThreadLocalRandom.getProbe()) == 0) { ThreadLocalRandom.localInit(); // force initialization h = ThreadLocalRandom.getProbe(); wasUncontended = true; } boolean collide = false; // 是否有新的衝突 for (;;) { CounterCell[] cs; CounterCell c; int n; long v; if ((cs = counterCells) != null && (n = cs.length) > 0) { if ((c = cs[(n - 1) & h]) == null) { //隨機匹配的槽位尚未有CounterCell元素則初始化之 if (cellsBusy == 0) { // Try to attach new Cell CounterCell r = new CounterCell(x); // Optimistic create if (cellsBusy == 0 && U.compareAndSetInt(this, CELLSBUSY, 0, 1)) { boolean created = false; try { // Recheck under lock CounterCell[] rs; int m, j; if ((rs = counterCells) != null && (m = rs.length) > 0 && rs[j = (m - 1) & h] == null) { rs[j] = r; created = true; } } finally { cellsBusy = 0; } if (created) break; continue; // Slot is now non-empty } } collide = false; } else if (!wasUncontended) wasUncontended = true; //fullAddCount前已經存在cas失敗但並不立即擴容,重新生成一個隨機數進行CAS重試 else if (U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x)) break; else if (counterCells != cs || n >= NCPU) collide = false; // 超過CPU的最大核數,或者檢測到counterCells已擴容,都將衝突狀態置為無 else if (!collide) collide = true; // 以上的若干條件都不滿足,可以判定必定有衝突,再生成一個隨機數試探一下 else if (cellsBusy == 0 && U.compareAndSetInt(this, CELLSBUSY, 0, 1)) { try { if (counterCells == cs) //對counterCells進行doubleCheck counterCells = Arrays.copyOf(cs, n << 1); //擴容,容量翻倍 } finally { cellsBusy = 0; } collide = false; continue; // 對性的counterCell[]進行重試CAS操作 } h = ThreadLocalRandom.advanceProbe(h); //以舊的隨機數為基數生成一個新的隨機數 } else if (cellsBusy == 0 && counterCells == cs && U.compareAndSetInt(this, CELLSBUSY, 0, 1)) { //第一次初始化工作,初始的陣列大小為2 boolean init = false; try { // Initialize table if (counterCells == cs) { CounterCell[] rs = new CounterCell[2]; rs[h & 1] = new CounterCell(x); counterCells = rs; init = true; } } finally { cellsBusy = 0; } if (init) break; } //初始化過程中其它執行緒的抄底方案 else if (U.compareAndSetLong(this, BASECOUNT, v = baseCount, v + x)) break; } }
迴圈生成新的隨機數匹配到新的槽位進行CAS的計數操作,出現CAS失敗後並不急於擴容;而是總是在連續出現CAS失敗的情況才會嘗試擴容。
CounterCell[]的整體方案相對獨立,與C13Map的關係並不大,可以視為一種成熟的高效能技術方案在各個場景使用。
十一、與stream類似的bulk操作支援
1、bulkTask類的子類
所有的批量任務執行類均為bulkTask的子類, bulkTask內建了與traverser類似的實現,用以支援對C13Map的遍歷;同時它也是ForkJoinTask的子類,支援以fork/join的方式來完成各種批量任務的執行。
因為ForkJoinTask並非本文的重點,這裡僅列出幾種有代表性的批量方法,以及相應的的task實現。
2、幾種有代表性的批量方法
C13Map的批量任務
//將所有的entry按照transformer函式進行二元計算,再對所有生成的結果執行action一元函式 public <U> void forEach(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> transformer, Consumer<? super U> action); //對所有的entry執行searchFunction二元計算,一旦發現任意一個計算結果不為null,即全盤返回 public <U> U search(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> searchFunction); //對所有的entry執行transformer二元計算,再對所有的結果執行reducer收斂函式 public <U> U reduce(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) //對所有的entry中的value執行transformer二元計算,再對所有的結果執行reducer收斂函式 public <U> U reduceValues(long parallelismThreshold, Function<? super V, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer)
以上所有的批量方法都有唯一與其對應的批量task執行類,背後均是基於fork/join思想實現。
3、批量task的實現
以2中列出的reduce方法所對應的MapReduceMappingsTask為例,有關fork/join中的實現細節不屬於本文的範疇,不做詳細討論。
C13Map的MapReduceMappingsTask
static final class MapReduceMappingsTask<K,V,U> extends BulkTask<K,V,U> { final BiFunction<? super K, ? super V, ? extends U> transformer; final BiFunction<? super U, ? super U, ? extends U> reducer; U result; MapReduceMappingsTask<K,V,U> rights, nextRight; MapReduceMappingsTask (BulkTask<K,V,?> p, int b, int i, int f, Node<K,V>[] t, MapReduceMappingsTask<K,V,U> nextRight, BiFunction<? super K, ? super V, ? extends U> transformer, BiFunction<? super U, ? super U, ? extends U> reducer) { super(p, b, i, f, t); this.nextRight = nextRight; this.transformer = transformer; this.reducer = reducer; } public final U getRawResult() { return result; } public final void compute() { final BiFunction<? super K, ? super V, ? extends U> transformer; final BiFunction<? super U, ? super U, ? extends U> reducer; if ((transformer = this.transformer) != null && (reducer = this.reducer) != null) { for (int i = baseIndex, f, h; batch > 0 && (h = ((f = baseLimit) + i) >>> 1) > i;) { addToPendingCount(1); //裂變出新的fork-join任務 (rights = new MapReduceMappingsTask<K,V,U> (this, batch >>>= 1, baseLimit = h, f, tab, rights, transformer, reducer)).fork(); } U r = null; //遍歷本batch元素 for (Node<K,V> p; (p = advance()) != null; ) { U u; //對本batch做reduce收斂操作 if ((u = transformer.apply(p.key, p.val)) != null) r = (r == null) ? u : reducer.apply(r, u); } //對自己和自己fork出的子任務做reducer收斂操作 result = r; CountedCompleter<?> c; for (c = firstComplete(); c != null; c = c.nextComplete()) { @SuppressWarnings("unchecked") MapReduceMappingsTask<K,V,U> t = (MapReduceMappingsTask<K,V,U>)c, s = t.rights; while (s != null) { U tr, sr; if ((sr = s.result) != null) t.result = (((tr = t.result) == null) ? sr : reducer.apply(tr, sr)); s = t.rights = s.nextRight; } } } } }
十二、小結
自JDK8開始C13Map摒棄了JDK7中的Segment段實現方案,將鎖的粒度細化到了每個bin上,鎖的粒度更小併發能力更強。用syncronized關鍵字代替原先的ReentrantLock互斥鎖,因JDK8中對syncronized做了大量優化,可以達到比ReentrantLock更優的效能。
引入併發transfer的機制支援多執行緒搬運,寫操作和transfer操作在不同bin上可並行。引入ForwardingNode支援讀操作和transfer並行,並進一步支援transfer過程有可能存在的雜湊錶鏈的遍歷。引入ReserveNode在compute原子計算可能耗時較長的情況下搶先佔位,避免重複計算。
引入紅黑樹來優化雜湊衝突時的檢索效能,其內部實現了輕量級的讀寫鎖保證讀寫安全,線上性檢索和tree檢索之間做了智慧切換,達到了效能與安全的極佳的平衡。引入CounterCell機制優化多核場景的計數,解決記憶體偽共享問題。
引入 ForkJoinTask的子類優化bulk計算時的效能。整個C13Map的實現過程大量使用volatile保證可見,使用CAS保證原子,是一種區域性無鎖的lockFree dataStructure的典範實現。
與HashMap的單執行緒讀寫操作不同的是,HashMap讀到的資料在下一次寫操作間是一直穩定的,在多個寫操作之間是一個穩定的snapshot,而C13Map因為併發執行緒的存在,資料瞬息萬變,讀到的永遠只是某個時間點的正確資料,寫入成功也只是在某個時間點保證寫入是安全的,因此C13Map通常只談安全而不談實時,這極大提高了程式設計的難度,也是單執行緒和併發資料結構之間的明顯差異。
更多內容敬請關注vivo 網際網路技術微信公眾號
注:轉載文章請先與微訊號:Labs2020聯絡