Java中的volatile關鍵字


本文大綱

1. 重排序
2. volatile的特性
3. happens-before
  3.1 線程內的happens-before
  3.2 線程間的happens-before
4. JMM底層實現原理

1. 重排序

  首先,我們來看一段代碼:

public class JmmTest1 {

    static int a = 0, b = 0, x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            x = b;
            a = 1;
        });

        Thread t2 = new Thread(() -> {
            y = a;
            b = 1;
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("x=" + x + " y=" + y);
    }
}

  上面這段代碼中,x、y的結果可能會有如下三種情況:

x=0,y=0(例如t1執行完第一個賦值語句后,再切換到t2執行賦值語句);

x=0,y=1(t1先執行完,再執行t2);

x=1,y=0(t2先執行完,再執行t1)。

(注:本文中,在非代碼片段中的“=”均念作等於,非賦值操作。)

  但是,還存在一種看起來不可能的結果x=1,y=1。

  我們可以對上面的代碼稍加修改,以便展示x=1,y=1的情況:

public class JmmTest2 {
    private static int count = 0;
    static int a = 0, b = 0, x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        boolean flag = true;
        while (flag) {

            Thread t1 = new Thread(() -> {
                x = b;
                a = 1;
            });

            Thread t2 = new Thread(() -> {
                y = a;
                b = 1;
            });

            t1.start();
            t2.start();

            // 讓t1、t2線程先執行
            t1.join();
            t2.join();

            System.out.println("第" + ++count + "次打印: " + "x=" + x + " y=" + y);

            if (x == 1 && y == 1) {
                flag = false; // 停止循環
            } else {
                // 復位
                a = 0;
                b = 0;
                x = 0;
                y = 0;
            }

        }
    }
}

  我在我的機器上跑了一次上面的代碼,在進行了179007次循環后出現了x=1,y=1的情況,截圖如下:

  造成這種結果的原因可能有:

  • 即時編譯器的重排序;
  • 處理器的亂序執行。

  即時編譯器和處理器可能將代碼中沒有數據依賴的代碼進行重排序。但如果代碼存在數據依賴關系,那么這部分代碼不會被重排序。上面的示例代碼中,t1線程中對a、x的賦值就不存在依賴關系,所以可能會發生重排序。t2線程同理。

  代碼被重排序后,可能存在如下的順序:

Thread t1 = new Thread(() -> {
    a = 1; // 重排序后,t1先對a進行賦值
    x = b;
});

Thread t2 = new Thread(() -> {
    b = 1; // 重排序后,t2先對b進行賦值
    y = a;
});

 

  這種情況下,當一個線程對a、b其中的一個變量進行賦值后,CPU切換到另外一個線程對另外一個變量進行賦值,就會出現x=1,y=1的結果。

  需要指出的是,在單線程情況中,即使經過重排序的代碼也不會影響代碼輸出正確的結果。因為即時編譯器和處理器會遵守as-if-serial語義,即在單線程情況下,要給程序一個順序執行的假象,即使經過重排序的代碼的執行結果要和代碼順序執行的結果一致。但是,在多線程的情況下,即時編譯器和處理器是不會對經過重排序的代碼做任何保證。同時,Java語言規范將這種歸咎於我們的程序沒有做出恰當的同步操作,即我們沒有顯式地對數據加上volatile聲明或者其他加鎖操作。

2. volatile的特性

  • 禁止某些編譯器的重排序
  • 線程間共享數據的可見性;
  • 不保證原子性。

  類字段加上volatile修飾符后,就不會出現上述的代碼片段中的重排序問題。

  我們用下面這張圖來幫忙理解線程間共享數據的可見性。

  可以看到,每個線程都有一個自己的本地內存用於存放該線程所使用的共享變量的副本。本地內存是JMM中的一個抽象概念,並沒有真實存在。當對一個volatile變量進行寫操作時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存,由於內存寫操作同時會無效化其他處理器所持有的、指向同一內存地址的緩存行,因此可以認為其他處理器能夠立即見到該volatile字段的最新值。

  關於不保證原子性,是指對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種復合操作不具有原子性,如以下代碼:

    class VolatileAtomicFeature {
        volatile long vl = 0L; // 使用volatile修飾long型變量

        public void set(long l) {
            vl = l;
        }

        public void getAndIncrement() {
            vl++;
        }

        public long get() {
            return vl;
        }
    }

  當有3個線程同時調用上面代碼中的3個方法時,上面的代碼和下面的代碼在語義上是等價的:

    class VolatileAtomicFeature {
        long vl = 0L; // 型普通變量

        public synchronized void set(long l) { // 同步方法
            vl = l;
        }

        public void getAndIncrement() { // 普通方法
            long temp = get(); // 調用同步的讀方法
            temp += 1L;
            set(temp); // 調用同步的寫方法
        }

        public synchronized long get() { // 同步方法
            return vl;
        }
    }

