一、爲什麼引入 ConcurrentHashMap?
- HashMap 可能會在擴容時的 transfer 操作發生併發問題導致鏈表循環引用,導致在進行 get 操作時發生死循環。
- HashTable 可以提供併發功能,但它是用 synchronize 關鍵字修飾每一個存在併發需求的方法上,也就是給整個 table 都加了鎖,在多線程環境下,可能存在所有線程都等正競爭一把鎖的情況,這也就造成了效率低下的問題。-
- Q1:怎麼解決/優化上述問題?
- A1:採用對 table 的不同數據集分別加鎖的方案代替鎖住整個 table 的數據。
- Q2:上述方案可行的原理是什麼?
- A2:我們知道 hash 值不同,在 rehash 時並不會造成線程安全問題,所以分別鎖住別個數據段是可行的。
二、源碼閱讀
(1) 底層數據結構
在 JDK1.7 版本中,ConcurrentHashMap 的數據結構是由一個內含多個 HashEntry 組 的 Segment 數組構成。先總覽一下 ConcurrentHashMap 幾個重要的成員變量:
//默認的數組大小16(HashMap裏的那個數組)
static final int DEFAULT_INITIAL_CAPACITY = 16;
//擴容因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//ConcurrentHashMap中的數組
final Segment<K,V>[] segments
//默認併發標準16
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//Segment是ReentrantLock子類,因此擁有鎖的操作
static final class Segment<K,V> extends ReentrantLock implements Serializable {
//HashMap的那一套,分別是數組、鍵值對數量、閾值、負載因子
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int threshold;
final float loadFactor;
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
//segment中HashEntry[]數組最小長度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//用於定位在segments數組中的位置,下面介紹
final int segmentMask;
final int segmentShift;
看下 Segment 數組,它的意義是:將一個 Segment 分割成多個小的 table 來進行加鎖,也就是上面的提到的分段鎖,而每一個 Segment 元素存儲的是 HashEntry 數組,每一個 HashEntry (數組+鏈表)
有沒有覺得 Segment 很像 HashMap 的組成...
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;
transient int modCount;
transient int threshold;
final float loadFactor;
}
再看下 HashEntry 的內部構造
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
//其他省略...
}
(2) 構造方法
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 最大併發數爲 1<<16=65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 2 的sshif次方等於ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
int sshift = 0;
//ssize 爲segments數組長度,根據concurrentLevel計算得出
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//默認值,concurrencyLevel 爲 16,sshift 爲 4,那麼計算出 segmentShift 爲 28,segmentMask 爲 15
//segmentShift和segmentMask這兩個變量在定位segment位置時會用到,後面會詳細講
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
/* initialCapacity設置整個 segm 的初始容量,根據 initialCapacity 計算 Segment 數組的每個位置可以分配的大小
如 initialCapacity 爲 64,那麼每個 Segment 可以分到大小爲 4 的 HashEntry 數組*/
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,爲什麼是 2 呢?因爲對於具體的槽上,插入一個元素不會立刻擴容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//創建segments數組並初始化第一個Segment,其餘的Segment延遲初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
總結一下,無外乎幾步:
- 確定最大併發數 concurrencyLevel(最大爲 2^{16})
- 根據 concurrencyLevel 確定 sshift 和 ssize
- ssize 初始值爲 1,通過左移得到一個 >= concurrencyLevel 的最小 2 的冪次方數。
- sshift 表示 sszie 左移的次數。
- 根據 sshift 算出 segmentShift = 32 - sshift 根據 ssize 算出 segmentMask = ssize - 1,因爲 ssize 的值爲 2^n,所以減 1 就變成爲二進制位中第 n 位以下全是 1 的二進制數。
- 確定每個槽的初始容量 initialCapacity,但最大不能超過 MAXIMUM_CAPACITY = 2
- 根據 ssize 和 initialCapacity 算出每個槽中 HashEntry 數組的長度 cap(這也一定要是一個 2 的冪次方數,具體看代碼)
- 根據 loadFactor (0.75)、cap 創建第一個 Segment 對象 s0,這個是有講究的,後面在說。
(3) put 方法
public V put(K key, V value) {
Segment<K,V> s;
if (value == null) //1. ConcurrentHashMap不允許valus爲空
throw new NullPointerException();
int hash = hash(key); //2. 根據key計算hash值,key也不能爲null,否則hash(key)報空指針
//3. 根據hash值計算在segments數組中的位置
// hash 是 32 位,無符號右移 segmentShift(28) 位,用剩下的高 4 位,
// 和 segmentMask(15) 做一次與操作,也就是說數組下標 j 是 hash 值的高 4 位
int j = (hash >>> segmentShift) & segmentMask;
//4. 取第Segment數組的 j 個位置的元素
if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null) // nonvolatile + in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
總結一下:
- 調用 hash 方法,算出非空 key 的 32 位整數變量 hash
- 用 hash 右移 segmentShift 位的結果對 segmentMask 進行按位與 & 操作得到 Segment 數組的下標 j:
- 如果該位置的 Segment 還沒有初始化,就會通過 CAS 操作對位置的 Segment 代用 ensureSegment 方法進行賦值。
- 否則,直接調用 Segment 的 put 方法進行賦值。
CAS(compare and swap):即比較並交換,CAS 機制當中使用了 3 個基本操作數:
- 內存地址 V,預期的舊值 A,要修改的新值 B。
- 更新一個變量的時候,只有當變量的預期的舊值 A 和內存地址 V 當中的實際值相同時,纔會將內存地址 V 對應的值修改爲 B。
接着看一下 ensureSegment 方法的邏輯
3.1 ensureSegment 方法
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
// 在 seg 的第 j 個位置爲空的情況下(見put方法)進到該方法內部
// 因爲可能存在併發情況,故要檢查是否被其他線程先初始化了 seg 的 u 位置,是就先返回
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 這裏相當於利用seg[0]來初始化了一個"HashMap"
// 可以直接使用"當前"的 seg[0] 處的數組長度和負載因子來初始化 segment[k],省去一些瑣碎的計算 cap、lf、thre...
// 爲什麼說“當前”呢,是因爲seg[0]可能被其他線程修改過(擴容等等)
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化 segment[u] 內部的數組
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 再次檢查該槽是否被其他線程初始化,儘量減少下面的初始化操作
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) {
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用保險的自旋:用 CAS 一直檢查,直到當前線程成功設值或其他線程成功設值後才退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用 cas 操作來更新值(內存地址ss[u]、預期值null、新值 seg),存在兩種情況:
// 1.因爲被其他線程搶先操作了(不等於預期值 null),所以更新失敗,然後繼續循環直到滿足預期值爲止
// 2.更新成功,break
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
看完我有點感嘆:不得不說高併發設計是多麼嚴格啊,只要我還沒有到初始化成功,我就要用 CAS 機制檢查我要賦值的位置是否被其他線程先初始化了!
總結一下:
- 根據索引 k 計算出 segment 數組的第 u 個位置
- 檢查 segment 數組的第 u 個位置是否已經有值
- 沒有,則繼續利用 ss[0] 的屬性來初始化 seg
- 有則,返回該位置的已存在的值
- 因爲中間經歷了一些耗時的初始化動作,所以又檢查了一遍 ss[u] 是否有值,如果沒有,繼續按部就班。
- 最後利用 while + if 的 cas 自旋操作一直檢查到,cas 成功爲止。
執行完 ConcurrentHashMap 的 put 方法,接下來就是執行返回的 Segment 對象的的 put 方法了
3.2 Segment.put()
這個方法作用就是在獲取的 segment 對象內部的 HashEntry 數組/鏈表中放入/插入參數 key、value 等元素
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往該 segment 對象放入之前,先非阻塞地嘗試獲取該 segment 對象的鎖
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table; //獲取segment對象內部的數組
int index = (tab.length - 1) & hash; //再利用參數hash求出放置k、v的數組下標
HashEntry<K,V> first = entryAt(tab, index); // 獲取數組index位置的鏈表表頭
for (HashEntry<K,V> e = first;;) { //遍歷鏈表
if (e != null) {
K k; //if操作檢查是否需要覆蓋舊值
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (onlyIfAbsent == false) {//是否替換取決於調用者的意願
e.value = value;
++modCount;
}
break;
}
e = e.next;
}else {// 判斷node到底是否爲null,這個要看獲取鎖的過程,不過和這裏都沒有關係。
// 如果不爲 null,使用頭插法插在鏈表表頭;如果是null,初始化並設置爲鏈表表頭。
if (node != null) node.setNext(first);
else node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 超過了該 segment 的閾值,這個 segment 需要擴容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else // 沒有達到閾值,將 node 放到數組 tab 的 index 位置
// 注:裏面是調用UNSAFE的put方法保證將對象存到內存中,而不是僅僅插在線程的工作空間中
setEntryAt(tab, index, node); // 鏈表下移
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock(); //解鎖
}
return oldValue;
}
對 scanAndLockForPut 方法有一些疑惑,不妨看看這個場景:
現在 Thread1 調用當前 seg[5] 對象的 put 方法存值,假設它可成功拿到鎖,根據計算,得出它要存的鍵值對應該放在 HashEntry[] 的 0 號位置,0 號位置爲空,於是新建一個 HashEntry,並通過 setEntryAt() 方法,放在 0 號位置,然而還沒等 Thread1 釋放鎖,系統的時間片切到了 Thread2 ,先畫圖存檔:
此時正好 Thread2 也來存值,通過下標計算,Thread2 被定位到 seg[5] 中 HashEntry[] 的 0 號位置,接下來 Thread2 也調用當前 seg 對象的 put 方法,一開始先嚐試獲取鎖,沒有成功 (Thread1 還未釋放,沒有插入完畢),就會去執行 scanAndLockForPut() 方法:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash); //根據hash值獲取當前HashEntry數組的對應位置的結點
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node,控制分支
while (!tryLock()) { // 自旋並嘗試獲取鎖
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // 進到這裏說明數組該位置的鏈表是空的,沒有任何元素
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key)) retries = 0;//如果發現key重複證明該位置的值不空
else e = e.next; //否則繼續遍歷
}
// 重試次數如果超過 MAX_SCAN_RETRIES(單核重試1次多核64次)
else if (++retries > MAX_SCAN_RETRIES) {
lock(); // lock() 是阻塞方法,直到獲取鎖後返回
break;
}else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {
//每間隔2次重試就檢查鏈表是否發生被其它線程膝蓋,如果是,則重新自旋獲取鎖
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
雖然 Thread2 沒有獲取到鎖,但它並不是閒着,而是在進入 scanAndLockForPut 方法等待鎖的過程中,預先計算好自己要存放的鍵值對的在 seg[5] 中的相應位置,以便拿到鎖時立刻執行賦值操作,達到節省時間的作用。
Q1:那爲什麼在自旋的時候還要去檢查鏈表是否被改變了呢?- A1:這是因爲當 Thread2 確定了插入的位置在 0 號位置,但 Thread1 已經完成插入了,那麼此時根據 new HashEntry<K,V>(hash, key, value, first) 計算出來的值可能會造成 hash 衝突,所以要重新咯...
看到這裏,其實整體感覺也不難,總結一下:
- 非阻塞地嘗試獲取 seg 對象的可重入鎖
- 這裏使用了非阻塞的 tryLock 去獲取鎖
題外話:另外,如果是阻塞式地獲取鎖就應該調用可重入鎖的 lk.lock() 方法
- 使用頭插法插入到合適的位置,位置可能是數組也可能是鏈表
- 插入完畢還有一些是否需要擴容的檢查,下面會講到。
3.3 Segment.rehash()
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table; //獲取原table
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;//長度取原長的2倍
threshold = (int)(newCapacity * loadFactor);//得到新的閾值
HashEntry<K,V>[] newTable = //創建新table
(HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1; //新的掩碼,如從16擴容到32,那麼 sizeMask 爲 31,對應二進制 ‘000...00011111’
// 遍歷原數組,將原數組位置 i 處的鏈表拆分到新數組位置 i 和 i+oldCap 兩個位置
for (int i = 0; i < oldCapacity ; i++) {
//e是鏈表表頭
HashEntry<K,V> e = oldTable[i];
if (e != null) { //不空才進行數據遷移
HashEntry<K,V> next = e.next;
//重定位,假設原數組長度爲16,e 在oldTable[3]處,那麼idx只可能是3或者是3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) //數組的鏈表只有一個元素
newTable[idx] = e;
else { //循環遷移
HashEntry<K,V> lastRun = e;//e 是鏈表表頭
int lastIdx = idx; //idx是當前鏈表的頭結點 e 的新位置
//該for循環會找到一個lastRun節點,區間[lastRun, end]中的結點的下標都是一樣的,所以在新數組的位置是一樣的
for (HashEntry<K,V> last = next; last != null; last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) { //只有下標不一樣纔會更新
lastIdx = k;
lastRun = last;
}
}
newTable[lastIdx] = lastRun; //直接賦值就不用將lastRun後面的所有結點一個一個地插入
//下面的for是遷移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];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 將要插入的node結點放到新數組中某個位置鏈表頭部
int nodeIndex = node.hash & sizeMask;
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
rehash 是在加鎖的 put 方法中調用的,所以不會產生線程不安全問題。邏輯也比較清楚,總結一下:
- 計算新長度、新閾值、新掩碼。
- 遍歷老數組,將位置 i 的元素遷移到新數組的兩個位置
i / i + oldCap
之一中去,中間涉及到了一些細節,可看上述代碼註釋。 - 最後就將,要插入的新 node 插到新數組中某個位置鏈表頭部。
好,至此 ConcurrentHashMap 的 put 方法也就講解完畢,下面到它的 get 方法...
(4) get 方法
get 方法相對比較簡單
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key); // 1.獲取hash值
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//2.根據hash值找到對應的 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
//3.從內存中找到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;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
(5) size 方法
public int size() {
final Segment < K, V > [] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // 總修改次數
long last = 0 L; // 上一次的總修改次數
int retries = -1; // first iteration isn't retry
try {
for(;;) { //如果遍歷次數達到2次以上,證明在第二次遍歷時存在併發修改問題,故在第三次遍歷時,對每個seg對象加上鎖
if(retries++ == RETRIES_BEFORE_LOCK) {//RETRIES_BEFORE_LOCK=2
for(int j = 0; j < segments.length; ++j)
ensureSegment(j).lock();
}
sum = 0 L;
size = 0;
overflow = false;
for(int j = 0; j < segments.length; ++j) {
Segment < K, V > seg = segmentAt(segments, j);
if(seg != null) {
sum += seg.modCount; //得到當前seg對象的修改次數(put、remove)
int c = seg.count; //得到單個seg的大小
if(c < 0 || (size += c) < 0) //標記是否如果產生溢出
overflow = true;
}
}
if(sum == last) //直到和上一次修改總次數得到的總和相等,ConcurrentHashMap 沒有被修改過
break;
last = sum;
}
} finally {
if(retries > RETRIES_BEFORE_LOCK) {
for(int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock(); //解鎖
}
}
return overflow ? Integer.MAX_VALUE : size; //如果發生溢出,返回最大整形
}
- Q1:爲什麼方法中的 modCount 只增不減,這樣設計的目的是什麼?
- A1:還是從併發的角度來分析,這樣設計的目的是避免一個線程 put 元素和另一個線程 remove 元素後抵消了前面線程的 put 動作的 modCount,進而避免在統計 size 的時候產生死循環問題。
總結一下:
- 前後兩次計算出 ConcurrentHashMap 內部的每一個 Segment 對象的 modCount 總和到 sum 和 last 中。
- 如果兩次遍歷得到的結果不同,即 sum != last 則證明存在線程併發修改,到第三次遍歷就會對每一個 seg 對象都加上鎖,然後再次遍歷,直到 sum = last 退出循環
- 其中需要記錄 map 中的元素總數是否發生溢出。
恢復: https://www.javadoop.com/post/hashmap#toc_3 https://www.jianshu.com/p/9c713de7bbdb https://juejin.im/post/5a2f2f7851882554b837823a https://www.cnblogs.com/study-everyday/p/6430462.html#autoid-2-0-0 https://www.cnblogs.com/chengxiao/p/6842045.html