天天看點

【并發程式設計】(三)多線程帶來的線程安全問題1.什麼是線程安全問題2.用三個例子來說明線程安全問題3.總結

文章目錄

  • 1.什麼是線程安全問題
    • 1.1.并發程式設計的三要素
  • 2.用三個例子來說明線程安全問題
    • 2.1.可見性問題
      • 2.1.1.模拟可見性問題
      • 2.1.2.如何解決可見性問題
    • 2.2.原子性問題
      • 2.2.1.模拟原子性問題
      • 2.2.2.Synchronized
    • 2.3.有序性問題
  • 3.總結

1.什麼是線程安全問題

線程安全問題指的是多個線程通路共享資源時,在缺少同步措施的情況下對共享資源進行了寫操作而導緻的執行結果與預期值不符的情況。

但如果多個線程隻是共同讀取共享資源,不進行寫操作是不會有線程安全問題的。

共享資源:就是多個線程都可以共同通路的資源,例如:在JVM中,處于堆、方法區中的對象。

1.1.并發程式設計的三要素

也可以說是實作線程安全需要滿足的條件,原子性、可見性、有序性。

  • 原子性:指一個操作不可分割,要麼全部成功,要麼全部失敗。
  • 可見性:一個線程對共享變量的修改,對另外一個線程立即可見。
  • 有序性:程式執行順序,按照代碼的先後順序執行。

三者任意一個出問題都會導緻線程安全問題。

2.用三個例子來說明線程安全問題

2.1.可見性問題

2.1.1.模拟可見性問題

在上一篇《如何優雅的中斷一個線程》1.2中提到使用中斷辨別來中斷一個線程時,提到使用一個用volatile修飾的boolean字段來作為循環的條件,如果去掉這個volatile會發生什麼事呢?代碼如下:

public class VisibilityDemo implements Runnable{

    private static boolean stopFlag = false;

    @Override
    public void run() {
        System.out.println("開始循環");
        while (!stopFlag) {

        }
        System.out.println("停止循環");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread volatileThread = new Thread(new VisibilityDemo());
        volatileThread.start();
        // 主線程睡一秒再修改辨別,讓上面volatileThread充分運作
        TimeUnit.SECONDS.sleep(1);
        stopFlag = true;
    }
}
           

預期的結果是列印出開始循環,然後停頓1秒後列印停止循環。但實際上列印出開始循環,等待1秒修改辨別後,依然沒有列印出停止循環,且線程處于活動狀态。

這說明volatileThread并沒有擷取到main線程對stopFlag修改後的值。

2.1.2.如何解決可見性問題

加上volatile修飾:

會按照預期結果列印,開始循環,然後停頓1秒後列印停止循環。

這說明volatile可以解決記憶體的可見性問題。

2.2.原子性問題

2.2.1.模拟原子性問題

volatile解決了可見性問題後,一個線程對共享變量的修改對另一個線程立即可見,是不是就一定的線程安全了呢?

不如看下面的例子,親自驗證一下:

public class AtomicityDemo implements Runnable {

    private static volatile int value = 0;

    @Override
    public void run() {
        value++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(new AtomicityDemo());
            thread.start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(value);
    }
}
           

多次執行這段代碼得到的結果可能不一緻,總的來說,會列印出一個<=1000的數字。這說明即使是使用volatile修飾,還是可能因為原子性的問題造成程式執行結果與預期值不符。

可以看到的是,線程中隻有一行代碼,value++,也就是說value++是個非原子操作。

如何證明value++沒有原子性?

可以使用 javap -c 打出這個類的彙編碼來看看value++都做了什麼,大部分的彙編碼省略了,下面隻複制出run方法那一部分:

public void run();
    Code:
       0: getstatic     #2                  // Field value:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field value:I
       8: return
           
  • getstatic:擷取指定類的靜态域, 并将其壓入棧頂
  • iconst_1:将int型1推送至棧頂
  • iadd:将棧頂兩int型數值相加并将結果壓入棧頂
  • putstatic:為指定類的靜态域指派

整個value++在編譯後分成了上面4個步驟,證明了value++不具備原子性。

在《【JVM】(二)運作時資料區及記憶體模型》的2.1.2有類似的方法運作過程圖示,将圖示中的一個局部變量替換成靜态變量就可以了。

為什麼不具備原子性就會有線程安全問題呢?

舉個例子,如果有兩個線程同時getstatic擷取了初始值0,再進行自增操作iadd,那麼兩個線程運作的結果都是1,都會将1指派個靜态變量。但是實際上正确的結果是2。

