天天看點

JAVA技術-volatile關鍵字

作者:代碼小郭

01、簡介

volatitle經常被用到并發程式設計的場景中。它的作用有兩個,即:

  • 保證可見性;
  • 保證有序性。

但是,要注意volatile關鍵字并不能保證原子性。

JAVA技術-volatile關鍵字

02、如何保證可見性

由于每個線程都有自己的工作空間,導緻多線程的場景下會出現緩存不一緻性的問題。即,當兩個線程共用一個共享變量時,如果其中一個線程修改了這個共享變量的值。但是由于另外一個線程在自己的工作記憶體中已經保留了一份該共享變量的副本,是以它無法感覺該變量的值已經被修改。volatile可以解決這個問題。

先看一個沒有使用volatile時的問題:

 package com.gyd;

public class VolatileDemo1 {
    private static boolean flag = false;

    public static class PhoneThread extends Thread {
        @Override
        public void run() {
            System.out.println("PhoneThread is running...");
            while (!flag) ; // 如果flag為false,則死循環
            System.out.println("PhoneThread is end");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new PhoneThread().start();
        Thread.sleep(1000);
        flag = true;
        System.out.println("flag = " + flag);
        Thread.sleep(5000);
        System.out.println("main thread is end.");
    }
}           

上面代碼運作輸出預期是:

PhoneThread is running...
PhoneThread is end
flag = true
main thread is end.           

實際是:

PhoneThread is running...
flag = true
main thread is end.           

上面代碼涉及一個PhoneThread線程和Main線程,每個線程都有自己的一個工作記憶體副本,各自有flag的一個變量副本。當Main線程中修改了flag值時,隻影響了Main線程中的工作副本,而PhoneThread的工作副本依舊是舊的值。

那麼接下來我們将成員變量flag使用volatile關鍵字修飾後,再運作看列印日志:

 public class VolatileDemo2 {
    private volatile static boolean flag = false;

    public static class PhoneThread extends Thread {
        @Override
        public void run() {
            System.out.println("PhoneThread is running...");
            while (!flag) ; // 如果flag為false,則死循環
            System.out.println("PhoneThread is end");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new PhoneThread().start();
        Thread.sleep(1000);
        flag = true;
        System.out.println("flag = " + flag);
        Thread.sleep(5000);
        System.out.println("main thread is end.");
    }
}
           

輸出結果符合預期:

PhoneThread is running...
PhoneThread is end
flag = true
main thread is end.           

可見,當在主線程中修改了flag為true後,PhoneThread線程立即感覺了flag的變化,并結束了死循環。

從上面例子中也可以看見volatile确實能有效的保證多個線程共享變量的可見性!

03、如何保證有序性

編譯器為了優化程式性能,可能會在編譯時對位元組碼指令進行重排序。重排序後的指令在單線程中運作時沒有問題的,但是如果在多線程環境中,重排序後的代碼則可能會出現問題。是以,一般在多線程并發情況下我們都應該禁止指令重排序的優化。而volatile關鍵字就可以禁止編譯器對位元組碼進行重排序。

雙重鎖校驗就是一個典型例子,在單例模式下需要使用volatile關鍵字來禁止指令重排序。我們來看下代碼:

package com.gyd;

public class VolatileDemo3 {

    private volatile static VolatileDemo3 instance;

    private VolatileDemo3(){}

    public static VolatileDemo3 getInstance(){

        //第一次檢測
        if (instance==null){
            //同步
            synchronized (VolatileDemo3.class){
                if (instance == null){
                    //多線程環境下可能會出現問題的地方
                    instance = new VolatileDemo3();
                }
            }
        }
        return instance;
    }
}           

如果上述代碼中沒有給instance加上volatile關鍵字會怎麼呢?我們不妨來分析一下,首先我們應該清楚instance = new VolatileDemo3();這一操作并不是一個原子操作,執行個體化對象的位元組指令可以分為三步,如下:

1.配置設定對象記憶體:memory = allocate();

2.初始化對象:instance(memory);

3.instance指向剛配置設定的記憶體位址:instance = memory;

而由于編譯器的指令重排序,以上指令可能會出現以下順序:

1.配置設定對象記憶體:memory = allocate();

2.instance指向剛配置設定的記憶體位址:instance = memory;

3.初始化對象:instance(memory);

以優化後的位元組碼指令來看雙重鎖校驗的代碼是否有問題呢?不難發現,如果線程1第一次調用單例方法,在該線程的時間片輪轉結束後執行到了優化後的第二個指令,即instance被指派,但是還未被配置設定初始化對象。此時,線程2搶到了CPU時間片,同時調用了getInstance方法,第一次校驗就發現instance不為null,遂将其傳回。在得到這個單例後調用單例的方法,此時必定出現空指針異常。

是以,可見指令重排序在多線程并發的情況下是會出現問題的。此時,我們便可以通過volatile關鍵字來禁止編譯器的優化,進而避免空指針的出現。

04、原理

Java 定義一個volatile修飾的變量:

volatile instance = new Singleton(); // instance 是 volatile 變量

轉變成彙編代碼,如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);

x01a3de24: lock addl $0×0,(%esp)

可以看到除了第一行彙編以外,還額外多了第二行lock彙編指令。隻要有 volatile 變量修飾的共享變量進行寫操作的時候就會多出第二行彙編指令lock。

有lock标記的指令在多核處理器下會引發如下兩件事情:

  • 将處理器中的變量緩存值寫入系統記憶體
  • 其它處理器的緩存中該變量值會失效,強制從系統記憶體中讀取變量最新的值

由于cpu的處理速度和記憶體處理速度不一緻,在計算機架構設計中,大佬們專門在cpu和記憶體之間增加了一層内部緩存(L1,L2或其它)。cpu讀寫操作都是先和緩存層進行互動,最終通過一些機制刷回系統記憶體中。

JAVA技術-volatile關鍵字

jmm記憶體模型就是這種方式:

JAVA技術-volatile關鍵字

JMM可以簡單的了解為線程通路共享變量的方式。可見JMM是Java并發程式設計的底層基礎,想要深入了解并發程式設計,就需要先了解JMM。

JMM規定所有變量都存儲在主記憶體中,每條線程還有自己的工作記憶體。線程的工作記憶體中儲存了被線程使用的變量的主記憶體副本,線程對變量的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的資料。不同線程之間也無法直接通路對方的工作記憶體中的變量,線程間變量值的傳遞需要通過主記憶體來完成。 也就是說Java線程之間的通信采用的是共享記憶體。

然而這種緩存架構在多處理器并發場景下會引發緩存一緻性的問題:每個處理器對同一個變量都有一份自己的副本在自己的緩存中,當某個處理器對變量進行了修改,其它處理器從自己緩存中拿到的還是該變量的舊值。

在多處理器下,為了保證各個處理器的緩存是一緻的,就會實作緩存一緻性協定,每個處理器通過嗅探在總線上傳播的資料來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的記憶體位址被修改,就會将目前處理器的緩存行設定成無效狀态,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器緩存裡。這就是volatile的效果!

繼續閱讀