ConcurrentHashMap 底層原理

這篇文章,我打算從以下幾個方面來講。

1)多線程下的 HashMap 有什麼問題?

2)怎樣保證線程安全,爲什麼選用 ConcurrentHashMap?

3)ConcurrentHashMap 1.7 源碼解析

  • 底層存儲結構

  • 常用變量

  • 構造函數

  • put() 方法

  • ensureSegment() 方法

  • scanAndLockForPut() 方法

  • rehash() 擴容機制

  • get() 獲取元素方法

  • remove() 方法

  • size() 方法是怎麼統計元素個數的

4)ConcurrentHashMap 1.8 源碼解析

  • put()方法詳解

  • initTable()初始化表

  • addCount()方法

  • fullAddCount()方法

  • transfer()是怎樣擴容和遷移元素的

  • helpTransfer()方法幫助遷移元素

多線程下 HashMap 有什麼問題?

在上一篇文章中,已經講解了 HashMap 1.7 死循環的成因,也正因爲如此,我們才說 HashMap 在多線程下是不安全的。但是,在JDK1.8 的 HashMap 改爲採用尾插法,已經不存在死循環的問題了,爲什麼也會線程不安全呢?

我們以 put 方法爲例(1.8),

假如現在有兩個線程都執行到了上圖中的劃線處。當線程一判斷爲空之後,CPU 時間片到了,被掛起。線程二也執行到此處判斷爲空,繼續執行下一句,創建了一個新節點,插入到此下標位置。然後,線程一解掛,同樣認爲此下標的元素爲空,因此也創建了一個新節點放在此下標處,因此造成了元素的覆蓋。

所以,可以看到不管是 JDK1.7 還是 1.8 的 HashMap 都存在線程安全的問題。那麼,在多線程環境下,應該怎樣去保證線程安全呢?

怎樣保證線程安全,爲什麼選用 ConcurrentHashMap?

首先,你可能想到,在多線程環境下用 Hashtable 來解決線程安全的問題。這樣確實是可以的,但是同樣的它也有缺點,我們看下最常用的 put 方法和 get 方法。

Hashtable-put

Hatable-get

可以看到,不管是往 map 裏邊添加元素還是獲取元素,都會用 synchronized 關鍵字加鎖。當有多個元素之前存在資源競爭時,只能有一個線程可以獲取到鎖,操作資源。更不能忍的是,一個簡單的讀取操作,互相之間又不影響,爲什麼也不能同時進行呢?

所以,hashtable 的缺點顯而易見,它不管是 get 還是 put 操作,都是鎖住了整個 table,效率低下,因此 並不適合高併發場景。

也許,你還會想起來一個集合工具類 Collections,生成一個SynchronizedMap。其實,它和 Hashtable 差不多,同樣的原因,鎖住整張表,效率低下。

所以,思考一下,既然鎖住整張表的話,併發效率低下,那我把整張表分成 N 個部分,並使元素儘量均勻的分佈到每個部分中,分別給他們加鎖,互相之間並不影響,這種方式豈不是更好 。這就是在 JDK1.7 中 ConcurrentHashMap 採用的方案,被叫做鎖分段技術,每個部分就是一個 Segment(段)。

但是,在JDK1.8中,完全重構了,採用的是 Synchronized + CAS ,把鎖的粒度進一步降低,而放棄了 Segment 分段。(此時的 Synchronized 已經升級了,效率得到了很大提升,鎖升級可以瞭解一下)

ConcurrentHashMap 1.7 源碼解析

我們看下在 JDK1.7中 ConcurrentHashMap 是怎麼實現的。牆裂建議,在本文之前瞭解一下多線程的基本知識,如JMM內存模型,volatile關鍵字作用,CAS和自旋,ReentranLock重入鎖。

底層存儲結構

在 JDK1.7中,本質上還是採用鏈表+數組的形式存儲鍵值對的。但是,爲了提高併發,把原來的整個 table 劃分爲 n 個 Segment 。所以,從整體來看,它是一個由 Segment 組成的數組。然後,每個 Segment 裏邊是由 HashEntry 組成的數組,每個 HashEntry之間又可以形成鏈表。我們可以把每個 Segment 看成是一個小的 HashMap,其內部結構和 HashMap 是一模一樣的。

