淺談Java8的HashMap為什麼執行緒不安全
PS:本文使用的Java原始碼是JDK1.8。
事情起因很簡單,起源於類似you can,you up的玩笑。我這人喜歡較真,尤其是遇見我會的問題的時候。
我們先上一組程式碼。
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
for (int j = 0; j < 100; j++) {
double i = Math.random() * 100000;
map.put("鍵" + i, "值" + i);
map.remove("鍵" + i);
System.out.println(j + "當前時間:" + i + " size = " + map.size());
}
}
結果如圖
我新增一個K,然後再移出K,size大小為0,邏輯上說是沒有任何問題的。結果證明也沒有問題。單執行緒執行程式碼一般都是沒有任何問題的,是按照邏輯來的。即使指令重排,對結果影響基本為0的。
現在我們上一組多執行緒程式碼
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
for (int i = 0; i < 100; i++) {
MyThread myThread = new MyThread(map, "執行緒名字:" + i);
myThread.start();
}
}
static class MyThread extends Thread {
public Map map;
public String name;
public MyThread(Map map, String name) {
this .map = map;
this.name = name;
}
public void run() {
double i = Math.random() * 100000;
map.put("鍵" + i, "值" + i);
map.remove("鍵" + i);
System.out.println(name + "當前時間:" + i + " size = " + map.size());
}
}
結果如圖
好像看著沒有任何差異,如果我們擴大迴圈到100000
結果如圖
這差距就非常明顯了,很明顯的有問題的,如果我們執行緒休眠1ms,再來100個迴圈。
public void run() {
double i = Math.random() * 100000;
map.put("鍵" + i, "值" + i);
try{
Thread.sleep(1);
}catch (Exception e){
e.printStackTrace();
}
map.remove("鍵" + i);
System.out.println(name + "當前時間:" + i + " size = " + map.size());
}
結果如圖
不用我多說,鐵一般的事實在眼前,HashMap不是執行緒安全的。我們一起去看看原始碼。
先看看size()這個方法原始碼
public int size() {
return size;
}
很簡單的邏輯,然後我們看看size這個變數說明
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
大意就是說包含的鍵值對數量,還是一個不可序列化物件,當然就和我們的講解無關了。
首先這個size沒有用volatile關鍵字修飾,代表這不是一個記憶體可見的變數。瞭解過多執行緒應該都知道,我們執行緒操作資料的時候一般是從主存拷貝一個變數副本進行操作。
示意圖
能領悟意思就差不多了,執行緒中的變數,都是從主存拷貝過去,操作完成過後在把size的值寫回到主存size的。
接下來我們分析一下原始碼put(K key,V value)的實現過程。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
好像沒有什麼操作,就呼叫了一個putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict)方法,我們繼續往下看。putVal()方法也沒有用synchronized修飾,代表這個方法裡面任意的位置時間片耗盡(可以類比休眠狀態,休眠是主動進入阻塞,休眠結束進入就緒狀態,時間片耗盡是進入直接進入就緒狀態)。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//這裡是核心,大概就是各種判斷,然後賦值的問題,感興趣的可以自己去了解一下。
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize()方法是擴大容器的策略,在這裡我們不用管,不是我們講解的重點,問題出在++size上面的,如果鍵是以前不存在的,那麼必然會執行++size這段邏輯。假設現在我兩個執行緒,每個執行緒都在執行put方法。
size的大致變化過程就是這樣的,理論結果應該是size=3的,而我們實際執行的結果是size=2,remove()方法的原理也是差不多的,在這裡就不詳細解釋。這肯定和我們的預期是有差距的,你想想如果去銀行存錢你存了兩次100元,銀行只給你帳號增加100元,你怕是馬上就要找銀行麻煩了,鬧得天下皆知。但是如果一筆錢你能花兩次,你估計會非常開心吧,還會覺得銀行真傻的,心裡偷著樂。
這只是一個int型變數size,我還沒有分析table儲存問題的,假設我兩個執行緒分別呼叫put(1,”111”)和put(1,”222”),那麼我get(1)取到的究竟是哪個值呢?比如我執行緒A先呼叫get(1)在get(1)還沒有執行完成的時候,A執行緒時間片用盡進入就緒狀態,然後B執行緒呼叫remove(1),A繼續回來執行的get(1)的剩餘邏輯,會不會找到的呢?這些答案無從得知,有興趣的可以自己模擬實驗一下的。
或許你會說,哪有那麼巧合的事情?世界之大,無奇不有。世界那麼大,你應該出去看看。
總結:執行緒不安全問題應該屬於併發問題之一的,屬於相對高階的問題了。這個時候的問題已經不僅僅侷限於程式碼層面了,很多時候需要結合JVM一起分析了。