你的 Java 並發程式 Bug,100% 是這幾個原因造成的

  • 2019 年 10 月 31 日
  • 筆記

可見性問題

可見性是指一個執行緒對共享變數進行了修改,其他執行緒能夠立馬看到該共享變數更新後的值,這視乎是一個合情合理的要求,但是在多執行緒的情況下,可能就要讓你失望了,由於每個 CPU 都有自己的快取,每個執行緒使用的可能是不同的 CPU ,這就會出現數據可見性的問題,先來看看下面這張圖:

CUP 快取於主記憶體的關係

對於一個共享變數 count ,每個 CPU 快取中都有一個 count 副本,每個執行緒對共享變數 count 的操作的只能操作自己所在 CPU 快取中的副本,不能直接操作主存或者其他 CPU 快取中的副本,這也就產生了數據差異。由於可見性在多執行緒情況下造成程式問題的典型案例就是變數的累加,如下面這段程式:

public class Demo {        private int count = 0;        // 每個執行緒為count + 10000      public void add() {          for (int i = 0; i < 10000; i++) {              count += 1;          }      }        public static void main(String[] args) throws InterruptedException {            for (int i = 0; i < 10; i++) {              Demo demo = new Demo();              Thread t1 = new Thread(() -> {                  demo.add();              });              Thread t2 = new Thread(() -> {                  demo.add();              });              t1.start();              t2.start();              t1.join();              t2.join();              System.out.println(demo.count);          }      }  }

我們使用了 2 個程式對 count 變數累加,每個執行緒累加 10000 次,按道理來說最終結果應該是 20000 次,但是你多次執行後,你會發現結果不一定是 20000 次,這就是由於共享變數的可見性造成的。

我們啟動了兩個執行緒 t1 和 t2,執行緒啟動的時候會把當前主記憶體的 count 讀入到自己的 CPU 快取當中,這時候 count 的值可能是 0 也可能是 1 或者其他,我們就默認為 0,每個執行緒都會執行 count += 1 操作,這是一個並行操作,CPU1 和 CPU2 快取中的 count 都是 1,然後他們分別將自己快取中的count 寫回到主記憶體中,這時候主記憶體中的 count 也是 1 ,並不是我們預計的 2,。這個原因就是數據可見性造成的。

原子性問題

原子性:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。這個原子性針對的是 CPU 級別的,並不是我們 Java 程式碼裡面的原子性,拿我們可見性 Demo 程式中的 count += 1;命令為例,這一條 Java 命令最終會被編譯成如下三條 CPU 指令:

  • 把變數 count 從記憶體載入到 CPU 的暫存器,假設 count = 1
  • 在暫存器中執行 count +1 操作,count = 1+1 =2
  • 將結果 +1 後的 count 寫入記憶體

這是一個典型的 讀-改-寫 的操作,但是它不是原子性的,因為 多核CPU 之間有競爭關係,並不是某一個 CPU 一直執行,他們會不斷的搶佔執行權、釋放執行權,所以上面三條指令就不一定是原子性的,下圖是兩個執行緒 count += 1命令的模擬流程:

非原子性操作

執行緒1 所在的 CPU 執行完前兩條指令後,執行權被 執行緒2 所在的 CPU 搶佔了,這時候執行緒1 所在的 CPU 執行掛起等待再次獲取執行權,執行緒2 所在的 CPU 獲取到執行權之後,先從記憶體中讀取 count,此時記憶體中的 count 還是 1,執行緒2 所在的 CPU 恰好執行完了這三條指令,執行緒2 執行完之後記憶體中的 count 就等於 2 了,這時候執行緒1 再次獲取了執行權,這時候執行緒1 只剩下最後一條將 count 寫回記憶體的命令,執行完之後,記憶體中的 count 的值還是 2 ,並不是我們預計的 3。

有序性問題

有序性:程式執行的順序按照程式碼的先後順序執行,比如下面這段程式碼

1  int i = 1;  2  int m = 11;  3  long x = 23L;

按照有序性的話就需要按照程式碼的順序執行下來,但是執行結果不一定是按照這個順序來的,因為 JVM 為了提高程式的運行效率,會對上面的程式碼按照 JVM 編譯器認為最優的順序執行,從而可能打亂程式碼的執行順序,是它會保證程式最終執行結果和程式碼順序執行的結果是一致的,這也就是我們所說的指令重排序

由於指令重排序造成程式出 Bug 的典型案例就是:未加 volatile 關鍵字的雙重檢測鎖單例模式,如下程式碼:

public class Singleton {      static Singleton instance;      public static Singleton getInstance(){      // 第一次判斷      if (instance == null) {          // 加鎖,只有一個執行緒能夠獲取鎖          synchronized(Singleton.class) {              // 第二次判斷              if (instance == null)                  // 構建對象,這裡面就非常有學問了                  instance = new Singleton();              }      }      return instance;      }  }

雙重檢測鎖方案看上去非常完美,但是在實際運行時卻會出 Bug,會出現對象逸出的問題,可能會得到一個未構建完的 Singleton 對象, 這個就是在構建 Singleton 對象時指令重排序的問題。我們先來看看構建對象理想型的操作指令:

  • 指令1:分配一塊記憶體 M;
  • 指令2:在記憶體 M 上初始化 Singleton 對象;
  • 指令3:然後 M 的地址賦值給 instance 變數。

但是實際在 JVM 編譯器上可能不是這樣,可能會被優化成如下指令:

  • 指令1:分配一塊記憶體 M;
  • 指令2:將 M 的地址賦值給 instance 變數;
  • 指令3:最後在記憶體 M 上初始化 Singleton 對象。

看上去一個小小的優化,也就是這麼一個小小的優化就會使你的程式不安全,假設搶到鎖的執行緒執行完指令2 之後,此時的 instance 已經不為空了,這時候來了執行緒C,執行緒C 看到的 instance 已經是不為空的了,就會直接返回 instance 對象,這時候的 instance 並未初始化成功,調用 instance 對象的方法或者成員變數時將有可能觸發空指針異常。可能的執行流程圖:

未加 volatile 關鍵字的雙重檢測鎖單例模式

上面就是造成 Java 程式在多執行緒情況下出 Bug 的三種原因,關於這些問題 JDK 公司也給出了相應的解決辦法,具體如下圖所示,這些解決辦法的更多細節,我們後面在細細道來。

並發解決機制

文章不足之處,望大家多多指點,共同學習,共同進步

最後

打個小廣告,歡迎掃碼關注微信公眾號:「平頭哥的技術博文」,一起進步吧。
平頭哥的技術博文