當對某個 Segment 加鎖時,如圖中 Segment2,並不會影響到其他 Segment 的讀寫。每個 Segment 內部自己操作自己的數據。這樣一來,我們要做的就是儘可能的讓元素均勻的分佈在不同的 Segment中。最理想的狀態是,所有執行的線程操作的元素都是不同的 Segment,這樣就可以降低鎖的競爭。

廢話了這麼多,還是來看底層源碼吧,因爲所有的思想都在代碼裏體現。借用 Linus的一句話,“No BB . Show me the code ” (改編版,哈哈)

常用變量

先看下 1.7 中常用的變量和內部類都有哪些,這有助於我們瞭解 ConcurrentHashMap 的整體結構。

//默認初始化容量,這個和 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;
 }
}

構造函數

ConcurrentHashMap 有五種構造函數,但是最終都會調用同一個構造函數,所以只需要搞明白這一個核心的構造函數就可以了。

PS: 文章註釋中 (1)(2)(3) 等序號都是用來方便做標記,不是計算值

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;
 //Segment的掩碼
 this.segmentMask = ssize - 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;
}    
```須是2的n次冪
 int ssize = 1;

上邊的註釋中留了 (1)(2)(3) 三個地方還沒有細說。我們現在假設一組數據,把涉及到的幾個變量計算出來,就能明白這些參數的含義了。

```java
//假設調用了默認構造,都用的是默認參數,即 initialCapacity 和 concurrencyLevel 都是16
//(1)  sshift 和 ssize 值的計算過程爲,每次循環,都會把 sshift 自增1,並且 ssize 左移一位,即乘以2,
//直到 ssize 的值大於等於 concurrencyLevel 的值 16。
sshfit=0,1,2,3,4
ssize=1,2,4,8,16
//可以看到,初始他們的值分別是0和1,最終結果是4和16
//sshfit是爲了輔助計算segmentShift值,ssize是爲了確定Segment數組長度。
//(2)  此時,計算c的值,
c = 16/16 = 1;
//判斷 c * 16 < 16 是否爲真,真的話 c 自增1,此處爲false,因此 c的值爲1不變。
//(3)  此時,由於c爲1, cap爲2 ,因此判斷 cap < c 爲false,最終cap爲2。
//總結一下,以上三個步驟,最終都是爲了確定以下幾個關鍵參數的值,
//確定 segmentShift ,這個用於後邊計算hash值的偏移量,此處即爲 32-4=28,
//確定 ssize,必須是一個大於等於 concurrencyLevel 的一個2的n次冪值
//確定 cap,必須是一個大於等於2的一個2的n次冪值
//感興趣的小夥伴,還可以用另外幾組參數來計算上邊的參數值,可以加深理解參數的含義。
//例如initialCapacity和concurrencyLevel分別傳入10和5,或者傳入33和16

put()方法

put 方法的總體流程是,

  1. 通過哈希算法計算出當前 key 的 hash 值

  2. 通過這個 hash 值找到它所對應的 Segment 數組的下標

  3. 再通過 hash 值計算出它在對應 Segment 的 HashEntry數組 的下標

  4. 找到合適的位置插入元素

