ConcurrentHashMap原始碼閱讀
前言
ConcurrentHashMap是HashMap的多執行緒版本,經常用到,JDK裡的實現方式也非常的精妙,值得學習。JDK1.7和1.8的實現方式並不相同,所以這裡兩個版本都要學習,體會個中的精妙之處。
HashMap的各種多執行緒方式
ConcurrentHashMap並非是HashMap的唯一多執行緒方式,它還有其他的多執行緒方式,為什麼這些都被淘汰了,與ConcurrentHashMap的區別又在哪裡?
Hashtable
Hashtable是執行緒安全的,但效率低下,因為它的原始碼就是直接對所有資料操作都上鎖synchronized(直接在方法級別上加synchronized),哪怕是get方法,所以效率比較低下。除此之外,Hashtable與HashMap還有一些不同。
①關於null值,HashMap允許鍵值為null,而Hashtable不允許。這是因為Hashtable使用的是安全失敗機制(fail-safe),這種機制會使得你此次讀到的資料不一定是最新的資料。如果使用null值,就無法判斷對應的key是不存在還是為null,ConcurrentHashMap同理。對於單執行緒的HashMap,可以通過containsKey來判斷key是否存在,因此可以允許null值作為key和value。而對於多執行緒的Hashtable和ConcurrentHashMap,在get和containsKey兩個方法中間,可能map本身就發生了改變,此時containsKey方法就失去了意義,因此是不能判斷map到底是存在key為null的資料,還是不存在該key。
②實現方式不同,Hashtable繼承了Dictionary類,而HashMap繼承的是AbstractMap類。
③初始化容量不同,HashMap初始容量是16,而Hashtable是11,二者的負載因子都是0.75
④擴容機制不同,HashMap擴容是翻倍,而Hashtable是翻倍再+1
⑤迭代器不同,HashMap的Iterator是fail-fast的,而Hashtable的Enumerator是fail-safe的。所以HashMap使用迭代器後,如果修改了HashMap的結構,如增加,刪除元素,將會丟擲ConcurrentModificationException異常,而Hashtable不會。而HashMap是根據引數modCount來判斷是否發現了HashMap的改變,異常丟擲的條件是modCount != exceptedmodCount
,如果集合發生變化時,modCount剛好修改為exceptedmodCount,那麼是不會丟擲該異常的。因此不能依賴這個異常是否丟擲來進行併發程式設計,這個異常只建議用來檢測並修改的bug。
使用Collections.synchronizedMap(Map)建立執行緒安全的Map集合
此方法會返回一個SynchronizedMap物件,裡面維護了一個普通的Map物件,還有互斥鎖Mutex。
我們在呼叫這個方法的時候就需要傳入一個Map,可以看到有兩個構造器,如果你傳入了mutex引數,則將物件排斥鎖賦值為傳入的物件。
如果沒有,則將物件排斥鎖賦值為this,即呼叫synchronizedMap的物件,就是上面的Map。
建立出synchronizedMap之後,再操作map的時候,就會對方法上鎖,如圖,全是?
所以synchronizedMap的效率也不高。
ConcurrentHashMap
ConcurrentHashMap是併發度比較高的Map類,是多執行緒環境下常用的多執行緒版本的HashMap。在JDK1.7和JDK1.8中的實現並不一樣,下面將分別講述。
JDK1.7的ConcurrentHashMap
資料結構
HashMap的底層是由連結串列陣列而組成的。因為連結串列在雜湊的時候被稱作bucket(桶),所以下文有時候會把連結串列寫作桶,二者是等價的。在JDK1.7中,ConcurrentHashMap由Segment陣列構成,而Segment由HashEntry構成。採用的思想是分段鎖,即一個Segment相當於一個HashMap,Segment裡的HashEntry就是HashMap裡的桶陣列table。一個ConcurrentHashMap由一個Segment陣列構成,分段鎖的思想就是每一個Segment是相互獨立的,即對其中一個Segment操作時,並不會鎖住其他Segment的資料。因此ConcurrentHashMap的併發度就是Segment陣列的長度。
Segment的ConcurrentHashMap的一個內部類,如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一樣,真正存放資料的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
// 記得快速失敗(fail—fast)麼?
transient int modCount;
// 大小
transient int threshold;
// 負載因子
final float loadFactor;
}
HashEntry跟HashMap差不多的,但是不同點是,他使用volatile去修飾了它的資料Value以及下一個節點next。
Question:JDK1.7的ConcurrentHashMap為什麼併發度高?
Ans:原理上來說,ConcurrentHashMap 採用了分段鎖技術,其中 Segment 繼承於 ReentrantLock。
在 JDK1.7中,本質上還是採用連結串列+陣列的形式儲存鍵值對的。但是,為了提高併發,把原來的整個 table 劃分為 n 個 Segment 。所以,從整體來看,它是一個由 Segment 組成的陣列。然後,每個 Segment 裡邊是由 HashEntry 組成的陣列,每個 HashEntry之間又可以形成連結串列。我們可以把每個 Segment 看成是一個小的 HashMap,其內部結構和 HashMap 是一模一樣的。
當對某個 Segment 加鎖時,如上圖中 Segment2,此時並不會影響到其他 Segment 的讀寫,每個 Segment 內部自己操作自己的資料,彼此之間互相獨立。這樣一來,我們要做的就是儘可能的讓元素均勻的分佈在不同的 Segment中。最理想的狀態是,所有執行的執行緒操作的元素都是不同的 Segment,這樣就可以降低鎖的競爭。
常用變數
//預設初始化容量,這個和 HashMap中的容量是一個概念,表示的是整個 Map的容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//預設載入因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//預設的併發級別,這個引數決定了 Segment 陣列的長度
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//每個Segment中table陣列的最小長度為2,且必須是2的n次冪。
//由於每個Segment是懶載入的,用的時候才會初始化,因此為了避免使用時立即調整大小,設定了最小容量2
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//用於限制Segment數量的最大值,必須是2的n次冪
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//在size方法和containsValue方法,會優先採用樂觀的方式不加鎖,直到重試次數達到2,才會對所有Segment加鎖
//這個值的設定,是為了避免無限次的重試。後邊size方法會詳講怎麼實現樂觀機制的。
static final int RETRIES_BEFORE_LOCK = 2;
//segment掩碼值,用於根據元素的hash值定位所在的 Segment 下標。後邊會細講
final int segmentMask;
//和 segmentMask 配合使用來定位 Segment 的陣列下標,後邊講。
final int segmentShift;
// Segment 組成的陣列,每一個 Segment 都可以看做是一個特殊的 HashMap
final Segment<K,V>[] segments;
//Segment 物件,繼承自 ReentrantLock 可重入鎖。
//其內部的屬性和方法和 HashMap 神似,只是多了一些擴充功能。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//這是在 scanAndLockForPut 方法中用到的一個引數,用於計算最大重試次數
//獲取當前可用的處理器的數量,若大於1,則返回64,否則返回1。
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
//用於表示每個Segment中的 table,是一個用HashEntry組成的陣列。
transient volatile HashEntry<K,V>[] table;
//Segment中的元素個數,每個Segment單獨計數(下邊的幾個引數同樣的都是單獨計數)
transient int count;
//每次 table 結構修改時,如put,remove等,此變數都會自增
transient int modCount;
//當前Segment擴容的閾值,同HashMap計算方法一樣也是容量乘以載入因子
//需要知道的是,每個Segment都是單獨處理擴容的,互相之間不會產生影響
transient int threshold;
//載入因子
final float loadFactor;
//Segment建構函式
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
...
// put(),remove(),rehash() 方法都在此類定義
}
// HashEntry,存在於每個Segment中,它就類似於HashMap中的Node,用於儲存鍵值對的具體資料和維護單向連結串列的關係
static final class HashEntry<K,V> {
//每個key通過雜湊運算後的結果,用的是 Wang/Jenkins hash 的變種演算法,此處不細講,感興趣的可自行查閱相關資料
final int hash;
final K key;
//value和next都用 volatile 修飾,用於保證記憶體可見性和禁止指令重排序
volatile V value;
//指向下一個節點
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
重點:
concurrencyLevel:併發級別,相當於Segment陣列的最大長度。
segmentMask:用於定位到Segment陣列的下標。
segmentShift:用於定位到具體segment的具體下標(即具體元素的下標)。
Segment繼承自ReentrantLock,維護了一個HashEntry陣列(桶陣列),還有count,modCount,threshold,loadFactor等變數,put,remove,rehash等方法。而HashEntry就相當於HashMap中的Node,維護了hash,key,value,next等變數。
構造方法
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//檢驗引數是否合法。值得說的是,併發級別一定要大於0,否則就沒辦法實現分段鎖了。
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//併發級別不能超過最大值
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
//偏移量,是為了對hash值做位移操作,計算元素所在的Segment下標,put方法詳講
int sshift = 0;
//用於設定最終Segment陣列的長度,必須是2的n次冪
int ssize = 1;
//這裡就是計算 sshift 和 ssize 值的過程 (1)
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift; // 最後用高sshift位來記錄位移量
//Segment的掩碼
this.segmentMask = ssize - 1; // 類似於 n - 1的掩碼
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//c用於輔助計算cap的值 (2)
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// cap 用於確定某個Segment的容量,即Segment中HashEntry陣列的長度
int cap = MIN_SEGMENT_TABLE_CAPACITY;
//(3)
while (cap < c)
cap <<= 1;
// create segments and segments[0]
//這裡用 loadFactor做為載入因子,cap乘以載入因子作為擴容閾值,建立長度為cap的HashEntry陣列,
//三個引數,建立一個Segment物件,儲存到S0物件中。後邊在 ensureSegment 方法會用到S0作為原型物件去建立對應的Segment。
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
//建立出長度為 ssize 的一個 Segment陣列
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
//把S0存到Segment陣列中去。在這裡,我們就可以發現,此時只是建立了一個Segment陣列,
//但是並沒有把陣列中的每個Segment物件建立出來,僅僅建立了一個Segment用來作為原型物件。
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
構造方法所做的工作:
①計算sshift和ssize的值。
sshift:hash位移,用於計算元素所在的Segment下標,在後面put方法會用到。
ssize:用於計算最終Segment陣列的長度,是一個不小於concurrencyLevel的二次冪。轉換過程為程式碼中的(1)
②(2)和(3),通過ssize確定了HashEntry陣列的最終長度(cap),可以看到HashEntry陣列的長度也是2次冪。
③建立Segment陣列,Segment陣列的長度為ssize。同時還建立了一個s0作為原型物件,用於後續建立新的Segment,三個引數分別為負載因子loadFactor,擴容閾值(cap * loadFactor),長度為cap的HashEntry陣列。將s0存到陣列裡。
put方法
put 方法的總體流程:
- 通過雜湊演算法計算出當前 key 的 hash 值
- 通過這個 hash 值找到它所對應的 Segment 陣列的下標(在哪個Segment裡)
- 再通過 hash 值計算出它在對應 Segment 的 HashEntry陣列的下標(在Segment裡的具體元素下標)
- 找到合適的位置插入元素(具體元素下標裡是一個桶/連結串列,遍歷一次,如果有相同的key,說明是替換value,如果到達結尾還沒有匹配到相同的key,說明是插入新的key-value)
// Map的put方法
public V put(K key, V value) {
Segment<K,V> s;
//不支援value為空
if (value == null)
throw new NullPointerException();
//通過 Wang/Jenkins 演算法的一個變種演算法,計算出當前key對應的hash值
int hash = hash(key);
//上邊我們計算出的 segmentShift為28,因此hash值右移28位,說明此時用的是hash的高4位,
//然後把它和掩碼15進行與運算,得到的值一定是一個 0000 ~ 1111 範圍內的值,即 0~15 。
int j = (hash >>> segmentShift) & segmentMask;
//這裡是用Unsafe類的原子操作找到Segment陣列中j下標的 Segment 物件
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//初始化j下標的Segment
s = ensureSegment(j);
//在此Segment中新增元素
return s.put(key, hash, value, false); // 最終確定元素位置並插入元素
}
關於程式碼UNSAFE.getObject (segments, (j << SSHIFT) + SBASE
,它是為了通過Unsafe這個類,找到 j 最新的實際值。這個計算( j << SSHIFT ) + SBASE
,在後邊非常常見,我們只需要知道它代表的是 j 的一個偏移量,通過偏移量,就可以得到 j 的實際值。可以類比,AQS 中的 CAS 操作。Unsafe中的操作,都需要一個偏移量,看下圖:
( j << SSHIFT ) + SBASE 就相當於圖中的 stateOffset偏移量。只不過圖中是 CAS 設定新值,而我們這裡是取 j 的最新值,後邊還有很多這樣的計算方式。接著看 s.put 方法,這才是最終確定元素位置的方法。
// Segment中的 put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 這裡通過tryLock嘗試加鎖,如果加鎖成功,返回null,否則執行 scanAndLockForPut方法
// tryLock不會阻塞,而是自旋,直到自旋次數達到閾值,才呼叫lock方法進行阻塞等待
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
// 當前Segment的table陣列
HashEntry<K,V>[] tab = table;
// 這裡就是通過hash值,與tab陣列長度取模,找到其所在HashEntry陣列的下標
int index = (tab.length - 1) & hash;
// 當前下標位置的第一個HashEntry節點
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) { // 遍歷連結串列
// 如果e節點不為空
if (e != null) {
K k;
// 如果e的key相同,替換value值,否則繼續向後查詢
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
// 替換舊值
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break; // 修改完成,終止迴圈,因為是替換value,無需修改count值
}
e = e.next;
}
// 執行到else有兩種情況,一種是first為null,即連結串列為null。
// 另一種是連結串列遍歷到了結尾也沒有找到key相同的節點。
// 不管是哪一種,直接用頭插法把node插在first節點的前面即可。
else {
// 還要先判斷一下node是否為null,後面會看到scanAndLockForPut不一定完成node的初始化
if (node != null) // 如果node不為空,則直接頭插
node.setNext(first);
//否則,建立一個新的node,並頭插
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//如果當前Segment中的元素大於閾值,並且tab長度沒有超過容量最大值,則擴容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
//否則,就把當前node設定為index下標位置新的頭結點
else
setEntryAt(tab, index, node);
++modCount;
//更新count值
count = c;
//這種情況的舊值肯定為空
oldValue = null;
break;
}
}
} finally {
//需要注意ReentrantLock必須手動解鎖
unlock();
}
//返回舊值
return oldValue;
}
Segment中的put方法邏輯:
①使用tryLock嘗試加鎖。因為是在多執行緒環境下使用,所以要避免多個執行緒對同一個Segment同時進行put導致更新丟失等等的併發錯誤。HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
,如果tryLock成功,那麼當前執行緒成功獲取鎖,此時node直接賦值為null。如果tryLock失敗,說明有其他執行緒獲取了鎖,此時呼叫scanAndLockForPut方法。後面我們會看到,該方法邏輯如其名,會一遍一遍地掃描連結串列,會進行一些預熱的Node生成操作,一直自旋重試,直到重試次數上限才呼叫lock方法,直接阻塞等待,停止自旋。所以這一句程式碼可以確保執行後續程式碼的時候,該執行緒必定獲取了鎖。而scanAndLockForPut可能會預生成一個Node,把它賦值給node變數,用於put的後續使用。
②在Map的put方法中已經定位到了具體的Segment,而這裡就是在Segment里根據hash值定位到具體的HashEntry位置HashEntry<K,V> first = entryAt(tab, index);
。記住這裡的HashEntry是一個連結串列(桶),之後就是遍歷連結串列,如果找到相同key的節點,就進行替換value。如果找不到,那麼就新建一個node進行插入。
重點在於遍歷連結串列的邏輯,for(…) { if (not null) … else …},邏輯並不難懂,但網上一些互相cv的部落格,一個錯就全都錯,那些迷之註釋我也不確定它們到底有沒有看過是什麼意思,害得我糾結了很久。實際上就是兩種情況:如果匹配到相同的key,那麼就是進行value的替換。如果直到連結串列尾部還是沒有找到相同的key,那麼就是進行新的key-value的插入。搞清楚整體的邏輯之後,對於這段程式碼我只有一個疑問:if ((k = e.key) == key || (e.hash == hash && key.equals(k)))
,為什麼判斷key相等要寫成這麼奇怪的形式。答案也很簡單,減少equals方法的呼叫,畢竟呼叫一個方法肯定還是比直接數值的比較要消耗更多的資源。如果key值相同,那麼該邏輯表示式就直接為true,否則就先判斷hash,而不是直接呼叫equals方法。在JDK1.8,這段程式碼會改寫為:if (e.hash == hash && ((k = e.key) == key) || key != null && key.equals(k))
,直接先判斷hash,如果hash都不相同,那麼key肯定就不相同了(重寫了hashCode)。如果hash相同,那麼就是在同一個連結串列(桶)裡,此時key的值並不一定相等,可能是發生了碰撞,因此還要判斷key。先用key的數值去判斷,如果返回的是false再用equals去判斷。因為key型別不一定是基本型別,如果是String型別,此時(k = e.key) == key
就會返回false。總而言之,這些寫法都是儘量減少equals方法的呼叫。
對於1.7方法,採用的是分段鎖,同時我們早已定位到了具體的桶位置,所以優先判斷key的數值,再判斷hash,雖然的確存在不同hash在&操作之後定位到了同一個桶,但概率相對來說比較小。而對於1.8,並沒有分段鎖的概念,因此優先判斷hash來過濾掉大部分不合格的節點。
當然除此之外,put方法還呼叫了諸如ensureSegment,scanAndLockForPut,entryAt,setEntryAt等方法。當達到擴容閾值的時候還會呼叫rehash,後面會講到。
Question:計算Segment陣列下標和計算HashEntry陣列下標有何不同?
Ans:
計算Segment陣列下標: (hash >>> segmentShift) & segmentMask
計算HashEntry陣列下標:(tab.length - 1) & hash
Segment使用的是hash的高位和掩碼進行與運算,HashEntry直接使用hash和陣列長度減1進行與運算。這樣做可以避免同時使用低位相同的hash,與運算後的結果容易相同,導致元素扎堆,連結串列過長的缺點。只要兩個hash它不是高位和低位都相同,那麼二者計算的下標結果就會不同。(有點類似HashMap的高16位和低16位進行與運算)
ensureSegment方法
Map的put方法,如果對應下標的Segment物件為null,此時會呼叫ensureSegment方法,初始化一個Segment物件,以確保拿到的的物件一定不為null,然後再呼叫s.put方法。
//k為 (hash >>> segmentShift) & segmentMask 演算法計算出來的值
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
//u代表 k 的偏移量,用於通過 UNSAFE 獲取主記憶體最新的實際 K 值
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//從記憶體中取到最新的下標位置的 Segment 物件,判斷是否為空,(1)
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//之前構造方法裡說了,s0是作為一個原型物件,用於建立新的 Segment 物件
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
//容量
int cap = proto.table.length;
//載入因子
float lf = proto.loadFactor;
//擴容閾值
int threshold = (int)(cap * lf);
//把 Segment 對應的 HashEntry 陣列先建立出來
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//再次檢查 K 下標位置的 Segment 是否為空, (2)
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
//此處把 Segment 物件建立出來,並賦值給 s,
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//迴圈檢查 K 下標位置的 Segment 是否為空, (3)
//若不為空,則說明有其它執行緒搶先建立成功,並且已經成功同步到主記憶體中了,
//則把它取出來,並返回
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
//CAS,若當前下標的Segment物件為空,就把它替換為最新建立出來的 s 物件。
//若成功,就跳出迴圈,否則,就一直自旋直到成功,或者 seg 不為空(其他執行緒成功導致)。
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
ensureSegment方法的目標就是建立一個Segment物件,理論上應該很簡單,但方法看起來還是寫了挺長,原因在於方法裡進行了三次判斷,判斷Segment物件是否為null。因為在多執行緒環境下,不確定什麼時候其他執行緒的CAS操作會成功,有可能發生在上面的任意時刻。所以只要有任意一次檢測到Segment物件不為null,說明其他執行緒已經把Segment物件建立好了,並已經成功地CAS同步到記憶體了,此時就可以直接返回,無需再重複地建立。
一共有三次判斷,第一次是在方法的最開始。如果為null,那麼繼續建立Segment物件所需要的HashEntry陣列物件,建立完之後進行第二次判斷。如果依然為null,那麼就正式建立Segment物件,傳入loadFactor,threshold以及前面建立的HashEntry物件。接著就是進行第三次判斷,如果依然為null,說明物件依然沒有建立完全並同步到記憶體,此時繼續往後執行CAS操作。最後一步是自旋,等待CAS操作成功,每一次自旋都會判斷一次Segment物件是否為null。
scanAndLockForPut方法
Segment裡的tryLock失敗後,就會呼叫此方法。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//根據hash值定位到它對應的HashEntry陣列的下標位置,並找到連結串列的第一個節點
//注意,這個操作會從主記憶體中獲取到最新的狀態,以確保獲取到的first是最新值
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//重試次數,初始化為 -1
int retries = -1; // negative while locating node
//若搶鎖失敗,就一直迴圈,直到成功獲取到鎖。有三種情況
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//1.若 retries 小於0,
if (retries < 0) {
if (e == null) {
//若 e 節點和 node 都為空,則建立一個 node 節點。這裡只是預測性的建立一個node節點
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//如當前遍歷到的 e 節點不為空,則判斷它的key是否等於傳進來的key,若是則把 retries 設為0
else if (key.equals(e.key))
retries = 0;
//否則,繼續向後遍歷節點
else
e = e.next;
}
//2.若是重試次數超過了最大嘗試次數,則呼叫lock方法加鎖。表明不再重試,我下定決心了一定要獲取到鎖。
//要麼當前執行緒可以獲取到鎖,要麼獲取不到就去排隊等待獲取鎖。獲取成功後,再 break。
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
//3.若 retries 的值為偶數,並且從記憶體中再次獲取到最新的頭節點,判斷若不等於first
//則說明有其他執行緒修改了當前下標位置的頭結點,於是需要更新頭結點資訊。
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//更新頭結點資訊,並把重試次數重置為 -1,繼續下一次迴圈,從最新的頭結點遍歷當前連結串列。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
該方法的核心是自旋,直到tryLock成功,即直到成功獲取鎖。所以自旋次數達到MAX_SCAN_RETRIES,那麼會停止自旋,直接呼叫lock方法,排隊等待獲取鎖。從整體的邏輯可以看出來,該方法確保返回的時候,當前執行緒一定是成功獲取鎖的狀態,所以前面put方法在最開始的tryLock就實現了鎖的獲取,保證執行緒同步。
自旋里首先判斷retries是否小於0,小於0說明此時是第一次自旋,或者因為連結串列被修改了重新自旋。當進入到這一段邏輯,就是普通的連結串列遍歷。如果e為null,說明連結串列為空,所以直接插入node節點即可。但因為在while迴圈體裡,說明此時已經發生了併發問題,比如多個執行緒同時要put到一個空的連結串列(桶)裡。所以此時會把retries置為0,那麼就不會繼續進入這一段邏輯,而是一直自旋,直到成功獲取鎖,或者達到最大嘗試次數++retries > MAX_SCAN_RETRIES
。如果e不為null,那麼就繼續遍歷,找尋key相同的節點。如果存在相同的key,那麼後續就是要進行value的更新。如果不存在,那麼就繼續往後遍歷e = e.next
。如果遍歷到最後還是沒有相同的key,那麼此時就會執行到e == null
處。除了這一段邏輯以及最大嘗試次數的判斷,還有一個判定條件是:else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first)
,這個主要就是用於實時更新頭節點資訊。因為put方法使用的是頭插法,所以如果頭節點更新了,說明其他執行緒進行了put操作,那麼當前執行緒就要更新頭節點資訊,並且重新自旋,重新從頭開始遍歷連結串列。不然如果另一個執行緒它新put的key恰好就是我們要找尋的key,此時如果不從頭遍歷,那麼就會出現key冗餘的錯誤。
其實基本的邏輯就是這樣,只是第一段邏輯e == null
裡還有一段比較迷惑的程式碼:
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
如果最開始不理解整個方法的邏輯,可以直接先把這段程式碼刪掉,那麼就能很清晰地看出來三個if, else if之間的關係和作用了。這裡的操作稱為預測性地建立節點,後續在put方法裡可以用到。實際上這裡不建立,put方法裡檢測到node為null也會自行建立,但這裡就“多餘”地進行了預測性的建立。為什麼?原始碼裡的解釋是這樣的:Since traversal speed doesn’t matter, we might as well help warm up the associated code and accesses as well.
意思就是,遍歷連結串列的速度並不重要,所以我們可以在可以預先做一些操作(建立node),這樣就不用在後續的put方法裡再進行建立了。為什麼要這樣做,我個人的猜測是,這一段程式碼消耗時間最長的並非遍歷連結串列的時間,而是自旋的時間,所以提升連結串列遍歷的時間意義不大。相反,在這裡就預先建立好node,這樣能提升put方法的效率,從而節省put方法裡獲取了鎖之後的操作時間。
再提一下最後的判定條件,(retries & 1) == 0
是什麼意思。可以參考一下此連結的答案:https://stackoverflow.com/questions/25196851/concurrenthashmap-in-jdk7-code-explanation-scanandlockforput
但我覺得並沒有給出具體的答案,為什麼要偶數次才進行一次檢測頭節點是否發生了改變。顯然,每一次retry都檢測肯定是可行的,只是偶數次可以減少檢測的次數,提高效率。那麼為什麼一定要每兩次檢測一次,為什麼不會在中途出錯?這個問題,其實我還沒搞懂。// TODO!!!
總而言之,scanAndLockForPut方法可以確保當前執行緒獲取到了鎖,如果可以的話,會順便把node也預先建立好。
rehash方法
當 put 方法時,發現元素個數超過了閾值,則會擴容。需要注意的是,每個Segment只管它自己的擴容,互相之間並不影響。換句話說,可以出現這個 Segment的長度為2,另一個Segment的長度為4的情況(都是2的n次冪)。如下程式碼:(可以先把lastRun相關的程式碼全部刪掉,這樣就很好理解了)
//node為建立的新節點
private void rehash(HashEntry<K,V> node) {
//當前Segment中的舊錶
HashEntry<K,V>[] oldTable = table;
//舊的容量
int oldCapacity = oldTable.length;
//新容量為舊容量的2倍
int newCapacity = oldCapacity << 1;
//更新新的閾值
threshold = (int)(newCapacity * loadFactor);
//用新的容量建立一個新的 HashEntry 陣列
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//當前的掩碼,用於計算節點在新陣列中的下標
int sizeMask = newCapacity - 1;
//遍歷舊table陣列
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
// 當前下標的連結串列/桶不為空
if (e != null) {
HashEntry<K,V> next = e.next;
//計算hash值在新陣列中的下標位置
int idx = e.hash & sizeMask;
//如果e不為空,且它的下一個節點為空,則說明這條連結串列只有一個節點,
//直接把這個節點放到新陣列的對應下標位置即可
if (next == null) // Single node on list
newTable[idx] = e;
//否則,處理當前連結串列的節點遷移操作
else { // Reuse consecutive sequence at same slot
// lastRun節點表示連結串列最後對映到同一下標的幾個連續節點的第一個
HashEntry<K,V> lastRun = e;
// lastRun節點對應的新下標就是lastIdx
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//計算當前遍歷到的節點的新下標
int k = last.hash & sizeMask;
//若 k 不等於 lastIdx,則說明此次遍歷到的節點和上次遍歷到的節點不在同一個下標位置
//需要把 lastRun 和 lastIdx 更新為當前遍歷到的節點和下標值。
//若相同,則不處理,繼續下一次 for 迴圈。
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//把和 lastRun 節點的下標位置相同的連結串列最末尾的幾個連續的節點直接放到新陣列的對應下標位置
newTable[lastIdx] = lastRun;
//再把剩餘的節點,複製到新陣列
//從舊陣列的頭結點開始遍歷,直到 lastRun 節點,因為 lastRun節點後邊的節點都已經遷移完成了。
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
//用的是複製節點資訊的方式,並不是把原來的節點直接遷移,區別於lastRun處理方式
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
//所有節點都遷移完成之後,再處理傳進來的新的node節點,把它頭插到對應的下標位置
int nodeIndex = node.hash & sizeMask; // add the new node
//頭插node節點
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
//更新當前Segment的table資訊
table = newTable;
}
看起來程式碼很長,實際上是因為它進行了優化,新增了lastRun這個概念。如果暫時不考慮lastRun這個節點,那麼整體的邏輯實際上就這幾行程式碼:
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
//用的是複製節點資訊的方式,並不是把原來的節點直接遷移,區別於lastRun處理方式
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
方法名雖然叫rehash,但實際上並沒有重新計算hash值,只是把table的大小擴大為2倍,與此同時sizeMask也會發生變化,最後改變的只有下標index。而lastRun的含義是:找到連結串列最後一個新下標連續相同的節點。如圖所示:
桶陣列裡,每一個節點在新陣列的下標不一定相同,但最後可能有連續幾個是相同的,這時候只要記錄這幾個連續相同節點的第一個位置,記錄為lastRun,此時只需要進行一次賦值操作:newTable[lastIdx] = lastRun;
對於lastRun節點,直接賦值一個子連結串列。而剩餘的節點是複製節點資訊,從連結串列頭開始,直到lastRun。對於上圖的例子,首先會找到lastRun的位置,即倒數第三個元素。然後這時候newTable[lastIdx] = lastRun;
就直接一次把lastRun開始,直到連結串列結尾的元素全部遷移完成了。最後再從頭開始,處理前面的元素,即k2,k2,k1。
為什麼要這樣設計一個lastRun,毫無疑問也是為了優化,如果是普通的實現,直接從頭開始一個一個進行newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
肯定也是可行的。這裡看一下設計者的註釋:
/*
* Reclassify nodes in each list to new Map. Because we are
* using power-of-two expansion, the elements from each bin
* must either stay at same index, or move to
* oldCapacity+index. We also eliminate unnecessary node
* creation by catching cases where old nodes can be reused
* because their next fields won't change. Statistically, at
* the default threshhold, only about one-sixth of them need
* cloning. (The nodes they replace will be garbage
* collectable as soon as they are no longer referenced by any
* reader thread that may be in the midst of traversing table
* right now.)
*/
中文意思就是,因為規定了陣列長度是2次冪,所以sizeMask實際上只是增加一個值為1的高位,最後新的下標只有兩種情況,要麼跟原座標相等,要麼移動到原座標+舊的陣列長度(oldCapacity + index),這實際上在JDK1.8裡的HashMap就已經應用到了。lastRun就是為了減少不必要的節點建立,對於那些“next fields won’t change”的節點,即最後那些新座標都相同的連續節點,直接賦值一次lastRun即可,而不是一個一個地建立。據統計,在預設的閾值下,大約只有1/6的節點需要被克隆。而原本的節點因為沒有引用指向,所以很快就會被GC回收,不必擔心記憶體洩漏的問題。當然,在最壞的情況下,lastRun沒有任何優化,即lastRun為連結串列的最後一個元素或者很靠後的元素,但整體上的效率還是提升了,所以就保留了lastRun這個設計。(但這個優化效率有點誇張,為什麼能達到這麼好的效果,這裡我不是很清楚~)
參考連結:
http://gee.cs.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/ConcurrentHashMap.java
get方法
get方法的邏輯相比put就簡單很多了,先定位到Segment,再定位到HashEntry,over。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
//計算hash值
int h = hash(key);
//同樣的先定位到 key 所在的Segment ,然後從主記憶體中取出最新的節點
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//若Segment不為空,且連結串列也不為空,則遍歷查詢節點
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
//找到則返回它的 value 值,否則返回 null
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
remove方法
remove方法與put類似,tryLock裡呼叫的是scanAndLock方法,唯一的區別是無需預先建立節點,所以更加簡單。
public V remove(Object key) {
int hash = hash(key);
//定位到Segment
Segment<K,V> s = segmentForHash(hash);
//若 s為空,則返回 null,否則執行 remove
return s == null ? null : s.remove(key, hash, null);
}
public boolean remove(Object key, Object value) {
int hash = hash(key);
Segment<K,V> s;
return value != null && (s = segmentForHash(hash)) != null &&
s.remove(key, hash, value) != null;
}
final V remove(Object key, int hash, Object value) {
//嘗試加鎖,若失敗,則執行 scanAndLock ,此方法和 scanAndLockForPut 方法類似
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
//從主記憶體中獲取對應 table 的最新的頭結點
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
//匹配到 key
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
// value 為空,或者 value 也匹配成功
if (value == null || value == v || value.equals(v)) {
if (pred == null)
setEntryAt(tab, index, next);
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
size方法
在多執行緒的環境下,統計size的時候可能不同Segment的陣列元素也在不斷變化,因此size方法和單執行緒不一樣。這裡採用的是樂觀的做法,不加鎖地去測試至多3次,使用一個last引數去記住上一次迴圈的值。如果在當前迴圈的時候,獲得了sum == last
,說明這一次迴圈和上一次迴圈中途沒有出現併發,因此這個size就是正確的結果,直接返回。否則繼續重試,直到Retry次數達到閾值,再給所有Segment加鎖,再次統計準確的size。中間還有一個overflow的布林值,用於記錄結果是否int溢位。
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
//segment陣列
final Segment<K,V>[] segments = this.segments;
//統計所有Segment中元素的總個數
int size;
//如果size大小超過32位,則標記為溢位為true
boolean overflow;
//統計每個Segment中的 modcount 之和
long sum;
//上次記錄的 sum 值
long last = 0L;
//重試次數,初始化為 -1
int retries = -1;
try {
for (;;) {
//如果超過重試次數,則不再重試,而是把所有Segment都加鎖,再統計 size
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
//強制加鎖
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
//遍歷所有Segment
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
//若當前遍歷到的Segment不為空,則統計它的 modCount 和 count 元素個數
if (seg != null) {
//累加當前Segment的結構修改次數,如put,remove等操作都會影響modCount
sum += seg.modCount;
int c = seg.count;
//若當前Segment的元素個數 c 小於0 或者 size 加上 c 的結果小於0,則認為溢位
//因為若超過了 int 最大值,就會返回負數
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
//當此次嘗試,統計的 sum 值和上次統計的值相同,則說明這段時間內,
//並沒有任何一個 Segment 的結構發生改變,就可以返回最後的統計結果
if (sum == last)
break;
//不相等,則說明有 Segment 結構發生了改變,則記錄最新的結構變化次數之和 sum,
//並賦值給 last,用於下次重試的比較。
last = sum;
}
} finally {
//如果超過了指定重試次數,則說明表中的所有Segment都被加鎖了,因此需要把它們都解鎖
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
//若結果溢位,則返回 int 最大值,否則正常返回 size 值
return overflow ? Integer.MAX_VALUE : size;
}
程式碼if (sum == last) break;
,這裡的sum和last統計的是modCount,因為只要兩次迴圈的modCount相同,中間肯定就沒有任何併發修改導致的資料不一致問題,所以此時可以直接返回size作為最後的結果了。
JDK1.8的ConcurrentHashMap
JDK1.8的ConcurrentHashMap拋棄了分段鎖的概念,利用CAS+synchronized來實現執行緒同步,底層採用和HashMap一樣的資料結構,即陣列+連結串列+紅黑樹。在過去Synchronized一直是重量級鎖,但在JDK1.6開始,引入了偏向鎖,輕量級鎖,鎖升級的概念,使得synchronized的效率已經可以媲美Lock鎖,甚至是趕超。因此只要在HashMap的基礎上使用CAS和synchronized進行改進,就能改造成優秀的多執行緒版本。
既然synchronized已經得到了優化,為什麼Hashtable依然不能效率低下?原因有兩個,其一是Hashtable沒有紅黑樹的存在,其二是synchronized直接宣告在get和put方法開頭,因此即使synchronized效率升級,Hashtable整體的效率依然低下。
資料結構
ConcurrentHashMap有很多屬性都與HashMap是相同的,新增加的屬性有:
/* ---------------- Fields -------------- */
/**
* The array of bins. Lazily initialized upon first insertion.
* Size is always a power of two. Accessed directly by iterators.
*/
transient volatile Node<K,V>[] table;
/**
* The next table to use; non-null only while resizing.
*/
private transient volatile Node<K,V>[] nextTable;
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
/**
* The next table index (plus one) to split while resizing.
*/
private transient volatile int transferIndex;
/**
* Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
*/
private transient volatile int cellsBusy;
/**
* Table of counter cells. When non-null, size is a power of 2.
*/
private transient volatile CounterCell[] counterCells;
因為是在多執行緒環境下使用,所以這些變數都用了volatile修飾,CAS+volatile實現。在HashMap裡是沒有nextTable這個變數的,因為預設在單執行緒環境下使用,於是新擴容的table直接在resize裡建立。但多執行緒環境下,要確保多個執行緒同時resize時,只有一個成功建立並同步到記憶體裡,所以nextTable也要設定為全域性的volatile變數。
sizeCtl是一個狀態量,用於控制table的初始化和擴容。不同的值有不同的含義,-1表示正在初始化,-N表示有N - 1個活躍的執行緒正在進行resize。0表示table還沒有初始化,初始化完成後,sizeCtl會是一個正數。當陣列為null時,sizeCtl代表陣列初始化大小,當陣列不為null時,代表陣列的擴容閾值。後面三個引數會在put方法裡具體介紹。
put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
//可以看到,在併發情況下,key 和 value 都是不支援為空的。
if (key == null || value == null) throw new NullPointerException();
//這裡和1.8 HashMap 的hash 方法大同小異,只是多了一個操作,如下
//( h ^ (h >>> 16)) & HASH_BITS; HASH_BITS = 0x7fffffff;
// 0x7fffffff ,二進位制為 0111 1111 1111 1111 1111 1111 1111 1111 。
//所以,hash值除了做了高低位異或運算,還多了一步,保證最高位的 1 個 bit 位總是0。
//這裡,我並沒有明白它的意圖,僅僅是保證計算出來的hash值不超過 Integer 最大值,且不為負數嗎。
//同 HashMap 的hash 方法對比一下,會發現連原始碼註釋都是相同的,並沒有多說明其它的。
//我個人認為意義不大,因為最後 hash 是為了和 capacity -1 做與運算,而 capacity 最大值為 1<<30,
//即 0100 0000 0000 0000 0000 0000 0000 0000 ,減1為 0011 1111 1111 1111 1111 1111 1111 1111。
//即使 hash 最高位為 1(無所謂0),也不影響最後的結果,最高位也總會是0.
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();
//若表已經初始化,則找到當前 key 所在的桶,並且判斷是否為空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//若當前桶為空,則通過 CAS 原子操作,把新節點插入到此位置,
//這保證了只有一個執行緒可以 CAS 成功,其它執行緒都會失敗。
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//若所在桶不為空,則判斷節點的 hash 值是否為 MOVED(值是-1)
else if ((fh = f.hash) == MOVED)
//若為-1,說明當前陣列正在進行擴容,則需要當前執行緒幫忙遷移資料
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//這裡用加同步鎖的方式,來保證執行緒安全,給桶中第一個節點物件加鎖
synchronized (f) {
//recheck 一下,保證當前桶的第一個節點無變化,後邊很多這樣類似的操作,不再贅述
if (tabAt(tab, i) == f) {
//如果hash值大於等於0,說明是正常的連結串列結構
if (fh >= 0) { // fh (first hash value)
binCount = 1; // binCount記錄連結串列的元素個數
//從頭結點開始遍歷,每遍歷一次,binCount計數加1
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果找到了和當前 key 相同的節點,則用新值替換舊值
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;
}
}
}
//否則判斷是否是樹節點。這裡提一下,TreeBin只是頭結點對TreeNode的再封裝
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;
}
}
}
}
//注意下,這個判斷是在同步鎖外部,因為 treeifyBin內部也有同步鎖,無需擔心執行緒同步的問題
if (binCount != 0) {
//如果節點個數大於等於 8,則轉化為紅黑樹
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
//把舊節點值返回
if (oldVal != null)
return oldVal;
break;
}
}
}
//給元素個數加 1,並有可能會觸發擴容,比較複雜,稍後細講,記住第二個引數是連結串列/樹的元素個數
addCount(1L, binCount);
return null;
}
逐行來看:
第一行:if (key == null || value == null) throw new NullPointerException();
這也印證了前面的內容,ConcurrentHashMap不允許key或value為null值。
第二行:int hash = spread(key.hashCode());
與HashMap是基本一致的,只是spread方法稍微有點不一樣,如下:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
HASH_BITS的值為0x7fffffff,所以這個與操作只會導致一個結果:返回的hash值第一位必定為0,作用是使得hash值必定不會超過Integer的最大值,並且不為負數。看起來沒什麼意義,因為後續hash都是要和capacity - 1進行與操作的,而capacity的最大值為1 << 30。但後續我們會看到,ConcurrentHashMap裡設定了三個特殊的負數hash值,分別代表不同的含義,因此普通的節點就設定為非負數了。
第三行:for (Node<K,V>[] tab = table;;)
注意,這裡根本就不是table的遍歷,不要看到for迴圈就習慣性地覺得,啊這裡是對table的遍歷。table是一個雜湊陣列,這裡只是一個賦值+無限迴圈,我們後續會根據hash值定位到具體的下標,也就是具體的Node<K, V>。要明確這裡的含義,可以和HashMap的put方法進行對比一下,就清晰了。
第四行:
if (tab == null || (n = tab.length) == 0)
//初始化表,只有一個執行緒可以初始化成功。
tab = initTable();
與HashMap同理,在構造方法裡並不會初始化table,而是在第一次put操作的時候,檢測到table為null,才會進行對table的初始化(懶漢式載入)。initTable方法先放著,後面再看它的程式碼。
第五行:
//若表已經初始化,則找到當前 key 所在的桶,並且判斷是否為空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//若當前桶為空,則通過 CAS 原子操作,把新節點插入到此位置,
//這保證了只有一個執行緒可以 CAS 成功,其它執行緒都會失敗。
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
程式碼(f = tabAt(tab, i = (n - 1) & hash))
,這裡就是根據hash值定位到具體的bucket桶中。這時候就是要把資料put到該桶中,首先判斷該桶是否為空,如果空,那麼就進行CAS自旋嘗試put操作。只要put成功,那麼此處就執行break跳出for迴圈,表明put操作完成。同時CAS也保證了只有一個執行緒可以CAS成功,避免因為執行緒同步導致的更新丟失問題。
第六行:
//若所在桶不為空,則判斷節點的 hash 值是否為 MOVED(值是-1)
else if ((fh = f.hash) == MOVED)
//若為-1,說明當前陣列正在進行擴容,則需要當前執行緒幫忙遷移資料
tab = helpTransfer(tab, f);
執行到這裡,說明表已經初始化,而且bucket也不為null,但此時並不是直接開始遍歷該bucket/Node/連結串列,而是要先判斷table是否正在進行擴容。如果是,那麼呼叫helpTransfer方法幫助擴容。避免其他執行緒的put操作引發了resize,此時當前執行緒也應該去幫忙擴容,繼續CAS自旋進行put操作,只會一直失敗(其他執行緒resize會改變頭節點,index也會發生改變,從而put操作無法執行完成,或者執行的結果會是錯誤的)
第七行:
else {
V oldVal = null;
// 這裡用加同步鎖的方式,來保證執行緒安全,給桶中第一個節點物件加鎖
synchronized (f) {
// recheck 一下,保證當前桶的第一個節點無變化,後邊很多這樣類似的操作,不再贅述
if (tabAt(tab, i) == f) {
//如果hash值大於等於0,說明是正常的連結串列結構
if (fh >= 0) {
binCount = 1;
//從頭結點開始遍歷,每遍歷一次,binCount計數加1
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果找到了和當前 key 相同的節點,則用新值替換舊值
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) {
// ...
}
}
}
執行到這裡,說明table已經初始化,bucket也不會null,也沒有處於resize狀態,那麼就跟HashMap的時候一樣,遍歷連結串列(bucket)。如果找到了相同的key節點,則替換掉舊值,返回舊值oldValue。如果遍歷到了連結串列尾部也沒有找到相同的key節點,那麼就直接把新節點用尾插法進行插入,此時oldValue為null。但在這之前,首先要進行一次recheck,檢視bucket是否發生了變化,如果發生了變化,那麼此次synchronized加鎖就沒有意義,因為當前的資料是dirty data。所以會直接跳出synchronized塊,然後再次進行一次for迴圈,獲取到了新的f值再嘗試用synchronized加鎖。當我們確定了bucket沒有在中途被修改後,還要先判斷一下當前的節點是連結串列節點還是樹節點。
這裡我糾結了很久,為什麼fh >= 0
就是連結串列了?我想到了spread方法裡把第一位置為0,使得hash必定為非負數的操作。那麼fh小於0說明是什麼呢?如下:
/*
* Encodings for Node hash fields. See above for explanation.
*/
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
我們前面已經看到了MOVED表示要擴容,而-2表示紅黑樹的根節點,-3表示transient reservations(不知道是啥),所以當fh小於0的時候,它還不一定是紅黑樹節點,所以仍然要再使用instanceof來判斷是否為TreeBin。紅黑樹的部分依然先pass,後面再補。。。
第八行:
//注意下,這個判斷是在同步鎖外部,因為 treeifyBin內部也有同步鎖,並不影響
if (binCount != 0) {
//如果節點個數大於等於 8,則轉化為紅黑樹
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
//把舊節點值返回
if (oldVal != null)
return oldVal;
break;
}
如果binCount為0,說明沒有進入synchronized體裡,繼續自旋。如果不為0,說明已經執行了synchronized內的程式碼,判斷是否需要樹化,再判斷oldVal是否為null。如果oldVal不為null,說明在bucket裡找到了相同的key並且進行了value的替代,此時直接返回oldVal。否則用break跳出for迴圈,執行到最後一行程式碼。
第九行:
//給元素個數加 1,並有可能會觸發擴容,比較複雜,稍後細講
addCount(1L, binCount);
return null;
最後就是呼叫addCount,使得元素的個數加1。在單執行緒環境下很簡單,但在多執行緒下挺複雜的,後面再講。
總而言之,這就是put的基本流程:
①如果table還沒有初始化,就先呼叫initTable進行初始化
②如果table已經初始化,定位到相應的Node,檢視Node是否為null。如果Node為null,那麼就不存在hash衝突,直接CAS自旋。
③如果Node不為null,那麼要先根據桶頭節點f的hash值判斷table是否處於擴容狀態,如果是,呼叫helpTransfer
④如果沒有處於擴容狀態,根據頭節點f的hash值,區分到底是連結串列節點還是樹節點,然後進行遍歷。遍歷過程中如果找到相同key的節點,則進行value的更新即可。如果到達尾部還沒有找到,進行尾插法。同時根據oldValue是否為null,決定是否要修改元素的個數,即是否會到達addCount方法。
initTable方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//迴圈判斷表是否為空,直到初始化成功為止。
while ((tab = table) == null || tab.length == 0) {
//sizeCtl 這個值有很多情況,預設值為0,
//當為 -1 時,說明有其它執行緒正在對錶進行初始化操作
//當表初始化成功後,又會把它設定為擴容閾值
//當為一個小於 -1 的負數,用來表示當前有幾個執行緒正在幫助擴容(後邊細講)
if ((sc = sizeCtl) < 0)
//若 sc 小於0,其實在這裡就是-1,因為此時表是空的,不會發生擴容
//因此,當前執行緒放棄 CPU 時間片,只是自旋。
Thread.yield(); // lost initialization race; just spin
//通過 CAS 把 SIZECTL 的值設定為-1,表明當前執行緒正在進行表的初始化,其它失敗的執行緒就會自旋
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//重新檢查一下表是否為空
if ((tab = table) == null || tab.length == 0) {
//如果sc大於0,則為sc,否則返回預設容量 16。
//當呼叫有參構造建立 Map 時,sc的值是大於0的。
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//建立陣列
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//n減去 1/4 n ,即為 0.75n ,表示擴容閾值
sc = n - (n >>> 2);
}
} finally {
//更新 sizeCtl 為擴容閾值
sizeCtl = sc;
}
//若當前執行緒初始化表成功,則跳出迴圈。其它自旋的執行緒因為判斷陣列不為空,也會停止自旋
break;
}
}
return tab;
}
initTable的邏輯並不難,主要就是通過sizeCtl來控制哪個執行緒進行初始化。各個執行緒進行CAS操作,如果成功執行完CAS,那麼此時sizeCtl的值就從0置為-1,其他執行緒就會自旋(一直在while和yield)。而對於成功執行完CAS的執行緒,則會進入try語句塊。首先還是要進行一次recheck,因為可能其他執行緒剛建立完,並且已經CAS執行完更新,這時候當前執行緒才剛剛把SIZECTL修改,進入try塊,如果重複建立就浪費資源了。recheck之後如果table依然為null,那麼就進行建立:Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
。這裡要提一下Unsafe裡的compareAndSwapInt方法,它的定義如下:
/**
* Atomically update Java variable to <tt>x</tt> if it is currently
* holding <tt>expected</tt>.
* @return <tt>true</tt> if successful
*/
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
如果offset的值為expected,那麼把x原子性地賦值給offset,返回true,反之賦值失敗,返回false。
所以對於這一行程式碼else if (U.compareAndSwapInt(this, SIZECTL, sc, -1))
,只有一個執行緒能成功獲取到樂觀鎖,將SIZECTL賦值為-1並進入try塊,而其他執行緒會因為SIZECTL已經被賦值為1,一直yield,也就是自旋。對於進入了try塊的執行緒,此時sc並不為-1,如果當使用有參構造器建立Map的時候,此時sc代表initialCapacity,sc最後會記錄table的擴容閾值:sc = n - (n >>> 2);
。最後再更新sizeCtl的值,注意這個sizeCtl是Map裡的一個volatile變數。之後其他執行緒會結束自旋,進入try塊,但在double-check裡發現Map已經構造完成,因此不會重複建立。
(initTable的CAS操作,關鍵不要混淆sizeCtl和sc,我一開始搞混了,還在納悶為什麼sc會是正數,不是應該穩定-1嗎)
addCount方法
addCount的目標很明確,就是要改變整個Map的元素個數,即一次put,使得size加一。對於HashMap來說,單執行緒環境下直接修改size變數即可。而對於ConcurrentHashMap的多執行緒環境下,很容易想到,把size修改為volatile,保證可見性,然後使用CAS進行自增。這確實可行,但JDK裡並不是這麼實現的。
當有多個執行緒進行了put,此時size變數就會造成很嚴重的競爭,直接volatile+CAS的效率很低。如果不考慮樂觀鎖,直接轉為悲觀鎖,那麼效率就更低了。JDK裡是把每一個競爭的執行緒分散到不同的物件裡,在該物件裡單獨計算每一個執行緒的size變化,最後要統計size的時候再一起相加。這個思想有點像1.7分段鎖時的做法,但1.8已經放棄了分段鎖,所以這裡的物件(CounterCell)僅僅是用作計算size。如下:
//執行緒被分配到的格子
@sun.misc.Contended static final class CounterCell {
//此格子內記錄的 value 值
volatile long value;
CounterCell(long x) { value = x; }
}
//用來儲存執行緒和執行緒生成的隨機數的對應關係
static final int getProbe() {
return UNSAFE.getInt(Thread.currentThread(), PROBE);
}
CounterCell的定義僅僅只有一個volatile變數,只為了計數的小單元(Cell)。同時這個類新增了註解:@sun.misc.Contended,這是一個避免偽造共享的註解,用於替代以前的快取行填充,在多執行緒環境下可以提高效能。
而getProbe是給當前執行緒生成一個隨機數,可以簡單地理解為生成了一個hash值,後續要用來和陣列長度取模,計算它所在CounterCells陣列的下標位置。
在addCount方法裡,baseCount就是size,而CounterCell,cellsBusy,cellValue等都是輔助變數。
// x為1,check代表連結串列上的元素個數
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//此處要進入if有兩種情況
//1.陣列不為空,說明陣列已經被建立好了。
//2.若陣列為空,說明陣列還未建立,很有可能競爭的執行緒非常少,因此就直接 CAS 操作 baseCount
//若 CAS 成功,則方法跳轉到 (2)處,若失敗,則需要考慮給當前執行緒分配一個格子(指CounterCell物件)
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
//字面意思,是無競爭,這裡先標記為 true,表示還沒有產生執行緒競爭
boolean uncontended = true;
//這裡有三種情況,會進入 fullAddCount 方法
//1.若陣列為空,進方法 (1)
//2.ThreadLocalRandom.getProbe() 方法會給當前執行緒生成一個隨機數(可以簡單的認為也是一個hash值)
//然後用隨機數與陣列長度取模,計算它所在的格子。若當前執行緒所分配到的格子為空,進方法 (1)。
//3.若陣列不為空,且執行緒所在格子不為空,則嘗試 CAS 修改此格子對應的 value 值加1。
//若修改成功,則跳轉到 (3),若失敗,則把 uncontended 值設為 fasle,說明產生了競爭,然後進方法 (1)
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
//方法(1), 這個方法的目的是讓當前執行緒一定把 1 加成功。情況更多,更復雜,稍後講。
fullAddCount(x, uncontended);
return;
}
// (3)能走到這,說明陣列不為空,且修改 baseCount失敗,
// 且執行緒被分配到的格子不為空,且修改 value 成功。
// check引數是在putVal裡的binCount
// binCount == 1說明是連結串列,且替換了head節點的val值
// 或者是陣列對應下標的連結串列為空,然後新增了新的head節點
// 無論是哪一種,都不需要擴容
if (check <= 1)
return;
//計算總共的元素個數
s = sumCount();
}
//(2)這裡用於檢查是否需要擴容(先跳過,先看後面的transfer方法
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//若元素個數達到擴容閾值(即map新增的節點數大於等於sizeCtl
//且tab不為空,且tab陣列長度小於最大容量
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//這裡假設陣列長度n就為16,這個方法返回的是一個固定值rs,用於當做一個擴容的校驗標識
int rs = resizeStamp(n);
//若sc小於0,說明正在擴容
if (sc < 0) {
// 此處有bug,我們只需要知道:當前桶陣列正在擴容,但當前執行緒無需幫助擴容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//到這裡說明當前執行緒可以幫助擴容,因此sc值加一,代表擴容的執行緒數加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 當sc大於0,說明sc代表擴容閾值,因此第一次擴容之前肯定走這個分支,用於初始化新表 nextTable
// 此時會把sc賦值為(rs << RESIZE_STAMP_SHIFT) + 2,是一個標記值,表示首個幫助擴容的執行緒
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//擴容,第二個引數代表新表,傳入null,則說明是第一次初始化新表(nextTable)
transfer(tab, null);
s = sumCount();
}
}
}
//計算表中的元素總個數
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
//baseCount,以這個值作為累加基準
long sum = baseCount;
if (as != null) {
//遍歷 counterCells 陣列,得到每個物件中的value值
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
//累加 value 值
sum += a.value;
}
}
//此時得到的就是元素總個數
return sum;
}
//擴容時的校驗標識(先跳過
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
addCount方法判斷具體的情況,然後做出相應的應對方法。
①:當陣列為null,此時直接進入第一個if體。如果陣列不為null,此時陣列還沒有建立,那麼很可能競爭的執行緒比較少,因此直接CAS嘗試操作baseCount。如果CAS成功,那麼說明baseCount已經成功自增,接下來就是跳轉到檢查是否擴容的部分。如果CAS失敗,那麼也進入第一個if體。第一個if體裡依然會分不同的情況做出不同的選擇。
②:進入第一個if體之後,如果CounterCells陣列為null,說明在①的時候CAS執行失敗,即遇到了併發問題,此時會進入fullAddCount方法。如果CounterCells陣列不為null,但相應下標的物件為null(a = as[ThreadLocalRandom.getProbe() & m]
),此時也會進入fullAddCount方法。如果相應下標的物件不為null,就嘗試CAS修改此格子對應的value,如果修改成功,那麼跳轉到檢查是否擴容的部分。如果CAS失敗,同樣是呼叫fullAddCount方法。直到這裡可以看到,①和②主要是對於併發情況可能不太嚴重的時候直接進行一次CAS,即使有併發問題,但只要情況不算嚴重,那麼CAS的成本並不算高,可如果CAS成功了就能節省一大筆資源。如果CAS失敗,會跳轉到fullAddCount方法,此方法就是使用CounterCells陣列,單獨計算每一個執行緒對應格子的值,最後再進行相加得到size(baseCount)。
③:擴容部分我們先跳過,檢視fullAddCount方法。
fullAddCount方法
fullAddCount方法名的意思,要全力增加計算值,一定要成功。可能有部分已經在前面CAS成功了,但我們要確保全部執行緒都已經修改為正確的值,因此要對剩下這部分進入了fullAddCount方法對執行緒單獨地修改它的值。
//傳過來的引數分別為 1 , false
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
//如果當前執行緒的隨機數為0,則強制初始化一個值
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
//此時把 wasUncontended 設為true,認為無競爭
wasUncontended = true;
}
//用來表示比 contend(競爭)更嚴重的碰撞,若為true,表示可能需要擴容,以減少碰撞衝突
boolean collide = false; // True if last slot nonempty
//迴圈內,外層if判斷分三種情況,內層判斷又分為六種情況
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
//1. 若counterCells陣列不為空。 建議先看下邊的2和3兩種情況,再回頭看這個。
if ((as = counterCells) != null && (n = as.length) > 0) {
// (1) 若當前執行緒所在的格子(CounterCell物件)為空
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) {
//若無鎖,則樂觀的建立一個 CounterCell 物件。
CounterCell r = new CounterCell(x);
//嘗試加鎖
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
//加鎖成功後,再 recheck 一下陣列是否不為空,且當前格子為空
try {
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;
}
//若當前格子建立成功,且上邊的賦值成功,則說明加1成功,退出迴圈
if (created)
break;
//否則,繼續下次迴圈
continue; // Slot is now non-empty
}
}
//若cellsBusy=1,說明有其它執行緒搶鎖成功。或者若搶鎖的 CAS 操作失敗,都會走到這裡,
//則當前執行緒需跳轉到(9)重新生成隨機數,進行下次迴圈判斷。
collide = false;
}
/**
*後邊這幾種情況,都是陣列和當前隨機到的格子都不為空的情況。
*且注意每種情況,若執行成功,且不break,continue,則都會執行(9),重新生成隨機數,進入下次迴圈判斷
*/
// (2) 到這,說明當前方法在被呼叫之前已經 CAS 失敗過一次,若不明白可回頭看下 addCount 方法,
//為了減少競爭,則跳轉到⑨處重新生成隨機數,並把 wasUncontended 設定為true ,認為下一次不會產生競爭
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// (3) 若 wasUncontended 為 true 無競爭,則嘗試一次 CAS。若成功,則結束迴圈,若失敗則判斷後邊的 (4)(5)(6)。
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
// (4) 結合 (6) 一起看,(4)(5)(6)都是 wasUncontended=true,且CAS修改value失敗的情況。
//若陣列有變化,或者陣列長度大於等於當前CPU的核心數,則把 collide 改為 false
//因為陣列若有變化,說明是由擴容引起的;長度超限,則說明已經無法擴容,只能認為無碰撞。
//這裡很有意思,認真思考一下,當擴容超限後,則會達到一個平衡,即 (4)(5) 反覆執行,直到 (3) 中CAS成功,跳出迴圈。
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
// (5) 若陣列無變化,且陣列長度小於CPU核心數時,且 collide 為 false,就把它改為 true,說明下次迴圈可能需要擴容
else if (!collide)
collide = true;
// (6) 若陣列無變化,且陣列長度小於CPU核心數時,且 collide 為 true,說明衝突比較嚴重,需要擴容了。
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
//recheck
if (counterCells == as) {// Expand table unless stale
//建立一個容量為原來兩倍的陣列
CounterCell[] rs = new CounterCell[n << 1];
//轉移舊陣列的值
for (int i = 0; i < n; ++i)
rs[i] = as[i];
//更新陣列
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
//認為擴容後,下次不會產生衝突了,和(4)處邏輯照應
collide = false;
//當次擴容後,就不需要重新生成隨機數了
continue; // Retry with expanded table
}
// (9),重新生成一個隨機數,進行下一次迴圈判斷
h = ThreadLocalRandom.advanceProbe(h);
}
//2.這裡的 cellsBusy 引數非常有意思,是一個volatile的 int值,用來表示自旋鎖的標誌,
//可以類比 AQS 中的 state 引數,用來控制鎖之間的競爭,並且是獨佔模式。簡化版的AQS。
//cellsBusy 若為0,說明無鎖,執行緒都可以搶鎖,若為1,表示已經有執行緒拿到了鎖,則其它執行緒不能搶鎖。
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try {
//這裡再重新檢測下 counterCells 陣列引用是否有變化
if (counterCells == as) {
//初始化一個長度為 2 的CounterCell陣列
CounterCell[] rs = new CounterCell[2];
//根據當前執行緒的隨機數值,計算下標,只有兩個結果 0 或 1,並初始化物件
rs[h & 1] = new CounterCell(x);
//更新陣列引用
counterCells = rs;
//初始化成功的標誌
init = true;
}
} finally {
//別忘了,需要手動解鎖。
cellsBusy = 0;
}
//若初始化成功,則說明當前加1的操作也已經完成了,則退出整個迴圈。
if (init)
break;
}
//3.到這,說明陣列為空,且 2 搶鎖失敗,則嘗試直接去修改 baseCount 的值,
//若成功,也說明加1操作成功,則退出迴圈。
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
首先開頭判斷執行緒getProbe返回的隨機數是否為0,如果是0,那麼要呼叫localInit再一次進行初始化。因為在ThreadLocalRandom類中,0代表特殊的含義,表示未初始化。
之後進入迴圈體for(;;)
裡,根據不同情況做出不同選擇。主要用到了4個變數:
cellsBusy:一個volatile的int值,用於表示自旋鎖的標誌,可以類比為AQS中的state引數,用來控制鎖之間的競爭,並且是獨佔模式,簡化版的AQS。cellsBusy如果為0,表示無鎖,執行緒可以進行搶佔。如果為1,表示已經有執行緒拿到了鎖,其他執行緒進入自旋。
cellValue:cellValue顧名思義,就是CounterCell裡的值,所以fullAddCount方法最終需要做的就是修改cellValue的值。
wasUncontended:一個boolean值,表示是否發生了競爭,預設是true,表示無競爭。競爭是指CounterCell物件為null,但CAS建立的時候發生衝突。
collide:一個boolean值,表示是否發生了碰撞,情況比contend要嚴重。如果為true,表示可能需要擴容,以減少碰撞衝突。碰撞時指CounterCell物件不為null,但CAS修改時發生衝突。需要區分wasUncontended和collide。
第一個if體裡的6種情況:
①如果CounterCells陣列不為null,並且執行緒隨機到的下標格子(CounterCell物件)為null。
此時直接建立了一個CounterCell物件,然後嘗試加鎖,把新建立的CounterCell物件賦值到對應的格子。這裡是先建立,再嘗試加鎖,是一種比較樂觀的狀態。如果加鎖成功,那麼格子賦值成功,已經到達了+1的效果,此時可以直接break退出迴圈了(原本是0,建立CounterCell的時候傳入int值,表示要修改的值即可,一般是1),最後再在finally裡釋放鎖即可。如果加鎖失敗,那麼會回到迴圈的開始,但值得注意的是,collide依然會賦值為false,因為CounterCell物件為null,說明不存在衝突問題,此時只是CAS自旋而已,collide狀態量的官方註釋是:True if last slot nonempty
後面的幾種情況,都是CounterCells陣列不為null,且執行緒隨機到的下標格子也不為null。
②else if (!wasUncontended)
,表明當前方法在被呼叫之前已經CAS失敗過一次。為了減少競爭,此時會跳轉到最後重新生成隨機數,並且把wasUncontended設定為true(都已經非空了,wasUncontended是CAS建立時衝突,因此wasUncontended已經不會再是限制)
③ else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
,執行到這裡,說明wasUncontended為true,無競爭,那麼會嘗試一次CAS(執行完②之後,①跟②的情況都不會再出現)。如果成功,break結束迴圈,否則繼續後面的④⑤⑥幾種情況,④⑤⑥都表示wasUncontended為true,且在③中都CAS修改失敗的情況。
④ else if (counterCells != as || n >= NCPU)
,如果陣列發生了變化,或者陣列的長度大於等於當前CPU的核心數,則把collide改為false。陣列發生了變化,說明此時的資料是stale的,那麼就直接把狀態量reset之後重試。如果陣列長度大於等於CPU核心數,那麼此時的陣列長度已經到達了最大值,就不能繼續擴容了。無論是哪種情況,collide都應該設定為false,表示無衝突。一種是資料已經改變,無法判斷是否衝突,另一種是資料已經沒有辦法再擴容了,而collide就是用於擴容的,只能設定為false禁止繼續擴容。
⑤ else if (!collide)
,如果陣列無變化,且陣列長度小於CPU核心數時,且collide為false,把它改為true。和④完全相反,此時表明CounterCells的資料沒有發生變化,是實時的可用資料,並且n小於NCPU,但③的CAS依然失敗,此時就是發生了碰撞。從②開始,CounterCell物件就不為null,並且CAS失敗,而且也不是情況④中的特殊情況,因此此時發生碰撞就直接修改collide值,以求擴容。
⑥ else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1))
,如果陣列沒有變化,且陣列長度小於CPU核心數,且collide為true,說明衝突比較嚴重,需要擴容了。
④⑤⑥是聯合起來的三種情況。如果發生碰撞,那麼就呼叫⑥來擴容。如果已經擴容到達上限仍然一直碰撞,那隻能④和⑤反覆執行,直到③的CAS成功,跳出迴圈。還有一些細節是④和⑤無需continue,此時它會執行到h = ThreadLocalRandom.advanceProbe(h);
,重新生成一個隨機數,而情況⑥擴容之後,就不需要重新生成隨機數了。
第一個if體的6種情況的邏輯大概就是這樣,看起來很複雜其實也是循序漸進。情況①先處理CounterCell物件為null的情況,那麼直接CAS建立。情況②表示當前方法已經用CAS失敗過一次(要麼是addCount方法裡,要麼是情況①時候的失敗),但此時終究已經不符合①,因此認為不會再發生競爭(不會再有CAS建立的衝突),但會發生碰撞。情況③就是CAS自旋,嘗試修改cellValue。情況④⑤⑥就是考慮發生碰撞後是否需要擴容。(記住wasUncontended和collide的區別)
至此,第一個if體的6種情況已經整理完畢,接下來是剩下的兩個else if的情況。這兩個相比而言就簡單很多,因為前面都是代表CounterCells陣列非空的情況,所以剩下的兩個判定,一個用於搶佔鎖,初始化CounterCells陣列,並且就和前面的情況①一樣,可以在賦值的時候就設定好value。
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try {
//這裡再重新檢測下 counterCells 陣列引用是否有變化
if (counterCells == as) {
//初始化一個長度為 2 的陣列
CounterCell[] rs = new CounterCell[2];
//根據當前執行緒的隨機數值,計算下標,只有兩個結果 0 或 1,並初始化物件
rs[h & 1] = new CounterCell(x);
//更新陣列引用
counterCells = rs;
//初始化成功的標誌
init = true;
}
} finally {
//別忘了,需要手動解鎖。
cellsBusy = 0;
}
//若初始化成功,則說明當前加1的操作也已經完成了,則退出整個迴圈。
if (init)
break;
}
如果搶佔鎖失敗,建立CounterCells失敗,那麼就直接嘗試去修改baseCount的值,一般我們自己設計可能就直接用這一步了,然後高併發的情況下競爭會導致效率極其低下。但JDK裡是在前面設定好了很多種情況,到達這裡只是嘗試一下,“最後的倔強”:
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
transfer方法
前面的putVal方法和addCount方法,我們看到中間可能會呼叫兩個方法,transfer和helpTransfer,目標是用於擴容,其實更準確的說法應該是幫助遷移元素。建立一個雙倍長度的table很簡單,更關鍵的是把原本table的資料全部遷移到新的nextTable裡。在單執行緒環境下的HashMap是如何做的?直接在需要擴容的時候呼叫resize方法即可。但多執行緒環境下的ConcurrentHashMap,會有多個執行緒同時put,然後新put的資料應該如何新增到擴容後的nextTable裡呢?最簡單的做法依然是直接加鎖,或者volatile+CAS自旋,但顯然二者都效率低下。JDK裡的做法是,一個執行緒正在處於put,為什麼要把這些新的資料留到後面再一起轉移呢,可以每一個執行緒單獨地加入到擴容方法裡,幫助資料轉移(helpTransfer),這樣既實現了多個執行緒快速進行擴容,同時無需重新建立執行緒,直接利用現有的執行緒進行put+transfer。helpTransfer實際上也是判定是否可以進入transfer幫助轉移資料,所以先來看transfer方法。
資料遷移的示意圖:
每一個新加入的執行緒都會從length - 1開始幫助遷移資料,也就是從陣列的最後。每一個執行緒負責的資料也是有範圍的,由變數stride指定,即一個執行緒在一次transfer中只負責stride個資料(這裡一個資料就是陣列上的一個Node,即一個連結串列/桶)。這樣可以使得每一個執行緒都負責定量的資料,避免單個執行緒承擔太多。對於已經遷移完成的資料,會標記為ForwardingNode,表示該資料(陣列中的某個下標位置)中的元素(連結串列裡的所有節點)已經全部遷移完畢,此時新加入的執行緒就不會遷移這些資料,而是繼續向前推進(advance),尋找其他可以遷移的資料。如上圖所示,我們假設stride為2(實際上預設是16,但這裡便於演示)。A執行緒是第一個加入transfer的,那麼它負責的就是index為7的資料和index為6的資料,一共負責stride(2)個資料。B執行緒隨後加入transfer,發現index7的資料已經有其他執行緒在幫助遷移,那麼就會繼續向前尋找,而且也不是i–,而是i -= stride
。因此B執行緒負責的是index為5和index為4的資料。可以看到,當一個位置的資料已經被處理完畢,或者正在被其他執行緒幫忙處理,此時新加入的執行緒都直接向前推進,尋找下一個需要幫助遷移的資料。
如果A執行緒把index6和index7的資料遷移完畢,它會繼續向前幫忙,而它檢測到index4和index5有其他執行緒在幫忙,因此它會幫忙index為2和index為3的資料。即每個執行緒遷移完它負責範圍內的資料,都會繼續向前推進。終止條件用到了一個全域性變數transferIndex,來表示所有執行緒總共推進到的元素下標位置。對於上圖的例子,此時transferIndex為2。直到transferIndex為0,說明每一個資料都已經有執行緒在幫忙處理了。但transferIndex為0只能說明所有資料都有執行緒在遷移,但不能說明所有資料都已經遷移完畢,所以後續還有其他校驗來判斷是否所有資料遷移完畢。先看程式碼:
//這個類是一個標誌,用來代表當前桶(陣列中的某個下標位置)的元素已經全部遷移完成
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
//把當前桶的頭結點的 hash 值設定為 -1,表明已經遷移完成,
//這個節點中並不儲存有效的資料
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
//遷移資料
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//根據當前CPU核心數,確定每次推進的步長,最小值為16.(為了方便我們以2為例)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range 此處預設是16
//從 addCount 方法,只會有一個執行緒跳轉到這裡,初始化新陣列
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 指代新陣列
nextTable = nextTab;
//這裡就把推進的下標值初始化為原陣列長度(以16為例)
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 代表當前執行緒正在遷移的桶的下標,bound代表它本次可以遷移的範圍下限
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//需要向前推進
while (advance) {
int nextIndex, nextBound;
//(1) 先看 (3) 。i每次自減 1,直到 bound。若超過bound範圍,或者finishing標誌為true,則不用向前推進。
//若未全部完成遷移,且 i 並未走到 bound,則跳轉到 (7),處理當前桶的元素遷移。
if (--i >= bound || finishing)
advance = false;
//(2) 每次執行,都會把 transferIndex 最新的值同步給 nextIndex
//若 transferIndex小於等於0,則說明原陣列中的每個桶位置,都有執行緒在處理遷移了,
//於是,需要跳出while迴圈,並把 i設為 -1,以跳轉到④判斷在處理的執行緒是否已經全部完成。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//(3) 第一個執行緒會先走到這裡,確定它的資料遷移範圍。(2)處會更新 nextIndex為 transferIndex 的最新值
//因此第一次 nextIndex=n=16,nextBound代表當次遷移的資料範圍下限,減去步長即可,
//所以,第一次時,nextIndex=16,nextBound=16-2=14。後續,每次都會間隔一個步長。
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//bound代表當次資料遷移下限
bound = nextBound;
//第一次的i為15,因為長度16的陣列,最後一個元素的下標為15
i = nextIndex - 1;
//表明不需要向前推進,只有當把當前範圍內的資料全部遷移完成後,才可以向前推進
advance = false;
}
}
//(4)
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//若全部執行緒遷移完成
if (finishing) {
nextTable = null;
//更新table為新表
table = nextTab;
//擴容閾值改為原來陣列長度的 3/2 ,即新長度的 3/4,也就是新陣列長度的0.75倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//到這,說明當前執行緒已經完成了自己的所有遷移(無論參與了幾次遷移),
//則把 sc 減1,表明參與擴容的執行緒數減少 1。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//在 addCount 方法最後,我們強調,遷移開始時,會設定 sc=(rs << RESIZE_STAMP_SHIFT) + 2
//每當有一個執行緒參與遷移,sc 就會加 1,每當有一個執行緒完成遷移,sc 就會減 1。
//因此,這裡就是去校驗當前 sc 是否和初始值是否相等。相等,則說明全部執行緒遷移完成。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//只有此處,才會把finishing 設定為true。
finishing = advance = true;
//這裡非常有意思,會把 i 從 -1 修改為16,
//目的就是,讓 i 再從後向前掃描一遍陣列,檢查是否所有的桶都已被遷移完成,參看 (6)
i = n; // recheck before commit
}
}
//(5) 若i的位置元素為空,則說明當前桶的元素已經被遷移完成,就把頭結點設定為fwd標誌。
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//(6) 若當前桶的頭結點是 ForwardingNode ,說明遷移完成,則向前推進
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
//(7) 處理當前桶的資料遷移。
else {
synchronized (f) { //給頭結點加鎖
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//若hash值大於等於0,則說明是普通連結串列節點
if (fh >= 0) {
int runBit = fh & n;
//這裡是 1.7 的 CHM 的 rehash 方法和 1.8 HashMap的 resize 方法的結合體。
//會分成兩條連結串列,一條連結串列和原來的下標相同,另一條連結串列是原來的下標加陣列長度的位置
//然後找到 lastRun 節點,從它到尾結點整體遷移。
//lastRun前邊的節點則單個遷移,但是需要注意的是,這裡是頭插法。
//另外還有一點和1.7不同,1.7 lastRun前邊的節點是複製過去的,而這裡是直接遷移的,沒有複製操作。
//所以,最後會有兩條連結串列,一條連結串列從 lastRun到尾結點是正序的,而lastRun之前的元素是倒序的,
//另外一條連結串列,從頭結點開始就是倒敘的。看下圖。
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);
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;
}
}
}
}
}
}
程式碼很長,但我們會逐步擊破!首先是ForwardingNode,可以看到只有一個成員變數,它會設定nextTable的頭節點為MOVED,用於表示該位置的元素已經遷移完畢。
接著就是transfer方法,先是宣告瞭一些變數,初始化stride的值,而且對於第一個進入transfer方法的執行緒,還會初始化新的陣列(nextTab),而後續的執行緒直接使用此陣列。如果回到addCount方法,會看到幫助擴容的執行緒程式碼是transfer(tab, nt)
,但有一處是transfer(tab, null)
,表明是第一次初始化新表nextTable。
接著是幾個重要的變數,fwd,advance,finishing:
//建立一個標誌類
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//是否向前推進的標誌
boolean advance = true;
//是否所有執行緒都全部遷移完成的標誌
boolean finishing = false; // to ensure sweep before committing nextTab
接著是一個for迴圈,是一個死迴圈,直到所有資料遷移完畢:for (int i = 0, bound = 0;;)
。接著跳入while迴圈,說白了就是領任務。可能是第一次領,也可能是做完了再次跳入while迴圈領任務。如果無需領任務,或者領完了,都會把advance設定為false,跳出while迴圈,繼續後面的遷移/檢驗操作:
//需要向前推進
while (advance) {
int nextIndex, nextBound;
// --i >= bound表示已經超出了邊界,或者finishing表示已經全部完成
// 那麼當然就不會繼續推進了。一個表示執行緒已經領好了任務,跳出while開始工作
// 另一個表示全部資料都遷移完了(可能在領任務中途其他執行緒全部做完了)
// 無論是哪一種,都會跳出while迴圈,設定advance為false
if (--i >= bound || finishing)
advance = false;
// 如果transferIndex已經小於0,說明所有資料都已經有執行緒在負責遷移
// 當前執行緒也無需領任務了,跳出while,等待全部finishing吧
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 如果執行到這裡,就是執行緒要領任務了,用CAS更新了transferIndex
// 更新成功說明執行緒領取任務成功,設定好bound和i,跳出while迴圈
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//bound代表當次資料遷移下限
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
當一個執行緒跳出了while迴圈,有兩種情況:
①它剛剛完成了任務,接著跳入while迴圈領取任務,發現沒有任務需要領取了
②它剛剛從while裡領取了任務
無論是哪一種情況,此時都應該先判斷所有的資料是否都已經遷移完畢(任務全部都完成了)。如果全部完成了,那麼就直接return。如果沒有,對於情況①,它就會自旋等待,等待其他執行緒把任務全部做完,或者當某個執行緒阻塞,這時候它CAS成功將會獲取這一份任務。對於情況②,那麼它就繼續完成自己的任務。判斷所有資料遷移完成的程式碼如下:
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//若全部執行緒遷移完成
if (finishing) {
nextTable = null;
//更新table為新表
table = nextTab;
//擴容閾值改為原來陣列長度的 3/2 ,即新長度的 3/4,也就是新陣列長度的0.75倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//到這,說明當前執行緒已經完成了自己的所有遷移(無論參與了幾次遷移),
//則把 sc 減1,表明參與擴容的執行緒數減少 1。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//在 addCount 方法最後,我們強調,遷移開始時,會設定 sc=(rs << RESIZE_STAMP_SHIFT) + 2
//每當有一個執行緒參與遷移,sc 就會加 1,每當有一個執行緒完成遷移,sc 就會減 1。
//因此,這裡就是去校驗當前 sc 是否和初始值是否相等。相等,則說明全部執行緒遷移完成。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//只有此處,才會把finishing 設定為true。
finishing = advance = true;
//這裡非常有意思,會把 i 從 -1 修改為16,
//目的就是,讓 i 再從後向前掃描一遍陣列,檢查是否所有的桶都已被遷移完成
i = n; // recheck before commit
}
}
裡面用finishing判斷是否全部執行緒遷移完成,因為陣列已經建立完,資料也遷移完成,所以只有幾行簡單的賦值程式碼。雖然finishing依然為false,但只要進入到這裡,說明當前執行緒已經完成了自己的遷移任務,於是把sc減1,之後再判斷一次是否全部執行緒遷移完成,但在此處是用CAS判斷的(可能當前執行緒就是最後一個執行緒)。如果確實全部都遷移完成了,那麼會執行:finishing = advance = true
。為什麼advance也要設定為true?因為後面還會把 i 的值賦值為n,使得i從後往前再掃描一次陣列,檢查是否所有的元素都遷移完成。
那麼為什麼這裡if的判定條件是:if (i < 0 || i >= n || i + n >= nextn)
,在後面我們會看到,資料遷移需要把原本的資料複製到新陣列裡,下標就和HashMap的resize邏輯一樣,無需再雜湊,下標要麼是原本的下標,要麼是原下標+原陣列長度。上面的三種情況,都超出了newTable的範圍,都說明執行緒已經完成了它當前要遷移的任務,而且無需繼續advance了,因此它不再需要去領取任務,可以直接去跳出transfer方法。同時判斷是否執行完畢,如果執行完畢就和前面所說的一樣,賦值,再掃描一次檢查等等。如果還沒執行完畢,但它的任務也已經結束,無需再做其他事了。
直到這裡,我們已經寫好了執行緒如何判斷要向前推進(advance),什麼時候表明執行緒全部遷移完成(finishing)。後面就是執行緒如何進行具體的遷移資料操作,以及如何判斷執行緒已經遷移完成:
// 若i的位置元素為空,則說明當前桶的元素已經被遷移完成,就把頭結點設定為fwd標誌。
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 若當前桶的頭結點是 ForwardingNode ,說明遷移完成,則向前推進
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
如果桶的元素為空,那麼就無需遷移了,因為根本就沒有資料,但還是要給頭節點設定為fwd。如果頭節點是fwd,說明遷移完成,向前推進。此時會回到while迴圈,但如果在while迴圈裡找不到新的任務,此時就會進入到if (i < 0 || i >= n || i + n >= nextn)
,判斷是否所有資料都遷移完成。
把終止條件都考慮好了,剩下的就是具體的遷移資料操作(依然把Tree操作省去了,以後再補充):
else {
synchronized (f) { // 給頭節點加鎖
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) { // 普通連結串列節點
int runBit = fh & n; // 擴容後的第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);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
// ...
}
}
}
}
前面已經把多執行緒環境下的執行緒進入,推進,終止已經考慮好了,因此這部分程式碼實際上跟HashMap非常像,只需要在開頭給頭節點加鎖。這裡結合了JDK1.7中ConcurrentHashMap的lastRun節點,以及JDK1.8中HashMap的雙連結串列進行遷移。首先依然是找到lastRun節點,然後計算好lastRun節點的runBit,決定到底是在hn連結串列還是ln連結串列(high index newTable,low index newTable)。處理完lastRun後半段的子連結串列,從頭節點開始遍歷到lastRun節點,這部分和JDK1.8一樣,使用複製,同樣地根據第n位的值為0還是1,決定加入到ln還是hn。如下圖例子:
步驟:先找到lastRun節點,把lastRun後半段子連結串列新增到ln或者hn連結串列,接著從頭節點開始遍歷節點,直到lastRun終止,採用頭插法。lastRun部分會是順序,而其他會是倒序。
Step1:找到lastRun節點,為65。計算得到runBit為0,於是ln連結串列此時為「65➡️97」。
Step2:從頭開始遍歷節點,直到lastRun終止。對於節點1,計算得到bit為0,採用頭插法,此時ln連結串列為「1➡️65➡️97」。
Step3:對於節點17,bit為1,於是hn連結串列為「17」
Step4:對於節點33,bit為0,於是ln連結串列為「33➡️1➡️65➡️97」
Step5:對於節點49,bit為1,於是hn連結串列為「49➡️17」
所以,lastRun後面的節點是順序,而因為lastRun前面的節點採用頭插法,因而會是倒序的情況。至此,transfer方法到此結束。
helpTransfer方法
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//頭結點為 ForwardingNode ,並且新陣列已經初始化
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;
//當前執行緒需要幫助遷移,sc值加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
執行緒執行putVal方法時,當發現map正處於擴容,會呼叫helpTransfer考慮是否幫助擴容。helpTransfer主要是判定是否需要幫助擴容,如果需要,直接呼叫transfer方法,反之直接break。如上面的程式碼邏輯:
①當ConcurrentHashMap嘗試插入的時候,發現節點是forward型別,說明陣列已經初始化,那麼才會去考慮幫忙擴容。如果陣列還沒初始化,說明是第一個執行緒,應該呼叫transfer(tab, null);
②每加入一個執行緒都會將sizeCtl的低16位加1,同時會校驗高16位的標誌符。
③擴容最大的幫助執行緒數位65535,這是低16位的最大值限制。如果執行緒已經達到最上限,就不要去helpTransfer了。
④當執行緒確實需要helpTransfer,就CAS修改sc的值,進行加1操作,然後返回
(PS:此處有bug,錯誤的方式和addCount裡的擴容判斷一模一樣。)
addCount裡的擴容判斷
回過頭來看addCount裡的後半段關於擴容的程式碼。
// x為1,check代表連結串列上的元素個數
private final void addCount(long x, int check) {
// ...
// 執行到這裡說明需要擴容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//若元素個數達到擴容閾值(即map新增的節點數大於等於sizeCtl
//且tab不為空,且tab陣列長度小於最大容量
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//這裡假設陣列長度n就為16,這個方法返回的是一個固定值rs,用於當做一個擴容的校驗標識
int rs = resizeStamp(n);
//若sc小於0,說明正在擴容
if (sc < 0) {
// 此處有bug,我們只需要知道:當前桶陣列正在擴容,但當前執行緒無需幫助擴容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//到這裡說明當前執行緒可以幫助擴容,因此sc值加一,代表擴容的執行緒數加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 當sc大於0,說明sc代表擴容閾值,因此第一次擴容之前肯定走這個分支,用於初始化新表 nextTable
// 此時會把sc賦值為(rs << RESIZE_STAMP_SHIFT) + 2,是一個標記值,表示首個幫助擴容的執行緒
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//擴容,第二個引數代表新表,傳入null,則說明是第一次初始化新表(nextTable)
transfer(tab, null);
s = sumCount();
}
}
}
//擴容時的校驗標識
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
首先要再次清晰sizeCtl的作用,它雖然只是一個變數,但它承擔了5個角色的功能:
①當它為負數,且為-1時,表示正在初始化。
②當它為負數,且為-N時,它的後16位表示有 N - 1個執行緒在幫助擴容,而高16位表示在幫助哪個容量進行擴容
③當它為0時,這是預設值
④當它為正數時,且桶陣列為null,它代表桶陣列的初始化大小。
⑤當它為正數時,且桶陣列不為null,它代表桶陣列的擴容閾值。
對於上面的程式碼,while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY)
,這就是擴容的條件:當容量大於等於sizeCtl,執行到此處的sizeCtl為正數,且桶陣列不為null,並且桶陣列的長度還沒有達到MAX,根據上面的第②或第⑤點,這就是桶陣列的擴容閾值,因此此時桶陣列的容量大於等於sizeCtl,那麼就會進行擴容。
這時候先判斷sc到底是大於0(情況⑤),還是小於0(情況②)。如果是小於0,說明現在正處於擴容狀態,此處需要判斷當前執行緒是否應該幫助擴容。那麼什麼時候不需要幫助擴容呢,有三點:
① (sc >>> RESIZE_STAMP_SHIFT) != rs
這裡我們要先看rs的計算方法resizeStamp方法是如何計算的,含義是什麼:
/**
* Returns the stamp bits for resizing a table of size n.
* Must be negative when shifted left by RESIZE_STAMP_SHIFT.
*/
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
返回一個關於桶陣列長度n的一個stamp bits(戳),也就是根據n唯一對應的一個標記值。numberOfLeadingZeros返回n轉換為二進位制之後,前面有多少個字首0,後面是指2的15次方(RESIZE_STAMP_BITS為16)。於是我們得到的值形式會是:
0000 0000 0000 0000 1xxx xxxx xxxx xxxx
Must be negative when shifted left by RESIZE_STAMP_SHIFT,當此數左移16位之後,它必須是一個負數,因為第17位為1,左移之後第一位為1,為負數。至於為什麼必須是負數,後面再考慮,我們現在只需要知道,rs是一個對應於n的標記值,也就是n改變的時候,rs也會跟著改變。前面的第②個功能說明了sc的高16位就是這個標記值rs,如果當前sc的高16位與rs不相等,說明在這個併發的過程中,其他執行緒已經擴容完畢。現在雖然正處於擴容狀態,但並不是原本的桶陣列長度對應的擴容。比如原本是長度為16擴容為32,某個執行緒A參與到幫助擴容的任務當中。但它在某一個時刻發現高16位標記值與rs不相等,可能是其他執行緒已經擴容完畢了,現在是長度32擴容為64,那麼此時執行緒A就能退出當前的“過期”任務了。這就是為什麼當 (sc >>> RESIZE_STAMP_SHIFT) != rs
,直接break,跳出迴圈,不再幫助擴容。
② sc == rs + 1 (此處有Bug)
還是前面的第②個功能,-N表示有N - 1個執行緒在擴容,而此時為1,說明沒有執行緒在擴容。最關鍵的是,最開始的時候,我們會把sc設定為(rs << RESIZE_STAMP_SHIFT) + 2)
,表明有1個執行緒觸發了擴容,此時有1個執行緒在幫助擴容。但現在連觸發擴容的執行緒都已經退出,說明擴容已經結束,那麼當前執行緒自然也是直接break,跳出迴圈。
③sc == rs + MAX_RESIZER
RESIZER是指幫助擴容的執行緒數,那麼MAX_RESIZER顯然就是最大幫助擴容執行緒數。所以這裡的邏輯也呼之欲出:已經達到了最大幫助擴容執行緒數,當前執行緒直接退出。
那麼如果不是上面3種情況,說明當前執行緒需要幫助擴容,於是通過CAS把sc的值加1,表示幫助擴容的執行緒數+1:
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
如果sc的值為正數,此時就是第⑤條功能,會執行以下邏輯:
// 當sc大於0,說明sc代表擴容閾值,因此第一次擴容之前肯定走這個分支,用於初始化新表 nextTable
// 此時會把sc賦值為(rs << RESIZE_STAMP_SHIFT) + 2,是一個標記值,表示首個幫助擴容的執行緒
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//擴容,第二個引數代表新表,傳入null,則說明是第一次初始化新表(nextTable)
transfer(tab, null);
sc為正數,那麼sc為擴容閾值,即第一個執行緒導致了擴容,此時通過CAS把sc的值初始化為(rs << RESIZE_STAMP_SHIFT) + 2)
,transfer方法的第二個引數為null也說明了確實是第一次擴容。
上面可能會有一些遺留問題,如下:
Question1:rs是對應於n的一個標記值,但為什麼它要Must be negative when shifted left by RESIZE_STAMP_SHIFT?
Ans:sizeCtl為負數的時候表示它正在擴容,而sizeCtl在首次擴容的時候會初始化為(rs << RESIZE_STAMP_SHIFT) + 2)
。此時可以使得sizeCtl成為:高16位就是所謂的stamp(戳),是桶陣列長度的一個標記值,用於檢測當前擴容執行緒是否對應同一個長度,如果長度已經改變,當前執行緒就無需繼續擴容了(已經擴容完畢,現在其他執行緒正在執行其他擴容任務!)。而後16位表示有多少個執行緒在幫助擴容,-2,因此是1個執行緒幫助擴容。
Question2:所以為什麼要初始化為 (rs << RESIZE_STAMP_SHIFT) + 2)?
Ans:準確地說,當陣列正在擴容的時候,sizeCtl的值為resizeStamp + 1 + 正在幫助擴容的執行緒數量。
resizeStamp是一個很大的負值,其高16位為stamp,低16位為0(1xxx xxxx xxxx xxxx 0000 0000 0000 0000)。
因為設定低16位的值為k時,有k - 1個執行緒在幫助執行緒。初始的時候就是隻有1個執行緒正在擴容,所以低16位為2!
而且即使執行緒數到達MAX_RESIZER的時候,sc也不會發生符號改變,因為MAX_RESIZER的最大值為65535,+1之後也只是使得第17位進1。而因為桶的陣列長度是2次冪,因而第1位必定是0,移位後第17位也一定是0。因此即使線上程數最大的時候,sc依然是負數。
Question3:sc == rs + 1和 sc == rs + MAX_RESIZER有什麼錯誤?
Ans:雖然ConcurrentHashMap是設計非常精妙的類,但它還是出現了一些小bug。這裡的sc為負數,而rs是一個正數,它們是永遠不可能相等的。sc是後16位與rs進行比較,這就是這兩個情況的錯誤,它沒有進行移位計算。
在JDK12中,此bug得到了修改,rs變數在賦值的時候就先進行移位操作:參考連結
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();
}
}
同理,可以看一下JDK12裡的helpTransfer方法,也是做出了一模一樣的修改方案:
/**
* Helps transfer if a resize is in progress.
*/
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) << RESIZE_STAMP_SHIFT; // 移位操作放到賦值這裡進行操作了
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
總結
ConcurrentHashMap的學習時間線拖得比較長,但收穫還是很大。首先對JDK1.7中分段鎖的概念有了更深入的瞭解。而JDK1.8在synchronized優化之後,放棄了分段鎖,選用CAS+synchronized。但箇中還是加入了很多優化,因此理論雖簡單,程式碼實現卻比較複雜。1.7中的分段鎖主要是每一個Segment各司其職,只要Hash函式足夠好,元素儘量地均勻分佈到每一個Segment中,那麼就可以近似地認為ConcurrentHashMap的併發度是Segment陣列的長度,而編碼的關鍵也是處理好各個Segment之間的鎖問題。而1.8沒有了分段鎖的概念,卻加入了sizeCtl變數,一值多用,很好地提高了效率,多個put的執行緒可以選擇幫助擴容也使得效率得到大大的提升,但在計算size的時候還是用到了CounterCells陣列,個人覺得這個概念還是和分段鎖比較相似。
總結,1.7使用分段鎖,起初覺得概念複雜, 但實際上比較簡單,程式碼實現也不難。而1.8使用CAS+synchronized在HashMap的基礎上改進,起初覺得概念簡單,但程式碼的實現卻比較複雜。ConcurrentHashMap裡還有很多值得學習的地方,哪怕是上面介紹的方法也還是有很多值得深究的地方,但目前就暫時到這裡了~
相關文章
- ConcurrentHashMap 原始碼閱讀小結HashMap原始碼
- JDK原始碼閱讀(7):ConcurrentHashMap類閱讀筆記JDK原始碼HashMap筆記
- JDK1.8 ConcurrentHashMap原始碼閱讀JDKHashMap原始碼
- ConcurrentHashMap原始碼解讀HashMap原始碼
- 【原始碼閱讀】AndPermission原始碼閱讀原始碼
- ConcurrentHashMap原始碼解讀一HashMap原始碼
- ConcurrentHashMap原始碼解讀二HashMap原始碼
- 【原始碼閱讀】Glide原始碼閱讀之with方法(一)原始碼IDE
- 【原始碼閱讀】Glide原始碼閱讀之into方法(三)原始碼IDE
- ReactorKit原始碼閱讀React原始碼
- AQS原始碼閱讀AQS原始碼
- CountDownLatch原始碼閱讀CountDownLatch原始碼
- HashMap 原始碼閱讀HashMap原始碼
- delta原始碼閱讀原始碼
- 原始碼閱讀-HashMap原始碼HashMap
- NGINX原始碼閱讀Nginx原始碼
- Mux 原始碼閱讀UX原始碼
- HashMap原始碼閱讀HashMap原始碼
- fuzz原始碼閱讀原始碼
- RunLoop 原始碼閱讀OOP原始碼
- express 原始碼閱讀Express原始碼
- muduo原始碼閱讀原始碼
- stack原始碼閱讀原始碼
- 【原始碼閱讀】Glide原始碼閱讀之load方法(二)原始碼IDE
- PostgreSQL 原始碼解讀(3)- 如何閱讀原始碼SQL原始碼
- JDK原始碼閱讀:Object類閱讀筆記JDK原始碼Object筆記
- Laravel 原始碼閱讀 - QueueLaravel原始碼
- Vollery原始碼閱讀(—)原始碼
- 使用OpenGrok閱讀原始碼原始碼
- 如何閱讀Java原始碼?Java原始碼
- buffer 原始碼包閱讀原始碼
- 原始碼閱讀技巧篇原始碼
- 如何閱讀框架原始碼框架原始碼
- 再談原始碼閱讀原始碼
- Laravel 原始碼閱讀 - EloquentLaravel原始碼
- 如何閱讀jdk原始碼?JDK原始碼
- express 原始碼閱讀(全)Express原始碼
- Vuex原始碼閱讀分析Vue原始碼