聊一聊volatile的可見性和有序性

衆所周知,volatile無法保證原子性,但是可以保證可見性和有序性,今天就結合實際案例聊一聊volatile的可見性和有序性,同時詳細說一下happens-before原則中關於volatile的部分,最後說一下稍帶同步的概念。

1、可見性

基於對JMM的瞭解,線程從主內存中加載變量(比如實例變量)副本到自己的工作內存,後面使用的都是工作內存中的值,在單線程環境下,這是沒有問題的。但是,在多線程環境下就可能會產生數據不一致了。比如,線程1和線程2先讀取變量a到自己的工作內存,然後線程1修改了變量a的值,這個修改並不會立即更新到主內存,就算立即更新到了主內存,線程2也可能不會立即從主內存去拿變量a的最新值,這樣就會導致線程2使用的變量a還是其工作內存中的舊值,就出現了數據不一致的現象,也叫線程1對變量a的修改對線程2不可見。結合下面的代碼再詳細闡述一下:

/**
 * @author debo
 * @date 2020-04-25
 */
public class VisibleTest {

    public static class MyThread extends Thread {
        public boolean isStop = false;

        @Override
        public void run() {
            int i = 0;
            // (2)
            while (!isStop) {
                i++;
            }
            System.out.println("thread has stopped, i=" + i);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread t = new MyThread();
        t.start();
        Thread.sleep(1000);
        // (1)
        t.isStop = true;
    }
}

使用 -server JVM參數運行程序,發現程序永遠無法停止,雖然主線程在代碼1處設置isStop=true,但是子線程在代碼2處使用的isStop變量還是其工作內存中的舊值,子線程並不能立即感知到主線程對isStop變量的修改。

爲了解決這個問題,需要使用volatile關鍵字修飾isStop變量,執行修改過後的代碼,發現程序可以正常結束。

這就是volatile的可見性。使用volatile修飾的變量在被修改時,會立即更新到主內存,其他線程再次使用該變量時,也會直接從主內存中讀取變量的最新值而不是使用工作內存中的舊值。

2、有序性

對於這樣的代碼:

/**
 * @author debo
 * @date 2020-04-25
 */
public class OrderedTest {
    private Object obj;
    private int a;
    private boolean isAllInit;

    public void init() {
        // (1)
        obj = new Object();
        // (2)
        a = 1;
        // (3)
        isAllInit = true;
    }

    public void use() {
        // (4)
        if (isAllInit) {
            System.out.println(a);
            System.out.println(obj.toString());
        }
    }

    public static void main(String[] args) {
        final OrderedTest orderedTest = new OrderedTest();
        new Thread(new Runnable() {
            @Override
            public void run() {
                orderedTest.init();
            }
        }, "線程1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                orderedTest.use();
            }
        }, "線程2").start();
    }
}

代碼3一定會在代碼1和代碼2之後執行嗎?這個不一定,最極端的情況下,經過編譯器指令重排優化以後,代碼的執行順序變成3,1,2

        // (3)
        isAllInit = true;
        // (1)
        obj = new Object();
        // (2)
        a = 1;

指令重排序對於單線程沒什麼問題,但是在多線程環境下,線程1執行到代碼3處時,線程2剛好進入代碼4的if代碼塊,打印的a的值將會是0,同時還會拋出NPE異常,那這個問題就大了。

要想保證代碼3一定會在代碼1和2之後執行,就要使用volatile關鍵字修飾變量 isAllInit ,這樣的話代碼3絕對不會先於代碼1和2執行(但是不保證代碼1一定會先於代碼2執行),那麼如果線程2能進入代碼4的if代碼塊,就一定能保證讀取的是被初始化過的變量a和obj。這就是volatile的局部有序性,或者叫局部禁止指令重排序。

總結性概括就是,對volatile變量的讀或寫操作一定會後於該操作之前的代碼執行,同時也一定會先於該操作之後的代碼執行。

3、有關volatile的happens-before原則

happens-before原則中有兩條我想單獨拎出來講講。

  • 原則一:對一個volatile變量的寫操作happens-before對此變量的讀或寫操作
  • 原則二:如果A操作happens-before B操作,B操作happens-before C操作,那麼A操作happens-before C操作

原則二就是happens-before的傳遞性原則,原則一不大好理解,其實翻譯成大白話就是線程1對變量v的修改操作以及對該操作之前其它變量的修改,都對線程2可見。

在前面第2小節中有一個結論:那麼如果線程2能進入代碼4的if代碼塊,就一定能保證讀取的是被初始化過的變量a和obj,這個結論下的有點牽強,爲什麼這樣說呢?因爲根據volatile的可見性和有序性,只能得出線程1初始化了變量obj和a,但是無法確定線程2能拿到最新的變量obj和a的值,因爲這兩個變量沒有被volatile修飾,線程1修改了這兩個變量後不需要立即寫入主內存,那麼線程2有可能從主內存中讀取的變量obj等於null,a等於0(如果之前已經使用過這兩個變量,那麼後面就有可能直接讀工作內存中的舊值了)。但是,有happens-before原則一的保證,一切就變得不同了。雖然變量obj和a沒有被volatile修飾,但是線程1將變量isAllInit的修改寫回主內存時,會將變量obj和a的修改一併寫回。這時候線程2讀取isAllInit的值時,需要從主內存讀取最新值,同時也將變量obj和a的最新值讀到工作內存。

關於原則二,我們可以簡述成如果hb(A, B)且hb(B, C),那麼有hb(A, C)成立。看一下以下兩個代碼片段,其中變量nonsense是volatile實例變量。

片段1:

a = 1;
nonsense = true;

片段2:

System.out.println(nonsense);
b = a;

在多線程環境中假設有以下執行順序,其中t1~t4表示執行時間的先後順序

線程1 線程2
t1 a = 1
t2 nonsense = true
t3 System.out.println(nonsense)
t4 b = a

那麼b能否被賦予a的最新值呢?已知hb(t1, t2),hb(t3, t4),又根據原則一得出hb(t2, t3),所以根據傳遞性原則,hb(t1, t4),所以b等於1。(其實根據原則一就能得出b等於1,可以認爲傳遞性原則依賴於原則一)

這裏t2, t3可以不用volatile來舉例,只要是任何滿足hb(t2, t3)的原則(比如t2是對併發集合的寫,t3是對併發集合的讀),都能保證最終的傳遞性。

但是,如果多線程的執行順序是以下情況:

線程1 線程2
t1 a = 1
t2 System.out.println(nonsense)
t3 nonsense = true
t4 b = a

此時b能否被賦予a的最新值呢?由於hb(t2, t3)已經不成立了,無法滿足傳遞性原則,所以不能保證b一定等於1。

由此,我們可以引申出一個稍帶同步的概念,如下代碼段中,result只是普通的實例變量(無volatile修飾),且hb(method1, method2),同時method1沒有執行完時method2會阻塞等待

    public Object get() {
        method2();
        return result;
    }

    public void set() {
        result = newValue;
        method1();
    }

這種情況下,在多線程併發執行get, set方法時,能保證一個線程能讀取到另一個線程修改的result的最新值,雖然result變量並沒有進行任何同步控制。這就是稍帶同步(piggybacking on synchronization) 的概念,在JUC併發包中有很多地方用到了這樣的變量同步方式,也許是基於性能考慮,因爲哪怕是給變量加一個volatile關鍵字那也比不加時性能低啊!但是我們自己寫代碼的話,肯定不建議使用稍帶同步,這個不好理解同時維護難度也大。

4、參考資料

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