//這是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 是 ReentrantLock 中的方法,
 //區別是 tryLock 不會阻塞,搶鎖成功就返回true,失敗就立馬返回false,
 //而 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;;) {
   //如果第一個節點不爲空
   if (e != null) {
    K k;
    //並且第一個節點,就是要插入的節點,則替換value值,否則繼續向後查找
    if ((k = e.key) == key ||
     (e.hash == hash && key.equals(k))) {
     //替換舊值
     oldValue = e.value;
     if (!onlyIfAbsent) {
      e.value = value;
      ++modCount;
     }
     break;
    }
    e = e.next;
   }
   //說明當前index位置不存在任何節點,此時first爲null,
   //或者當前index存在一條鏈表,並且已經遍歷完了還沒找到相等的key,此時first就是鏈表第一個元素
   else {
    //如果node不爲空,則直接頭插
    if (node != null)
     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 數組下標和計算 HashEntry 數組下標的不同點:

//下邊的hash值是通過哈希運算後的hash值,不是hashCode
//計算 Segment 下標
 (hash >>> segmentShift) & segmentMask 
 //計算 HashEntry 數組下標
 (tab.length - 1) & hash

思考一下,爲什麼它們的算法不一樣呢?計算 Segment 數組下標是用的 hash值高几位(這裏以高 4 位爲例)和掩碼做與運算,而計算 HashEntry 數組下標是直接用的 hash 值和數組長度減1做與運算。

我的理解是,這是爲了儘量避免當前 hash 值計算出來的 Segment 數組下標和計算出來的 HashEntry 數組下標趨於相同。簡單說,就是爲了避免分配到同一個 Segment 中的元素扎堆現象,即避免它們都被分配到同一條鏈表上,導致鏈表過長。同時,也是爲了減少併發。下面做一個運算,幫助理解一下(假設不用高 4 位運算,而是正常情況都用低位做運算)。

//我們以併發級別16,HashEntry數組容量 4 爲例,則它們參與運算的掩碼分別爲 15 和 3
//hash值
0110 1101 0110 1111 0110 1110 0010 0010
//segmentMask = 15   ,標記爲 (1)
0000 0000 0000 0000 0000 0000 0000 1111
//tab.length - 1 = 3     ,標記爲 (2)
0000 0000 0000 0000 0000 0000 0000 0011
//用 hash 分別和 15 ,3 做與運算,會發現得到的結果是一樣,都是十進制 2.
//這表明,當前 hash值被分配到下標爲 2 的 Segment 中,同時,被分配到下標爲 2 的 HashEntry 數組中
//現在若有另外一個 hash 值 h2,和第一個hash值,高位不同,但是低4位相同,
1010 1101 0110 1111 0110 1110 0010 0010
//我們會發現,最後它也會被分配到下標爲 2 的 Segment 和 HashEntry 數組,就會和第一個元素形成鏈表。
//所以,爲了避免這種扎堆現象,讓元素儘量均勻分配,就讓 hash 的高 4 位和 (1)處做與 運算,而用低位和 (2)處做與運算
//這樣計算後,它們所在的Segment下標分別爲 6(0110), 10(1010),即使它們在HashEntry數組中的下標都爲 2(0010),也無所謂
//因爲它們並不在一個 Segment 中,也就不會在同一個 HashEntry 數組中,更不會形成鏈表。
//更重要的是,它們不會有併發,因爲在各自不同的 Segment 自己操作自己的加鎖解鎖,互不影響

可能有的小夥伴就會打岔了,那如果兩個 hash 值,低位和高位都相同,怎麼辦呢。如果是這樣,我只能說,這個 hash 算法也太爛了吧。(這裏的 hash 算法也會盡量避免這種情況,當然只是減少機率,並不能杜絕)

我有個大膽的想法,這裏的高低位不同的計算方式,是不是後邊 1.8 HashMap 讓 hash 高低位做異或運算的引子呢?不得而知。。

put 方法比較簡單,只要能看懂 HashMap 中的 put 方法,這裏也沒問題。主要是它調用的子方法比較複雜,下邊一個一個講解。

ensureSegment()方法

回到 Map的 put 方法,判斷 j 下標的 Segment爲空後,則需要調用此方法,初始化一個 Segment 對象,以確保拿到的對象一定是不爲空的,否則無法執行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;
}

可以發現,我標註了上邊 (1)(2)(3) 個地方,每次都判斷最新的Segment是否爲空。可能有的小夥伴就會迷惑,爲什麼做這麼多次判斷,我直接去自旋不就好了,反正最後都要自旋的。

我的理解是,在多線程環境下,因爲不確定是什麼時候會有其它線程 CAS 成功,有可能發生在以上的任意時刻。所以,只要發現一旦內存中的對象已經存在了,則說明已經有其它線程把Segment對象創建好,並CAS成功同步到主內存了。此時,就可以直接返回,而不需要往下執行了。這樣做,是爲了代碼執行效率考慮。

scanAndLockForPut()方法

put 方法第一步搶鎖失敗之後,就會執行此方法,

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;
}

這個方法邏輯比較複雜,會一直循環嘗試獲取鎖,若獲取成功,則返回。否則的話,每次循環時,都會同時遍歷當前鏈表。若遍歷完了一次,還沒找到和key相等的節點,就會預先創建一個節點。注意,這裏只是預測性的創建一個新節點,也有可能在這之前,就已經獲取鎖成功了。