3. happens-before

  Java 5明確定義了Java內存模型。其中最為重要的一個概念就是happens-before。

  happens-before是用於描述兩個操作間數據的可見性的。如果X happens-before Y,那么X的結果對於Y可見。下面將講述單一線程和多線程情況下的happens-before。

3.1 線程內的happens-before

  在同一個線程中,字節碼的先后順序暗含了happens-before的關系。在代碼中靠前的代碼happens-before靠后的代碼。但是,這並不意味前面的代碼一定比后面的代碼先執行,如果后面的代碼沒有依賴於前面代碼的結果,那么它們可能會被重排序,從而后面的代碼可能會先執行,就像文中前面提到的一樣。

3.2 線程間的happens-before

  如果一個操作A happens-before另一個操作B,那么操作A的執行結果對操作B可見。

  先重點關注下面的happens-before關系中標紅的部分:

  • volatile字段的寫操作happens-before 之后(這里指時鍾順序先后)對同一字段的讀操作
  • 解鎖操作happens-before之后(這里指時鍾順序先后)對同一把鎖的加鎖操作;
  • 線程的啟動操作(即 Thread.starts()) happens-before 該線程的第一個操作;
  • 線程的最后一個操作happens-before它的終止事件(即其他線程通過 Thread.isAlive() 或 Thread.join() 判斷該線程是否中止);
  • 線程對其他線程的中斷操作happens-before被中斷線程所收到的中斷事件(即被中斷線程的 InterruptedException 異常,或者第三個線程針對被中斷線程的 Thread.interrupted 或者 Thread.isInterrupted 調用);
  • 構造器中的最后一個操作happens-before析構器的第一個操作;
  • happens-before具備傳遞性

  上文我們的代碼中,除了有線程內的happens-before關系,沒有定義其他任何線程間的happens-before關系,並且t1線程和t2線程中的賦值操作沒有數據依賴關系,所以可能會發生重排序,從而得到x=1,y=1的結果。根據線程間的happens-before關系,我們可以對a或者b加上volatile修飾符來避免這個問題。

  以給JmmTest2.java文件中的成員變量a加上volatile修飾符為例:

public class JmmTest2 {
    private static int count = 0;
    static volatile int a = 0; // 變量a加上volatile關鍵字
    static int b = 0, x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        boolean flag = true;
        while (flag) {

            Thread t1 = new Thread(() -> {
                x = b;
                a = 1;
            });

            Thread t2 = new Thread(() -> {
                y = a;
                b = 1;
            });

            t1.start();
            t2.start();

            // 讓t1、t2線程先執行
            t1.join();
            t2.join();

            System.out.println("第" + ++count + "次打印: " + "x=" + x + " y=" + y);

            if (x == 1 && y == 1) {
                flag = false; // 停止循環
            } else {
                // 復位
                a = 0;
                b = 0;
                x = 0;
                y = 0;
            }

        }
    }
}

  一旦a加上了volatile,即時編譯器和CPU需要考慮到多線程happens-before關系,t1線程中x、a的賦值操作和t2線程中y、b的賦值操作將不能自由地重排序,所以x的賦值操作先於a的賦值操作執行。同時,根據volatile字段的寫操作happens-before之后對同一字段的讀操作,所以a的賦值操作先於y的賦值操作執行,這也就意味着,當對b進行賦值時,對x的賦值操作已經完成了(確切的說,這段話不是十分准確,但這樣可以幫助你理解)。正確的理解應該是:兩個操作具有happens-before關系時,並不意味前一個操作必須要在后一個操作前執行,happens-before僅僅要求前一個操作的執行結果對后一個操作可以見。所以,在a為volatile字段的情況下,程序不可能出現x=1,y=1的情況。

  總之,解決這種問題的關鍵在於構造一個線程間的happens-before關系。

4. JMM底層實現原理

  Java內存模型是通過內存屏障(memory barrier)來禁止重排序的。這些內存屏障會限制即時編譯器的重排序操作。以 volatile 字段訪問為例,所插入的內存屏障將不允許volatile字段寫操作之前的內存訪問被重排序至其之后;也將不允許volatile 字段讀操作之后的內存訪問被重排序至其之前。在碰到內存寫操作時,處理器並不會等待該指令結束,而是直接開始下一指令,並且依賴於寫緩存將更改的數據同步至主內存(main memory)之中。強制刷新寫緩存,將使得當前線程寫入volatile字段的值(以及寫緩存中已有的其他內存修改),同步至主內存之中。

參考文章

  極客時間鄭雨迪《深入拆解Java虛擬機》專欄的《Java內存模型》。

  程曉明《深入理解Java內存模型》。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM