1、概念
ConcurrentHashMap繼承了AbstractMap,實現了ConcurrentMap,Serializable接口,ConcurrentMap又實現了Map接口。ConcurrentHashMap是基於散列表實現的,存儲的是Key/Value對,底層使用數組+鏈表+紅黑樹+CAS算法實現的,數組是存儲元素並且查找快,鏈表是爲了解決哈希衝突而存在的,紅黑樹是爲了解決鏈表中查詢速度慢而使用的。CAS可以彌補HashMap線程不安全的缺點,使ConcurrentHashMap實現線程安全。
2、空間結構
對象序列化的UID,類序列化時會傳入一個serialVersionUID。在反序列化時,JVM會把傳來的字節流中的serialVersionUID於本地相應實體類的serialVersionUID進行比較。如果相同說明是一致的,可以進行反序列化,否則會出現反序列化版本一致的異常,即是InvalidCastException。
private static final long serialVersionUID = 7249069246763182397L;
默認的初始容量大小爲16。
private static final int DEFAULT_CAPACITY = 16;
默認最大容量爲1左移30位,aka 2的30次方。最好是通過構造函數指定,以免多次擴容影響效率。
private static final int MAXIMUM_CAPACITY = 1 << 30;
默認的裝載因子0.75,即當容量到達默認容量*裝載因子時就會擴容。
private static final float LOAD_FACTOR = 0.75f;
默認的鏈表長度達到8以後,鏈表就會轉化爲紅黑樹(實際上後面還有一個判斷條件)。
static final int TREEIFY_THRESHOLD = 8;
默認當紅黑樹元素個數將爲6個時,轉換回鏈表。
static final int UNTREEIFY_THRESHOLD = 6;
默認的當數組長度小於這個值時,會先進行擴容稀釋一個鏈表存儲位置,當數組長度大於64且鏈表長度大於8時,就會轉換爲紅黑樹。
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap中存儲這些Node<K,V>所使用的數組。使用transient修飾跟ArrayList類似,節省序列和時不必要的空間,同時避免在不同JVM中hashcode方法不同導致桶位置不一樣。
transient Node<K,V>[] table;
擴容時使用,平時爲 null,只有在擴容的時候才爲非 null
private transient volatile Node<K,V>[] nextTable;
該屬性用來控制 table 數組的大小,根據是否初始化和是否正在擴容有幾種情況:
當值爲負數時:如果爲-1 表示正在初始化,如果爲-N 則表示當前正有 N-1 個線程進行擴容操作;
當值爲正數時:如果當前數組爲 null 的話表示 table 在初始化過程中,sizeCtl 表示爲需要新建數組的長度;
若已經初始化了,表示當前數據容器(table 數組)可用容量也可以理解成臨界值(插入節點數超過了該臨界值就需要擴容),具體指爲數組的長度 n 乘以 加載因子 loadFactor;
當值爲 0 時,即數組長度爲默認初始值。
private transient volatile int sizeCtl;
Node 類實現了 Map.Entry 接口,存放包括hash值、key、value和鏈表的指針,重寫了equals方法,與HashMap不同的是,屬性加了volatile修飾,保證內存可見性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
......
}
TreeNode 樹節點,繼承於承載數據的 Node 類。而紅黑樹的操作是針對 TreeBin 類的,TreeBin 會將 TreeNode 進行再一次封裝。
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
......
}
TreeBin 這個類並不負責包裝用戶的 key、value 信息,而是包裝的很多 TreeNode 節點。實際的 ConcurrentHashMap“數組”中,存放的是 TreeBin 對象,而不是 TreeNode 對象。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1;
static final int WAITER = 2;
static final int READER = 4;
......
}
ConcurrentHashMap的大概結構與hashMap類似。
3、常用方法
構造函數
一共提供瞭如下幾個構造器方法:
// 1. 構造一個空的map,即table數組還未初始化,初始化放在第一次插入數據時,默認大小爲16
ConcurrentHashMap()
// 2. 給定map的大小
ConcurrentHashMap(int initialCapacity)
// 3. 給定一個map
ConcurrentHashMap(Map<? extends K, ? extends V> m)
// 4. 給定map的大小以及加載因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
// 5. 給定map大小,加載因子以及併發度(預計同時操作數據的線程)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
解析第二種構造函數:如果容量小於0直接拋出異常,大於了指定最大值則取指定最大值,否則對容量進行tableSizeFor處理,賦給sizeCtl。
public ConcurrentHashMap(int initialCapacity) {
//1. 小於0直接拋異常
if (initialCapacity < 0)
throw new IllegalArgumentException();
//2. 判斷是否超過了允許的最大值,超過了話則取最大值,否則再對該值進一步處理
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//3. 賦值給sizeCtl
this.sizeCtl = cap;
}
- tableSizeFor具體做法就是把當前容量的二進制最高位右邊的位都填上1。實際上返回了一個比給定容量大且接近2的冪次方的一個整數。借用一張圖來演示過程。
//返回一個比給定容量大且接近2的冪次方的一個整數。
static final int tableSizeFor(int cap) {
//減1防止cap剛好是2的冪次方數。
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
tabAt
該方法用來獲取 table 數組中索引爲 i 的 Node 元素。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
casTabAt
利用 CAS 操作設置 table 數組中索引爲 i 的元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
setTabAt
該方法用來設置 table 數組中索引爲 i 的元素
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
initTable
該方法用來初始化map,該方法可能存在多線程同時訪問。爲了保證能夠正確初始化,在第 1 步中會先通過 if 進行判斷,若當前已經有一個線程正在初始化即 sizeCtl 值變爲-1,這個時候其他線程在 If 判斷爲 true 從而調用 Thread.yield()讓出 CPU 時間片。正在進行初始化的線程會調用 U.compareAndSwapInt 方法將 sizeCtl 改爲-1 即正在初始化的狀態。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
// 保證只有一個線程正在進行初始化操作
Thread.yield();
// CAS 一下,將 sizeCtl 設置爲 -1,代表搶到了鎖
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// 得出數組的大小,DEFAULT_CAPACITY 默認初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化數組,長度爲 16 或初始化時提供的長度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 將這個數組賦值給 table,table 是 volatile 的
table = tab = nt;
// 4. 計算數組中可用的大小:實際大小n*0.75(加載因子)
sc = n - (n >>> 2);
}
} finally {
// 設置 sizeCtl 爲 sc,即數組中可用大小
sizeCtl = sc;
}
break;
}
}
return tab;
}
putVal
整體思路和hashMap的put操作相似,也要分爲數組下標無節點、鏈表和紅黑樹,爲了解決線程安全的問題,ConcurrentHashMap 使用了 synchronzied 和 CAS 的方式。
整體流程:
- 首先對於每一個放入的值,首先利用 spread 方法對 key 的 hashcode 進行一次 hash 計算,由此來確定這個值在 table 中的位置;
- 如果當前 table 數組還未初始化,先將 table 數組進行初始化操作;
- 如果這個位置是 null 的,那麼使用 CAS 操作直接放入;
- 如果這個位置存在結點,說明發生了 hash 碰撞,首先判斷這個節點的類型。如果該節點 fh==MOVED(代表 forwardingNode,數組正在進行擴容)的話,說明正在進行擴容;
- 如果是鏈表節點(fh>0),則得到的結點就是 hash 值相同的節點組成的鏈表的頭節點。需要依次向後遍歷確定這個新加入的值所在位置。如果遇到 key 相同的節點,則只需要覆蓋該結點的 value 值即可。否則依次向後遍歷,直到鏈表尾插入這個結點;
- 如果這個節點的類型是 TreeBin 的話,直接調用紅黑樹的插入方法進行插入新的節點;
- 插入完節點之後再次檢查鏈表長度,如果長度大於 8,就把這個鏈表轉換成紅黑樹;
- 對當前容量大小進行檢查,如果超過了臨界值(實際大小*加載因子)就需要擴容。
final V putVal(K key, V value, boolean onlyIfAbsent) {
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;
// 如果數組"空",進行數組初始化
if (tab == null || (n = tab.length) == 0)
// 初始化數組
tab = initTable();
// 找該 hash 值對應的數組下標,得到第一個節點 f
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果數組該位置爲空,用CAS 操作將這個新值放入其中即可
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
//當前正在擴容
else if ((fh = f.hash) == MOVED)
// 幫助數據遷移,這個等到看完數據遷移部分的介紹後,再理解這個就很簡單了
tab = helpTransfer(tab, f);
//f 是該位置的頭結點,而且不爲空
else {
V oldVal = null;
// 獲取數組該位置的頭結點的監視器鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
//因爲fh>0,所以當前爲鏈表,在鏈表中插入新的鍵值對
if (fh >= 0) {
// 用於累加,記錄鏈表的長度
binCount = 1;
// 遍歷鏈表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果發現了"相等"的 key,判斷是否要進行值覆蓋,然後也就可以 break 了
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,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 7.插入完鍵值對後再根據實際大小看是否需要轉換成紅黑樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//對當前容量大小進行檢查,如果超過了臨界值(實際大小*加載因子)就需要擴容
addCount(1L, binCount);
return null;
}
get
整體流程:
- 計算 hash 值
- 根據 hash 值找到數組對應位置: (n - 1) & h
- 根據該位置處結點性質進行相應查找
- 如果該位置爲 null,那麼直接返回 null 就可以了
- 如果該位置處的節點剛好就是我們需要的,返回該節點的值即可
- 如果該位置節點的 hash 值小於 0,說明正在擴容,或者是紅黑樹
- 如果以上 3 條都不滿足,那就是鏈表,進行遍歷比對即可
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 重hash
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2. table[i]桶節點的key與查找的key相同,則直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 3. 當前節點hash小於0說明爲樹節點,在紅黑樹中查找即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
//4. 從鏈表中查找,查找到則返回該節點的value,否則就返回null即可
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
擴容操作
兩種觸發擴容條件:
- 如果新增節點之後,所在鏈表的元素個數達到了閾值 8,則會調用treeifyBin方法把鏈表轉換成紅黑樹,不過在結構轉換之前,會對數組長度進行判斷,如果數組長度n小於閾值MIN_TREEIFY_CAPACITY,默認是64,則會調用tryPresize方法把數組長度擴大到原來的兩倍,並觸發transfer方法,重新調整節點的位置。
- 新增節點之後,會調用addCount方法記錄元素個數,並檢查是否需要進行擴容,當數組元素個數達到閾值時,會觸發transfer方法,重新調整節點的位置。
整個擴容操作分爲兩個部分:
-
構建一個 nextTable,它的容量是原來的兩倍,這個操作是單線程完成的。新建 table 數組的代碼爲:Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1],在原容量大小的基礎上右移一位。
-
將原來 table 中的元素複製到 nextTable 中,主要是遍歷複製的過程。
根據運算得到當前遍歷的數組的位置 i,然後利用 tabAt 方法獲得 i 位置的元素再進行判斷:- 如果這個位置爲空,就在原 table 中的 i 位置放入 forwardNode 節點。
- 如果這個位置是 Node 節點(fh>=0),使用fn&n可以快速把鏈表中的元素區分成兩類,A類是hash值的第X位爲0,B類是hash值的第X位爲1,並通過lastRun記錄最後需要處理的節點。把他們分別放在 nextTable 的 i 和 i+n 的位置上
- 如果這個位置是 TreeBin 節點(fh<0),也做一個反序處理,並且判斷是否需要 untreefi,把處理的結果分別放在 nextTable 的 i 和 i+n 的位置上
- 遍歷過所有的節點以後就完成了複製工作,這時讓 nextTable 作爲新的 table,並且更新 sizeCtl 爲新容量的 0.75 倍 ,完成擴容。
節點遷移示意圖:
遷移之前的節點:
使用fn&n可以快速把鏈表中的元素區分成兩類,A類是hash值的第X位爲0,B類是hash值的第X位爲1,並通過lastRun記錄最後需要處理的節點。
ln鏈:
hn鏈:
通過CAS把ln鏈表設置到新數組的i位置,hn鏈表設置到i+n的位置;
源碼如下:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//單核時stride等於n,多核時爲(n >>> 3) / NCPU,stride 可以理解爲”步長“,有 n 個位置是需要進行遷移的,將這 n 個任務分爲多個任務包,每個任務包有 stride 個任務
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
//新建Node數組,容量爲之前的兩倍
if (nextTab == null) {
try {
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE;
return;
}
//用於擴容遷移使用
nextTable = nextTab;
//用於控制遷移的位置
transferIndex = n;
}
int nextn = nextTab.length;
//新建forwardingNode引用,在之後會用到
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance 指的是做完了一個位置的遷移工作,可以準備做下一個位置的了
boolean advance = true;
boolean finishing = false;
// i 是位置索引,bound 是邊界
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 確定遍歷中的索引i,i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//將原數組中的元素複製到新數組中去
//for循環退出,擴容結束脩改sizeCtl屬性
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的遷移操作已經完成
nextTable = null;
// 將新的 nextTab 賦值給 table 屬性,完成遷移
table = nextTab;
// 重新計算 sizeCtl: n 是原數組長度,所以 sizeCtl 得出的值將是新數組長度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//使用 CAS 操作對 sizeCtl 進行減 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
}
}
//當前數組中第i個元素爲null,用CAS設置成特殊節點forwardingNode(可以理解成佔位符)
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//如果遍歷到ForwardingNode節點 說明這個點已經被處理過了 直接跳過 這裏是控制併發擴容的核心
else if ((fh = f.hash) == MOVED)
advance = true;
else {
// 對數組該位置處的結點加鎖,開始處理數組該位置處的遷移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 頭結點的 hash 大於 0,說明是鏈表的 Node 節點
if (fh >= 0) {
// 處理當前節點爲鏈表的頭結點的情況,構造兩個鏈表,找到原鏈表中的 lastRun,然後 lastRun 及其之後的節點是一起進行遷移的, lastRun 之前的節點需要進行克隆,然後分到兩個鏈表中
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;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//在nextTable的i位置上插入一個鏈表
setTabAt(nextTab, i, ln);
//在nextTable的i+n的位置上插入另一個鏈表
setTabAt(nextTab, i + n, hn);
//在table的i位置上插入forwardNode節點 表示已經處理過該節點,其他線程一旦看到該位置的 hash 值爲 MOVED,就不會進行遷移了
setTabAt(tab, i, fwd);
//設置advance爲true 代表該位置已經遷移完畢,返回到上面的while循環中 就可以執行i--操作
advance = true;
}
//處理當前節點是TreeBin時的情況,操作和上面的類似
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;
}
}
// 如果一分爲二後,節點數少於 8,那麼將紅黑樹轉換回鏈表
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;
// 將 ln 放置在新數組的位置 i
setTabAt(nextTab, i, ln);
// 將 hn 放置在新數組的位置 i+n
setTabAt(nextTab, i + n, hn);
// 將原數組該位置處設置爲 fwd,代表該位置已經處理完畢,其他線程一旦看到該位置的 hash 值爲 MOVED,就不會進行遷移了
setTabAt(tab, i, fwd);
// advance 設置爲 true,代表該位置已經遷移完畢
advance = true;
}
}
}
}
}
}
4、總結
HashTable 和同步包裝器包裝的 HashMap,使用一個全局的鎖來同步不同線程間的併發訪問,導致對容器的訪問變成串行化的了。ConcurrentHashMap 是一個併發散列映射表的實現。1.7與1.8的ConcurrentHashMap對比:
- 數據結構:取消了Segment分段鎖的數據結構,取而代之的是數組+鏈表+紅黑樹的結構。
- 保證線程安全機制:JDK1.7採用segment的分段鎖機制實現線程安全,其中segment繼承自ReentrantLock。JDK1.8採用CAS+Synchronized保證線程安全。
- 鎖的粒度:原來是對需要進行數據操作的Segment加鎖,現調整爲對每個數組元素加鎖(Node)。
- 鏈表轉化爲紅黑樹:定位結點的hash算法簡化會帶來弊端,Hash衝突加劇,因此在鏈表節點數量大於8時,會將鏈表轉化爲紅黑樹進行存儲。
- 查詢時間複雜度:從原來的遍歷鏈表O(n),變成遍歷紅黑樹O(logN)。