同時,當重試次每偶數次時,就會檢查一次當前最新的頭結點是否被改變。因爲若有變化的話,還需要從最新的頭結點開始遍歷鏈表。

還有一種情況,就是循環次數達到了最大限制,則停止循環,用阻塞的方式去獲取鎖。這時,也就停止了遍歷鏈表的動作,當前線程也不會再做其他預熱(warm up)的事情。

關於爲什麼預測性的創建新節點,源碼中原話是這樣的:

Since traversal speed doesn't matter, we might as well help warm up the associated code and accesses as well.

解釋一下就是,因爲遍歷速度無所謂,所以,我們可以預先(warm up)做一些相關聯代碼的準備工作。這裏相關聯代碼,指的就是循環中,在獲取鎖成功或者調用 lock 方法之前做的這些事情,當然也包括創建新節點。

在put 方法中可以看到,有一句是判斷 node 是否爲空,若創建了,就直接頭插。否則的話,它也會自己創建這個新節點。

scanAndLockForPut 這個方法可以確保返回時,當前線程一定是獲取到鎖的狀態。

rehash()方法

當 put 方法時,發現元素個數超過了閾值,則會擴容。需要注意的是,每個Segment只管它自己的擴容,互相之間並不影響。換句話說,可以出現這個 Segment的長度爲2,另一個Segment的長度爲4的情況(只要是2的n次冪)。

//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;
 //遍歷舊錶
 for (int i = 0; i < oldCapacity ; i++) {
  HashEntry<K,V> e = oldTable[i];
  //如果e不爲空,說明當前鏈表不爲空
  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
    //記錄上一次遍歷到的節點
    HashEntry<K,V> lastRun = e;
    //對應上一次遍歷到的節點在新數組中的新下標
    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 和 lastIdx 變量可能不太好理解,我畫個圖就明白了。以其中一條鏈表處理方式爲例。

從頭結點開始向後遍歷,找到當前鏈表的最後幾個下標相同的連續的節點。如上圖,雖然開頭出現了有兩個節點的下標都是 k2, 但是中間出現一個不同的下標 k1,打斷了下標連續相同,因此從下一個k2,又重新開始算。好在後邊三個連續的節點下標都是相同的,因此倒數第三個節點被標記爲 lastRun,且變量無變化。

從lastRun節點到尾結點的這部分就可以整體遷移到新數組的對應下標位置了,因爲它們的下標都是相同的,可以這樣統一處理。

另外從頭結點到 lastRun 之前的節點,無法統一處理,只能一個一個去複製了。且注意,這裏不是直接遷移,而是複製節點到新的數組,舊的節點會在不久的將來,因爲沒有引用指向,被 JVM 垃圾回收處理掉。

(不知道爲啥這個方法名起爲 rehash,其實擴容時 hash 值並沒有重新計算,變化的只是它們所在的下標而已。我猜測,可能是,借用了 1.7 HashMap 中的說法吧。。。)

get()

put 方法搞明白了之後,其實 get 方法就很好理解了。也是先定位到 Segment,然後再定位到 HashEntry 。

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 方法類似,也不用做過多特殊的介紹,

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 方法需要重點說明一下。愛思考的小夥伴可能就會想到,併發情況下,有可能在統計期間,數組元素個數不停的變化,而且,整個表還被分成了 N個 Segment,怎樣統計才能保證結果的準確性呢?我們一起來看下吧。

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;
}

其實源碼中前兩行的註釋也說的非常清楚了。我們先採用樂觀的方式,認爲在統計 size 的過程中,並沒有發生 put, remove 等會改變 Segment 結構的操作。但是,如果發生了,就需要重試。如果重試2次都不成功(執行三次,第一次不能叫做重試),就只能強制把所有 Segment 都加鎖之後,再統計了,以此來得到準確的結果。

ConcurrentHashMap 1.8 源碼分析

需要說明的是,JDK 1.8 的 CHM(ConcurrentHashMap) 實現,完全重構了 1.7 。不再有 Segment 的概念,只是爲了兼容 1.7 才申明瞭一下,並沒有用到。因此,不再使用分段鎖,而是給數組中的每一個頭節點(爲了方便,以後都叫桶)都加鎖,鎖的粒度降低了。並且,用的是 Synchronized 鎖。

可能有的小夥伴就有疑惑了,不是都說同步鎖是重量級鎖嗎,這樣不是會影響併發效率嗎?

