你的 Java 併發程序 Bug,100% 是這幾個緣由形成的

可見性問題

可見性是指一個線程對共享變量進行了修改,其餘線程可以立馬看到該共享變量更新後的值,這視乎是一個合情合理的要求,可是在多線程的狀況下,可能就要讓你失望了,因爲每一個 CPU 都有本身的緩存,每一個線程使用的多是不一樣的 CPU ,這就會出現數據可見性的問題,先來看看下面這張圖:java

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 編譯器認爲最優的順序執行,從而可能打亂代碼的執行順序,是它會保證程序最終執行結果和代碼順序執行的結果是一致的,這也就是咱們所說的指令重排序spa

因爲指令重排序形成程序出 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 公司也給出了相應的解決辦法,具體以下圖所示,這些解決辦法的更多細節,咱們後面在細細道來。

併發解決機制

文章不足之處,望你們多多指點,共同窗習,共同進步

最後

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

平頭哥的技術博文
相關文章
相關標籤/搜索