淺談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一起分析了。

如有疑問,歡迎留言!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章