確實之前同步鎖是一個重量級鎖,但是在 JDK1.6 之後進行了各種優化之後,它已經不再那麼重了。引入了偏向鎖,輕量級鎖,以及鎖升級的概念,而且,據說在更細粒度的代碼層面上,同步鎖已經可以媲美 Lock 鎖,甚至是趕超了。除此之外,它還有很多優點,這裏不再展開了。感興趣的可以自行查閱同步鎖的鎖升級過程,以及它和 Lock 鎖的區別。

在 1.8 CHM 中,底層存儲結構和 1.8 的 HashMap 是一樣的,都是數組+鏈表+紅黑樹。不同的就是,多了一些併發的處理。

文章開頭我們提到了,在 1.8 HashMap 中的線程安全問題,就是因爲在多個線程同時操作同一個桶的頭結點時,會發生值的覆蓋情況。那麼,順着這個思路,我們看一下在 CHM 中它是怎麼避免這種情況發生的吧。

PS:由於1.8的 CHM 和 HashMap 結構和基本屬性變量,還有初始化邏輯都差不多,只是多了一些併發情況需要用到的參數和內部類,因此,不再單獨拎出來介紹。在方法中用到的時候,再詳細解釋。

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) {
      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;
       }
      }
     }
     //否則判斷是否是樹節點。這裏提一下,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;
}

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,因爲此時表是空的,不會發生擴容,sc只能爲正數或者-1
   //因此,當前線程放棄 CPU 時間片,只是自旋。
   Thread.yield(); // lost initialization race; just spin
  //通過 CAS 把 sc 的值設置爲-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;
}

addCount()方法

若 put 方法元素插入成功之後,則會調用此方法,傳入參數爲 addCount(1L, binCount)。這個方法的目的很簡單,就是把整個 table 的元素個數加 1 。但是,實現比較難。

我們先思考一下,如果讓我們自己去實現這樣的統計元素個數,怎麼實現?

類比 1.8 的 HashMap ,我們可以搞一個 size 變量來存儲個數統計。但是,這是在多線程環境下,需要考慮併發的問題。因此,可以把 size 設置爲 volatile 的,保證可見性,然後通過 CAS 樂觀鎖來自增 1。

這樣雖然也可以實現。但是,設想一下現在有非常多的線程,都在同一時間操作這個 size 變量,將會造成特別嚴重的競爭。所以,基於此,這裏做了更好的優化。讓這些競爭的線程,分散到不同的對象裏邊,單獨操作它自己的數據(計數變量),用這樣的方式儘量降低競爭。到最後需要統計 size 的時候,再把所有對象裏邊的計數相加就可以了。

上邊提到的 size ,在此用 baseCount 表示。分散到的對象用 CounterCell 表示,對象裏邊的計數變量用 value 表示。注意這裏的變量都是 volatile 修飾的。

當需要修改元素數量時,線程會先去 CAS 修改 baseCount 加1,若成功即返回。若失敗,則線程被分配到某個 CounterCell ,然後操作 value 加1。若成功,則返回。否則,給當前線程重新分配一個 CounterCell,再嘗試給 value 加1。(這裏簡略的說,實際更復雜)

CounterCell 會組成一個數組,也會涉及到擴容問題。所以,先畫一個示意圖幫助理解一下。

//線程被分配到的格子
@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);
}

