一、概述
上一篇文章《帶你走進java集合之HashMap》分析了HashMap的實現原理,重點分析了HashMap是怎麼樣的一種資料結構,以及如何去插入,查詢,擴容等操作。相信經過上一篇文章的學習,大家應該對HashMap有了一定的瞭解,但是我們知道HashMap是一個執行緒不安全的集合,在多執行緒情況下使用HashMap會有很多問題,那麼我們如何使用一個執行緒安全的HashMap呢,接下來我們就介紹一個執行緒安全的Map,ConcurrentHashMap,我們來看看這個執行緒安全的Map道理如何使用,又是如何實現的。
二、HashMap和ConcurrentHashMap的對比
我們用一段程式碼證明下HashMap的執行緒不安全,以及ConcurrentHashMap的執行緒安全性。程式碼邏輯很簡單,開啟10000個執行緒,每個執行緒做很簡單的操作,就是put一個key,然後刪除一個key,理論上執行緒安全的情況下,最後map的size()肯定為0。
Map<Object,Object> myMap=new HashMap<>();
// ConcurrentHashMap myMap=new ConcurrentHashMap();
for (int i = 0; i <10000 ; i++) {
new Thread(new Runnable() {
@Override
public void run() {
double a=Math.random();
myMap.put(a,a);
myMap.remove(a);
}
}).start();
}
Thread.sleep(15000l);//多休眠下,保證上面的執行緒操作完畢。
System.out.println(myMap.size());
複製程式碼
結果:
這裡顯示Map的size=13,但是實際上map裡還有一個key。 同樣的程式碼我們用ConcurrentHashMap來執行下,結果如下: 這裡就證明了ConcurrentHashMap是執行緒安全的,我們接下來從原始碼分析下ConcurrentHashMap是如何保證執行緒安全的,本次原始碼jdk版本為1.8。三、ConcurrentHashMap原始碼分析
3.1 ConcurrentHashMap的基礎屬性
//預設最大的容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//預設初始化的容量
private static final int DEFAULT_CAPACITY = 16;
//最大的陣列可能長度
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//預設的併發級別,目前並沒有用,只是為了保持相容性
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//和hashMap一樣,負載因子
private static final float LOAD_FACTOR = 0.75f;
//和HashMap一樣,連結串列轉換為紅黑樹的閾值,預設是8
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹轉換連結串列的閥值,預設是6
static final int UNTREEIFY_THRESHOLD = 6;
//進行連結串列轉換最少需要的陣列長度,如果沒有達到這個數字,只能進行擴容
static final int MIN_TREEIFY_CAPACITY = 64;
//table擴容時, 每個執行緒最少遷移table的槽位個數
private static final int MIN_TRANSFER_STRIDE = 16;
//感覺是用來計算偏移量和執行緒數量的標記
private static int RESIZE_STAMP_BITS = 16;
//能夠調整的最大執行緒數量
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//記錄偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//值為-1, 當Node.hash為MOVED時, 代表著table正在擴容
static final int MOVED = -1;
//TREEBIN, 置為-2, 代表此元素後接紅黑樹
static final int TREEBIN = -2;
//感覺是佔位符,目前沒看出來明顯的作用
static final int RESERVED = -3;
//主要用來計算Hash值的
static final int HASH_BITS = 0x7fffffff;
//節點陣列
transient volatile Node<K,V>[] table;
//table遷移過程臨時變數, 在遷移過程中將元素全部遷移到nextTable上
private transient volatile Node<K,V>[] nextTable;
//基礎計數器
private transient volatile long baseCount;
//table擴容和初始化的標記,不同的值代表不同的含義,預設為0,表示未初始化
//-1: table正在初始化;小於-1,表示table正在擴容;大於0,表示初始化完成後下次擴容的大小
private transient volatile int sizeCtl;
//table容量從n擴到2n時, 是從索引n->1的元素開始遷移, transferIndex代表當前已經遷移的元素下標
private transient volatile int transferIndex;
//擴容時候,CAS鎖標記
private transient volatile int cellsBusy;
//計數器表,大小是2次冪
private transient volatile CounterCell[] counterCells;
複製程式碼
上面就是ConcurrentHashMap的基本屬性,我們大部分和HashMap一樣,只是增加了部分屬性,後面我們來分析增加的部分屬性是起到如何的作用的。
3.2 ConcurrentHashMap的常用方法屬性
- put方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key和value不允許為null
if (key == null || value == null) throw new NullPointerException();
//計算hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果table沒有初始化,進行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//計算陣列的位置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果該位置為空,構造新節點新增即可
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}//如果正在擴容
else if ((fh = f.hash) == MOVED)
//幫著一起擴容
tab = helpTransfer(tab, f);
else {
//開始真正的插入
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果已經初始化完成了
if (fh >= 0) {
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) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,alue)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}//判斷節點是否要轉換成紅黑樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);//紅黑樹轉換
if (oldVal != null)
return oldVal;
break;
}
}
}
//計數器,採用CAS計算size大小,並且檢查是否需要擴容
addCount(1L, binCount);
return null;
}
複製程式碼
我們發現ConcurrentHashMap的put方法和HashMap的邏輯相差不大,主要是新增了執行緒安全部分,在新增元素時候,採用synchronized來保證執行緒安全,然後計算size的時候採用CAS操作進行計算。整個put流程比較簡單,總結下就是:
1.判斷key和vaule是否為空,如果為空,直接丟擲異常。
2.判斷table陣列是否已經初始化完畢,如果沒有初始化,進行初始化。
3.計算key的hash值,如果該位置為空,直接構造節點放入。
4.如果table正在擴容,進入幫助擴容方法。
5.最後開啟同步鎖,進行插入操作,如果開啟了覆蓋選項,直接覆蓋,否則,構造節點新增到尾部,如果節點數超過紅黑樹閾值,進行紅黑樹轉換。如果當前節點是樹節點,進行樹插入操作。
6.最後統計size大小,並計算是否需要擴容。
- get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//計算hash值
int h = spread(key.hashCode());
//如果table已經初始化,並且計算hash值的索引位置node不為空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果hash相等,key相等,直接返回該節點的value
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}//如果hash值為負值表示正在擴容,這個時候查的是ForwardingNode的find方法來定位到節點。
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//迴圈遍歷連結串列,查詢key和hash值相等的節點。
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
複製程式碼
get方法比較簡單,主要流程如下:
1.直接計算hash值,查詢的節點如果key和hash值相等,直接返回該節點的value就行。
2.如果table正在擴容,就呼叫ForwardingNode的find方法查詢節點。
3.如果沒有擴容,直接迴圈遍歷連結串列,查詢到key和hash值一樣的節點值即可。
- ConcurrentHashMap的擴容
ConcurrentHashMap的擴容相對於HashMap的擴容相對複雜,因為涉及到了多執行緒操作,這裡擴容方法主要是transfer,我們來分析下這個方法的原始碼,研究下是如何擴容的。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//保證每個執行緒擴容最少是16,
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
//擴容2倍
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
//出現異常情況就不擴容了。
sizeCtl = Integer.MAX_VALUE;
return;
}
//用新陣列物件接收
nextTable = nextTab;
//初始化擴容下表為原陣列的長度
transferIndex = n;
}
int nextn = nextTab.length;
//擴容期間的過渡節點
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//如果該執行緒已經完成了
if (--i >= bound || finishing)
advance = false;
//設定擴容轉移下標,如果下標小於0,說明已經沒有區間可以操作了,執行緒可以退出了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}CAS操作設定區間
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//如果計算的區間小於0了,說明區間分配已經完成,沒有剩餘區間分配了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {//如果擴容完成了,進行收尾工作
nextTable = null;//清空臨時陣列
table = nextTab;//賦值原陣列
sizeCtl = (n << 1) - (n >>> 1);//重新賦值sizeCtl
return;
}//如果擴容還在進行,自己任務完成就進行sizeCtl-1,這裡是因為,擴容是通過helpTransfer()和addCount()方法來呼叫的,在呼叫transfer()真正擴容之前,sizeCtl都會+1,所以這裡每個執行緒完成後就進行-1。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//這裡應該是判斷擴容是否結束
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//結束,賦值狀態
finishing = advance = true;
i = n; // recheck before commit
}
}//如果在table中沒找到,就用過渡節點
else if ((f = tabAt(tab, i)) == null)
//成功設定就進入下一個節點
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
//如果節點不為空,並且該位置的hash值為-1,表示已經處理了,直接進入下一個迴圈即可
advance = true; // already processed
else {
//這裡說明老table該位置不為null,也沒有被處理過,進行真正的處理邏輯。進行同步鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//如果hash值大於0
if (fh >= 0) {
//為運算結果
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
//這裡的邏輯和hashMap是一樣的,都是採用2個連結串列進行處理,具體分析可以檢視我分析HashMap的文章
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;
}
}
}
}
}
}
複製程式碼
ConcurrentHashMap的擴容還是比較複雜,複雜主要表現在,控制多執行緒擴容層面上,擴容的原始碼我沒有解析的很細,一方面是確實比較複雜,本人有某些地方也不是太明白,另一方面是我覺得我們研究主要是弄懂其思想,能搞明白關鍵程式碼和關鍵思路即可,只要不是重新實現一套類似的功能,我想就不用糾結其全部細節了。總結下ConcurrentHashMap的擴容步驟如下:
1.獲取執行緒擴容處理步長,最少是16,也就是單個執行緒處理擴容的節點個數。
2.新建一個原來容量2倍的陣列,構造過渡節點,用於擴容期間的查詢操作。
3.進行死迴圈進行轉移節點,主要根據finishing變數判斷是否擴容結束,在擴容期間通過給不同的執行緒設定不同的下表索引進行擴容操作,就是不同的執行緒,操作的陣列分段不一樣,同時利用synchronized同步鎖鎖住操作的節點,保證了執行緒安全。
4.真正進行節點在新陣列的位置是和HashMap擴容邏輯一樣的,通過位運算計算出新連結串列是否位於原位置或者位於原位置+擴容的長度位置,具體分析可以檢視我的這篇文章。
四、總結
1.ConcurrentHashMap大部分的邏輯程式碼和HashMap是一樣的,主要通過synchronized和來保證節點插入擴容的執行緒安全,這裡肯定有同學會問,為啥使用synchronized呢?而不用採取樂觀鎖,或者lock呢?我個人覺得可能原因有2點:
- a.樂觀鎖比較適用於競爭衝突比較少的場景,如果衝突比較多,那麼就會導致不停的重試,這樣反而效能更低。
- b.synchronized在經歷了優化之後,其實效能已經和lock沒啥差異了,某些場景可能還比lock快。所以,我覺得這是採用synchronized來同步的原因。
2.ConcurrentHashMap的擴容核心邏輯主要是給不同的執行緒分配不同的陣列下標,然後每個執行緒處理各自下表區間的節點。同時處理節點複用了hashMap的邏輯,通過位執行,可以知道節點擴容後的位置,要麼在原位置,要麼在原位置+oldlength位置,最後直接賦值即可。
五、參考
更好地理解jdk1.8中ConcurrentHashMap實現機制