這就是具備可見性但不具備原子性導緻線程安全的原因,即使線程A對共享變量的修改對線程B可見,但是線程B已經線上程A作修改的步驟中擷取了靜态變量的原始值,那運算結果也是錯誤的。

2.2.2.Synchronized

改造一下上面的代碼,加入synchronized塊。

@Override
public void run() {
	synchronized (AtomicityDemo.class) {
		value++;
	}
}
           

此時無論如何運作,結果都是1000,再看一下彙編碼有什麼變化:

public void run();
    Code:
      ……
       4: monitorenter
       5: getstatic     #3                  // Field value:I
       8: iconst_1
       9: iadd
      10: putstatic     #3                  // Field value:I
      13: aload_1
      14: monitorexit
      ……
           

在同一時間隻有一個線程能進入monitorenter進行value++的操作,在前一個線程執行monitorexit後,下一個線程才有可能進入monitorenter,這兩個指令的底層就是Java記憶體模型中定義的原子操作,lock和unlock。

也就是說,Synchronized同步代碼塊中的代碼是原子性的代碼,這一部分是串行執行的,在一個線程執行完這段指令碼之前,沒有另外的線程可以執行到這段指令碼。

此外,Synchronized不僅僅可以保證原子性,也可以保證可見性和有序性。

在Synchronized塊中涉及到的對共享變量的修改,會在unlock前同步到主記憶體中,這樣的機制同樣保證的可見性。

同樣的,Synchronized每次隻讓一個線程進入同步塊執行操作,可以保證在同步塊中對共享變量的修改一定在下一個線程讀取共享變量之前,也就是保證了有序性。

上面所說的Synchronized保證原子性、可見性、有序性的前提是共享變量隻能出現在同步塊中,下面2.3的有序性問題中提到單例的雙檢鎖,就是共享變量出現在了同步塊外需要注意的問題。

2.3.有序性問題

除了可見性和原子性之外,還必須保證有序性,才能保證線程安全。Java中的有序性問題涉及到編譯器和CPU的指令重排序,在Java程式中很難複現出來。下面使用一個很常見的面試題來聊一聊有序性的問題。

先寫一個簡單的雙檢鎖懶漢式單例:

public class DoubleCheckDemo {

    private volatile static DoubleCheckDemo instance;

    public static DoubleCheckDemo getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckDemo.class) {
                if (instance == null) {
                    instance = new DoubleCheckDemo();
                }
            }
        }
        return instance;
    }
}
           

雙檢鎖單例的目的簡單提一下,就是為了保證在避免instance初始化多次的情況下,盡可能的提高并發通路的效率,但這不是本次研究的重點。

在做出這樣的優化的同時,出現了另外一個問題——半初始化問題。

什麼是半初始化問題?

對象在初始化的時候一共分為三步,按照執行順序分别為:

① 配置設定對象的記憶體空間。

② 初始化對象。

③ 将instance變量指向對象所在的記憶體空間。

如果按照①②③的順序,這個單例是沒有問題的,但是經過指令重排優化,可能執行的順序會變成①③②。

想象一下,假如現在有兩個線程的A和B,線程A執行到 new DoubleCheckDemo()時,這個步驟發生了指令重排,在執行到①③指令的時候,線程B進入第一個if判斷,此時instance已經指向了記憶體空間,那麼instance == null的判斷就是false,這時候會return instance。

那麼業務代碼中拿到的就是一個未初始化完成的instance對象,直接使用的時候肯定會出問題的。用一個更簡單的了解就是,if判斷的時候instance不為null,實際使用的時候instance為null。

執行圖示如下:

【并發程式設計】(三)多線程帶來的線程安全問題1.什麼是線程安全問題2.用三個例子來說明線程安全問題3.總結

如何解決有序性問題?

造成這段代碼出現有序性問題的原因是instance變量在Synchronized塊外做了一次null判斷,這個操作并不能保證有序性。

是以在instance變量前加一個volatile修飾,禁用指令重排序優化,保證有序性。

3.總結

① 造成線程安全問題的原因是程式代碼不具備三個特性——原子性、可見性、有序性。

② 使用volatile修飾變量,能解決變量的可見性和有序性問題,Java中的原子性操作往往加上volatile修飾被操作的字段就能保證線程安全。

③ synchronized同步代碼塊(或同步方法)解決原子性問題,此外還可以解決可見性問題。