// 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 成功。
  //但是這裏沒明白爲什麼小於等於1,就直接返回了,這裏我懷疑之前的方法漏掉了binCount=0的情況。
  //而且此處若返回了,後邊怎麼判斷擴容?(存疑)
  if (check <= 1)
   return;
  //計算總共的元素個數
  s = sumCount();
 }
 //(2)這裏用於檢查是否需要擴容(下邊這部分很多邏輯不懂的話,等後邊講完擴容,再回來看就理解了)
 if (check >= 0) {
  Node<K,V>[] tab, nt; int n, sc;
  //若元素個數達到擴容閾值,且tab不爲空,且tab數組長度小於最大容量
  while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
      (n = tab.length) < MAXIMUM_CAPACITY) {
   //這裏假設數組長度n就爲16,這個方法返回的是一個固定值,用於當做一個擴容的校驗標識
   //可以跳轉到最後,看詳細計算過程,0000 0000 0000 0000 1000 0000 0001 1011
   int rs = resizeStamp(n);
   //若sc小於0,說明正在擴容
   if (sc < 0) {
       //sc的結構類似這樣,1000 0000 0001 1011 0000 0000 0000 0001
    //sc的高16位是數據校驗標識,低16位代表當前有幾個線程正在幫助擴容,RESIZE_STAMP_SHIFT=16
    //因此判斷校驗標識是否相等,不相等則退出循環
    //sc == rs + 1,sc == rs + MAX_RESIZERS 這兩個應該是用來判斷擴容是否已經完成,但是計算方法存疑
    //感興趣的可以看這個地址,應該是一個 bug ,
    // https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427
    //nextTable=null 說明需要擴容的新數組還未創建完成
    //transferIndex這個參數小於等於0,說明已經不需要其它線程幫助擴容了,
    //但是並不說明已經擴容完成,因爲有可能還有線程正在遷移元素。稍後擴容細講就明白了。
    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
   //rs<<16
   //1000 0000 0001 1011 0000 0000 0000 0000
   //+2
   //1000 0000 0001 1011 0000 0000 0000 0010
   //這個值,轉爲十進制就是 -2145714174,用於標識,這是擴容時,初始化新表的狀態,
   //擴容時,需要用到這個參數校驗是否所有線程都全部幫助擴容完成。
   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));
}

//Integer.numberOfLeadingZeros方法的作用是返回 n 的最高位爲1的前面的0的個數
//n=16,
0000 0000 0000 0000 0000 0000 0001 0000
//前面有27個0,即27
0000 0000 0000 0000 0000 0000 0001 1011
//RESIZE_STAMP_BITS爲16,然後 1<<(16-1),即 1<<15
0000 0000 0000 0000 1000 0000 0000 0000
//它們做或運算,得到 rs 的值
0000 0000 0000 0000 1000 0000 0001 1011

fullAddCount()方法

上邊的 addCount 方法還沒完,別忘了有可能元素個數加 1 的操作還未成功,就走到 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[] 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
 }
}

不得不佩服 Doug Lea 大神,思維這麼縝密,如果是我的話,直接一個 CAS 完事。(手動攤手~)

transfer()方法

需要說明的一點是,雖然我們一直在說幫助擴容,其實更準確的說應該是幫助遷移元素。因爲擴容的第一次初始化新表(擴容後的新表)這個動作,只能由一個線程完成。其他線程都是在幫助遷移元素到新數組。

這裏還是先看下遷移的示意圖,幫助理解。

擴容

爲了方便,上邊以原數組長度 8 爲例。在元素遷移的時候,所有線程都遵循從後向前推進的規則,即如圖A線程是第一個進來的線程,會從下標爲7的位置,開始遷移數據。

而且當前線程遷移時會確定一個範圍,限定它此次遷移的數據範圍,如圖 A 線程只能遷移 bound=6到 i=7 這兩個數據。

此時,其它線程就不能遷移這部分數據了,只能繼續向前推進,尋找其它可以遷移的數據範圍,且每次推進的步長爲固定值 stride(此處假設爲2)。如圖中 B線程發現 A 線程正在遷移6,7的數據,因此只能向前尋找,然後遷移 bound=4 到 i=5 的這兩個數據。

當每個線程遷移完成它的範圍內數據時,都會繼續向前推進。那什麼時候是個頭呢?

這就需要維護一個全局的變量 transferIndex,來表示所有線程總共推進到的元素下標位置。如圖,線程 A 第一次遷移成功後又向前推進,然後遷移2,3 的數據。此時,若沒有其他線程在幫助遷移,則 transferIndex 即爲2。

剩餘部分等待下一個線程來遷移,或者有任何的 A 和B線程已經遷移完成,也可以推進到這裏幫助遷移。直到 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
 //從 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;
     }
    }
   }
  }
 }
}

遷移後的新數組鏈表方向示意圖,以 runBit =0 爲例。

helpTransfer()方法

最後再看 put 方法中的這個方法,就比較簡單了。

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;
}

JDK1.8 的 CHM 最主要的邏輯基本上都講完了,其它方法原理類同。1.8 的 ConcurrentHashMap 實現原理還是比較簡單的,但是代碼實現比較複雜。相對於 1.7 來說,鎖的粒度降低了,效率也提高了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章