天天看點

深入了解 JVM(9)volatile 關鍵字

轉載請注明原創出處,謝謝!

HappyFeet的部落格

被 volatile 修飾的變量具備兩種特性:

(1)保證該變量對所有線程的可見性;

(2)禁止指令重排序優化。

1、保證該變量對所有線程的可見性

可見性是指,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主記憶體,同時其他線程每次使用前都會從主記憶體讀取,進而新值對于其他線程來說是可以立即得知的。但普通變量做不到這點,普通變量的值線上程間傳遞均需要通過主記憶體來完成。

在 Java 記憶體模型(深入了解 JVM(6)Java 記憶體模型)中,可以知道,在多線程中,每一個線程都有各自的工作記憶體,工作記憶體是該線程使用到的變量的主記憶體副本拷貝,對變量的所有操作(讀取、指派等)都必須在工作記憶體中進行,并且不會立即更新至主記憶體中。這就可能造成一個變量在一個線程中修改了,還沒來得及更新至主記憶體,主記憶體中該變量的值就被其他線程使用了,而此時變量的值為修改前的舊值。

例如:線程 A 和線程 B 都用到了變量 C,線程 A 中對變量 C 執行了 +1 操作,由于是線上程 A 的工作記憶體中修改的,并且沒有立即回寫至主記憶體,而在此時刻線程 B 從主記憶體讀取變量 C 的值,得到的是過時的結果,即線程 A 對變量 C 的修改對線程 B 來說不可見。

volatile 變量不一樣,它擁有特殊的通路規則:

(1)使用前必須先從主記憶體重新整理最新的值;

(2)修改後必須立刻同步回主記憶體中。

以上兩條特殊規則,給人以

volatile 變量不是存在于工作記憶體而是存在于主記憶體中

的假象,進而保證了 volatile 變量對所有線程的可見性。

雖然 volatile 變量對所有線程是立即可見的,但是,基于 volatile 變量的運算在并發下不一定是安全的。例如:(注意:該例在 IDEA 中隻能以 debug 模式運作,以 run 模式運作會陷入死循環,原因可以參考一下這個:在閱讀《深入了解Java虛拟機》一書中,執行代碼清單12-1的例子時,疑似發現bug?)

public class VolatileTest {

    private static final int THREADS_COUNT = 20;

    private static volatile int race = 0;

    private static void increase() {
        race++;
    }

    public static void main(String[] args) {

        Thread[] threads = new Thread[THREADS_COUNT];

        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });

            threads[i].start();
        }

        while (Thread.activeCount() > 1) {
            Thread.yield();
        }

        System.out.println("race = " + race);

    }

}
           
output:
	// 這個輸出應該每次都不一樣,都是一個小于 200000 的數字。
	race = 189716
           

如果能夠正确并發的話,最後輸出結果應該是 200000,然後結果卻是小于 200000 的一個數,問題出在哪呢?其實就在 race++ 中,race++ 看似隻有一行代碼,其實是有三個操作:

a)讀取 race 的值;
b)将 race 值加一;
c)将 race 值寫回記憶體。
           

volatile 關鍵字能夠保證 a)操作讀取的 race 的值在這一時刻是正确的,但在執行 b)、c)操作時,可能有其他的線程已經對 race 的值進行了修改,導緻了 c)操作可能把較小的 race 值同步回主記憶體之中。是以要想保證結果的正确性,需要在 increase() 方法加鎖才行。

以下是适用 volatile 變量的兩種運算場景:

  • 運算結果并不依賴變量的目前值,或者能夠保證確定隻有單一的線程修改變量的值。
  • 變量不需要與其他的狀态變量共同參與不變限制。

像下面的代碼就很适合使用 volatile 變量來控制并發:

volatile boolean shutdownRequested;

    public void shutDown() {
        shutdownRequested = true;
    }

    public void doWork() {
        while (!shutdownRequested) {
            // do stuff
        }
    }
           

但遇到不符合上述兩條規則的運算場景時,就需要加鎖來保證原子性。

2、禁止指令重排序優化

為了盡可能的減小記憶體操作速度遠慢于 CPU 運作速度所帶來的 CPU 空置的影響,虛拟機會按照自己的一些規則将程式編寫順序打亂,而這一打亂,就可能幹擾程式的并發執行。

例如:

Map configOptions;
    char[] configText;
    // 此變量必須定義為 volatile
    volatile boolean initialized = false;

    // 假設以下代碼線上程 A 中運作
    // 模拟讀取配置資訊, 當讀取完成後将 initialized 設定為 true 以告知其他線程配置可用
    configOptions = new HashMap();
    configText = readConfigFile(fileName);
    processConfigOptions(configText, configOptions);
    initialized = true;
    
    
    
    // 假設以下代碼線上程 B 中運作
    // 等待 initialized 為 true, 代表線程 A 已經把配置資訊初始化完成
    while(!initialized) {
        sleep();
    }
    // 使用線程 A 中初始化好的配置資訊
    doSomethingWithConfig();
           

假如上面僞代碼中 initialized 沒有用 volatile 修飾,就可能由于指令重排序的優化,導緻位于線程 A 中最後一句代碼

initialized = true;

被提前執行,這樣線上程 B 中使用配置資訊的代碼就可能出錯。

再比如,double-check locking 中:

public class Singleton {

    private static volatile Singleton instance;

    public static Singleton getInstance() {

        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }

        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
           

這裡 volatile 修飾變量的作用在于禁止指令重排序,而不是它的可見性。

在 Double-checked_locking 中提到:

Due to the semantics of some programming languages, the code generated by the compiler is allowed to update the shared variable to point to a partially constructed object before A has finished performing the initialization. For example, in Java if a call to a constructor has been inlined then the shared variable may immediately be updated once the storage has been allocated but before the inlined constructor initializes the object.

由于某些程式設計語言的語義,允許編譯器生成的代碼在 A 完成初始化之前更新共享變量以指向部分構造的對象。例如:在 Java 中,如果共享變量的引用已經和構造函數的調用内聯了,即使構造器未完成初始化,共享變量也可能立即被更新。

對應于 double-check locking 例子來說就是:

這一行代碼分三個步驟:

(1)在堆上配置設定記憶體;
(2)賦初值;
(3)将 instance 引用指向堆位址。
           

假如是以(1)(3)(2)的順序執行,其他線程就可能得到一個未完成初始化的 instance ,導緻程式報錯。而添加 volatile 修飾之後可以阻止指令的重排序(Java 1.5 及以後的版本),進而避免了這種 case。

3、總結

volatile 關鍵字是 Java 虛拟機提供的最輕量級的同步機制,使用 volatile 可能比鎖更快,但在某些情況下它不起作用。在 Java 1.5 中擴充了 volatile 有效的情況範圍。 特别是,雙重檢查加鎖機制現在可以正常工作。

參考資料:

(1)《深入了解 Java 虛拟機》周志明 著.

(2)并發關鍵字volatile(重排序和記憶體屏障)

(3)Double-checked_locking

繼續閱讀