常見面試題:HashMap執行緒不安全怎麼辦?
常見面試題:HashMap執行緒不安全怎麼辦?
有關 HashMap 的具體分析在前一篇隨筆中有,如不瞭解可自行檢視
HashMap 執行緒不安全其實並不能說是它的缺點,畢竟它本來就不是為了執行緒安全而設計的,因此存線上程不安全的問題是很正常的
在 JDK7 中,HashMap 的執行緒不安全主要體現在擴容時可能會導致連結串列中存在環,因為在 transfer
方法中,轉移 node 時會把 node 以頭插法的方式一個個插入到新的位置中,而在 JDK8 中,就不再使用頭插法了,因此不會再出現這個問題,不過除了這個問題之外其實還有其它問題,就算是 JDK8 中的 HashMap 也只是解決了其中部分
個人認為,其實沒什麼必要糾結於 HashMap 哪裡執行緒不安全的問題,因為不管再怎麼優化它,總歸都是存在問題的,因此,如果有執行緒安全的需求,那麼直接使用 HashTable 或者 ConcurrentHashMap 就好了
modCount
在深入這兩個類前,我們瞭解一下 fail fast
這個安全機制,也就是 HashMap 的 modCount
屬性的意義,因為 HashMap 執行緒不安全,所以我們需要一個機制,使得虛擬機器可以快速判別同時有兩個執行緒在操作這個 HashMap,最典型的場景就是,當一個執行緒正在遍歷 HashMap,此時又有另一個執行緒修改了 HashMap 中的某個元素,看看遍歷的原始碼就能明白了:
final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; //檢查modCount是否符合預期 if (modCount != expectedModCount) //不符合預期,丟擲異常 new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null) } return e; }
HashMap 的迭代器內部的邏輯就是在不斷呼叫這個 nextNode
方法,可以發現,每次迭代都會檢查一次當前的 modCount 是否和最開始相同,如果不同,說明在遍歷的過程中有其它執行緒修改了這個 HashMap 的結構,因此報出 ConcurrentModificationException
這個異常
不過這個機制其實並不可靠,有兩個需要注意的點:
- 假如只有一個執行緒,但這個執行緒邊遍歷這個 HashMap 邊修改其中的元素,其實也會報出這個異常
- 這個機制並不能發現所有的併發操作,據我觀察,似乎只有在遍歷時會使用這個機制來保證沒有併發的修改操作(注意這裡的修改指的是新增元素或者刪除元素,如果是覆蓋元素是不會讓 modCount 加一的)
HashTable
HashTable 是 HashMap 的一個執行緒安全的版本,但它的效能是比較差的,因為看一下原始碼就會發現,它幾乎在所有方法上都加上了 synchronized
關鍵字,這種加鎖的粒度實在太大了,勢必會影響到效能
HashTable 和 HashMap 還有一些地方有差別:
- 前者不允許 null 作為 key 或者 value,但後者允許
- 前者預設的初始容量為 11,後者預設的初始容量為 16
ConcurrentHashMap
JDK7 和 JDK8 中的 ConcurrentHashMap 其實是有很大差別的
我們先來看一下 JDK7 的版本,它引入了一個很重要的概念叫做 Segment
,這個類繼承了 ReentrantLock
,我們直接看一下整個 ConcurrentHashMap 的結構:
在 HashMap 中,底層就是一個 Node 陣列,而在 ConcurrentHashMap 中,底層先是一個 Segment 陣列,而在每個 Segment 中,再包含一個 Node 陣列,這樣一來,當某個執行緒在操作 segment1 裡的資料時,其實只需要把 segment1 上鎖就足夠了,這就是所謂的分段鎖,相比 HashTable 直接把整個表都給鎖了,效能是有顯著提升的
但這種實現方式也有不好的地方,就是在定位一個 node 的位置時需要定位兩次,首先定位這個 node 在哪個 Segment 中,然後再定位這個 node 在對應 table 的哪個位置中
再來看看 JDK8 中的 ConcurrentHashMap,在 JDK8 中,ConcurrentHashMap 完全捨棄了之前的分段鎖的設計,單純從資料結構上來看和 HashMap 是一樣的,但通過細粒度的 CAS 和 synchronized
來保證執行緒安全,我們簡單看一下 putVal
方法:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f;int n, i, fh;K fk;V fv;
//如果table未初始化,進行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//定位要把node放到哪個位置,如果發現這個位置為空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//****通過CAS+自旋來把node寫入這個位置****
//這裡不傾向於上鎖,是因為,寫入一個node是一件很簡單的事情,可能只需要5ms就完成了
//首先,5ms很短,這段時間裡正好有執行緒併發修改這裡的可能性很低,就算真的有,5ms就算自旋重試也沒有什麼關係
//但上鎖解鎖是一件開銷很大的事情,本身5ms就能完成,結果上鎖解鎖就花了50ms,還阻塞了其它執行緒,明顯划不來
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)
&& (fv = f.val) != null)
return fv;
//前面也不用管了,反正到這裡就說明,真的得往連結串列或者紅黑樹裡插入node了
else {
V oldVal = null;
//****通過synchronized來保證執行緒安全****
//這裡不CAS了,是因為這裡的程式碼比較複雜,可能要花200ms才能執行完畢
//200ms算是比較長的時間了,期間很有可能出現其它執行緒進行併發的操作,而一旦自旋重試,就又是200ms,很恐怖
//這種情況下,加鎖解鎖的50ms的接受度就比較大了,並且可以保證一次性成功,顯然比CAS要好一些
synchronized (f) {
...
}
//對連結串列樹化的判斷
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
總之,可以發現 JDK7 的 ConcurrentHashMap 是改動了 HashMap 的資料結構的,主要就是引入了 Segment,然後通過分段鎖來儘量減小鎖的粒度,相比 HashTable 肯定是強了不少的,但 JDK7 中的 HashMap 還沒有引入紅黑樹,這意味著長連結串列的遍歷是不可避免的,因此,在 JDK8 中 HashMap 全面升級後,java 團隊對 ConcurrentHashMap 也做了升級,拋棄了 Segment 和分段鎖的設計,完全採用升級後的 HashMap 的資料結構,但相比 HashTable,它只會在有需求的地方上鎖(粒度較小),並且有些地方會通過 CAS+自旋 來進一步減小鎖帶來的開銷,因此效能也是較好的
學習過程中,部分內容參考了網上的其它文章,如有侵權必刪