寫在開頭
在《耗時2天,寫完HashMap》這篇文章中,我們提到關於HashMap執行緒不安全的問題,主要存在如下3點風險:
風險1: put的時候導致元素丟失;如兩個執行緒同時put,且key值相同的情況下,後一個執行緒put操作覆蓋了前一個執行緒的操作,導致前一個執行緒的元素丟失。
風險2: put 和 get 併發時會導致 get 到 null;若一個執行緒的put操作觸發了陣列的擴容,這時另外一個執行緒去get,因為擴容的操作很耗時,這時有可能會卡死或者get到null。
風險3: 多執行緒下擴容會死迴圈;多執行緒下觸發擴容時,因為前一個執行緒已經破壞了原有連結串列結構,後一個執行緒再去讀取節點,進行連結的時候,很可能發生順序錯亂,從而形成一個環形連結串列,進而導致死迴圈。
Hashtable解決執行緒安全靠譜嗎?
那我們怎麼辦呢?很多小夥伴可能第一時間想到了HashTable,因為它和HashMap擁有者相似的功能,底層也是基於雜湊表實現,陣列+連結串列構建,陣列容量到達閾值後,同樣會自動擴容,Hashtable 預設的初始大小為 11,之後每次擴充,容量變為原來的 2n+1。並且,Hashtable內部的方法幾乎都是synchronized關鍵字修飾,保證了執行緒的安全
。
哇!這樣一看,Hashtable簡直是解決HashMap執行緒不安全的天選之子啊!但事實上,因為效能的問題,Hashtable已經在被廢棄的邊緣了,非常不建議在程式碼中使用它,原因如下接著往下看。
我們先寫一個小小的測試類,來感受一下Hashtable的使用。
【程式碼示例1】
public class Test {
public static void main(String[] args) {
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "I");
map.put(2, "love");
map.put(3, "Java");
Hashtable<Integer, String> hashtable = new Hashtable<>();
hashtable.put(1, "JavaBuild");
for (Map.Entry<Integer, String> entry : hashtable.entrySet()) {
System.out.println(entry.getKey()+":"+entry.getValue());
}
}
}
輸出:
1:JavaBuild
然後,我們跟入到put中的原來,去看看它的底層實現
【原始碼解析1】
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
透過這段原始碼我們能夠發現
1、Hashtable雜湊值的計算,並沒有像HashMap那樣重新計算,而是直接取key的hashCode()方法,這樣一來它的擾動次數明顯降低,hash的重合度更高;
2,index的位置計算中,Hashtable採用了%取餘運算,而HashMap採用的是&運算,我們知道位運算直接對記憶體資料進行操作,不需要轉成十進位制,處理速度非常快,相比之下Hashtable的效率低下。
3,底層大部分的方法都是synchronized修飾,我們知道用synchronized 來保證執行緒安全的效率非常低下。當一個執行緒訪問同步方法時,其他執行緒也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 新增元素,另一個執行緒不能使用 put 新增元素,也不能使用 get,競爭會越來越激烈效率越低。
以上3點足以讓我們頭也不回的捨棄Hashtable,那麼問題來了,除了這個集合類外,我們還有什麼選項呢?這時,ConcurrentHashMap
高高的舉起了它的小手!
ConcurrentHashMap
文章寫到這些,終於引出了我們今天的主角,ConcurrentHashMap!作為一個效率又高,又能保證執行緒安全的集合類,它的使用頻率非常之高,話不多說,我們先來畫一個底層邏輯實現圖感受一下它的魅力!
JDK1.8下的ConcurrentHashMap底層實現
哦,對了,雖然我們現在主流的Java版本都是1.8+了,但很多公司在面試的時候,提及ConcurrentHashMap時,有時候還是會問到1.7的底層實現,因此,學有餘力的小夥伴,私下裡把JDK1.7的底層原始碼也拿過來讀讀哈(build哥本地沒有安裝JDK1.7,就不貼原始碼解析了)。
JDK1.8中ConcurrentHashMap拋棄了原有的 Segment 分段鎖,採用了 CAS + synchronized 來保證併發安全性,底層結構採用Node陣列+連結串列/紅黑樹,當連結串列長度達到一定長度後,會轉為紅黑樹,這和HashMap一樣。
【PUT原始碼解析】
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 不能為空
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// f = 目標位置元素
Node<K,V> f; int n, i, fh;// fh 後面存放目標位置的元素 hash 值
if (tab == null || (n = tab.length) == 0)
// 陣列桶為空,初始化陣列桶(自旋+CAS)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶內為空,CAS 放入,不加鎖,成功了就直接 break 跳出
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 加鎖加入節點
synchronized (f) {
if (tabAt(tab, i) == f) {
// 說明是連結串列
if (fh >= 0) {
binCount = 1;
// 迴圈加入新的或者覆蓋節點
for (Node<K,V> e = f;; ++binCount) {
K ek;
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
原始碼有點長,大致做了如下幾點:
- 先根據 key 計算出 hashcode;
- 判斷陣列桶是否為空,若為空則透過tab = initTable(),初始化陣列桶(自旋+CAS);
- 計算出key的陣列桶位置後,如果為空表示當前位置可以寫入資料,利用 CAS 嘗試寫入,失敗則自旋保證成功;
- 如果當前位置的 “hashcode == MOVED == -1”,則需要進行擴容;
- 如果都不滿足,則利用 synchronized 鎖寫入資料;
- 如果數量大於 TREEIFY_THRESHOLD 則要執行樹化方法,在 treeifyBin 中會首先判斷當前陣列長度 ≥64 時才會將連結串列轉換為紅黑樹。
【原始碼擴充套件1】
上面put的時候,若Node陣列桶為空時,需要進行初始化,那麼我們跟入initTable()中去看一看它的原始碼實現。
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl < 0 ,說明另外的執行緒執行CAS 成功,正在進行初始化。
if ((sc = sizeCtl) < 0)
// 讓出 CPU 使用權
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
從原始碼中我們可以看到,它的初始化是透過CAS和自旋完成的,注意其中的sizeCtl私有成員變數,當它的值小於0(準確來說等於-1)時,說明另外的執行緒執行CAS 成功,正在進行初始化。透過Thread.yield()做執行緒讓步動作,讓出CPU的使用權,自旋等待,隨著獲得資源,進入CAS。
知識點補充
CAS(compare and swap) 譯為:比較與交換
// 如果在這個位置(address) 的值等於 這個值(expectedValue),那麼交換(newValue)。
boolean CAS(address,expectedValue,newValue) {
if(address 的 value == expectedValue) {
address 的 value = newValue;
return true;
}
}
自旋: 所謂的自旋,旨線上程搶鎖失敗後進入阻塞狀態,放棄 CPU,需要過很久才能再次被排程。但經過測算,大部分情況下,雖然當前搶鎖失敗,但過不了很久,鎖就會被釋放。因此,當某個執行緒搶佔 CPU 失敗後,保持就緒狀態,一旦鎖釋放,就會繼續搶佔。
以上這2點內容,在後面的併發多執行緒中會著重學習,在這裡淺淺點名,讓大家明白他們的意思和作用即可。
【原始碼擴充套件2】
當連結串列的長度大於8時,會轉為紅黑樹,而紅黑樹的實現,是透過底層的TreeBin,我們跟進去看一下。
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; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
...
}
TreeBin透過root屬性維護紅黑樹的根結點,因為紅黑樹在旋轉的時候,根結點可能會被它原來的子節點替換掉,在這個時間點,如果有其他執行緒要寫這棵紅黑樹就會發生執行緒不安全問題,所以在 ConcurrentHashMap 中TreeBin透過waiter屬性維護當前使用這棵紅黑樹的執行緒,來防止其他執行緒的進入。
【Get原始碼解析】
與put相比,get的原始碼就簡單太多了,大概進行了如下幾步操作:
1,根據計算出來的 hash 值定址,如果在桶上直接返回值;
2,如果是紅黑樹,按照樹的方式獲取值;
3,如果是連結串列,按連結串列的方式遍歷獲取值;
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// key 所在的 hash 位置
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果指定位置元素存在,頭結點hash值相同
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
// key hash 值相等,key值相同,直接返回元素 value
return e.val;
}
else if (eh < 0)
// 頭結點hash值小於0,說明正在擴容或者是紅黑樹,find查詢
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
// 是連結串列,遍歷查詢
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
總結
文章寫到這裡,ConcurrentHashMap的介紹基本講完了,我們現在來自我總結一下為啥它的效率又高,又能保證執行緒安全。
以JDK1.8版本闡述:
- Node + CAS + synchronized 保證併發安全,每次上鎖的顆粒度細到連結串列或紅黑樹的根節點,不會影響其他Node的讀寫,此外CAS是輕量級的,synchronized 也經過了鎖升級;
- JDK1.7的版本里採用的Segment 分段鎖,顆粒度粗不說,Segment 的個數一旦初始化就不能改變。 Segment 陣列的大小預設是 16,也就是說預設可以同時支援 16 個執行緒併發寫。而1.8的版本中,Node是一個陣列,初始預設為16,後續仍然可以以2的冪次方級別進行擴容,因此,它所支援的併發量要看它陣列的真實容量;
- 效率高是因為它底層採用了和JDK1.8中HashMap相同的陣列+連結串列/紅黑樹結構。
結尾彩蛋
如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
如果您想與Build哥的關係更近一步,還可以關注俺滴公眾號“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!