java併發程式設計之volatile

zyl409214686發表於2018-01-16

Java語言規範第三版中對volatile的定義如下:Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保通過排他鎖單獨獲得這個變數。

瞭解volatile關鍵字之前需要先了解下Java記憶體模型,java記憶體模型抽象示意圖如下:

Java記憶體模型

java記憶體模型抽象示意圖
執行緒A和執行緒B之間若要通訊的話, 必須經歷下面兩個步驟 (1)執行緒A和執行緒A本地記憶體中更新過的共享變數重新整理到主存中去。 (2)執行緒B到主存中去讀取執行緒A之前更新過的共享變數。

由此可見執行下面的語句:

int a = 100 執行緒必須現在自己的工作執行緒中對變數i所在的快取進行賦值操作,然後再寫入主存當中,而不是直接將數值100寫入主存中。

特性

  1. 可見性 當一個共享變數被volatile修飾時,它會保證修改的值立即被更新到主存,所以對其他執行緒是可見的。當其他執行緒需要讀取該值時,其他執行緒會去主存中讀取新值。相反普通的共享變數不能保證可見性,因為普通共享變數被修改後並不會立即被寫入主存,何時被寫入主存也不確定。當其他執行緒去讀取該值時,此時主存可能還是原來的舊值,這樣就無法保證可見性。

  2. 有序性 java記憶體模型中允許編譯器和處理器對指令進行重排序,雖然重排序過程不會影響到單執行緒執行的正確性,但是會影響到多執行緒併發執行的正確性。這時可以通過volatile來保證有序性,除了volatile,也可以通過synchronized和Lock來保證有序性。synchronized和Lock保證每個時刻只有一個執行緒執行同步程式碼,這相當於讓執行緒順序執行同步程式碼,從而保證了有序性。如果不考慮原子性操作的話volatile比synchronized和Lock更輕量級,成本更低。

  3. 不保障原子性 volatile關鍵字只能保證共享變數的可見性和有序性。如果volatile修飾併發執行緒中共享變數, 而該共享變數是非原子操作的話,併發中就會出現問題。比如下面程式碼:

public class HelloVolatile{
    public volatile int mNumber = 0;
    public static void main(String []args){
        final HelloVolatile hello = new HelloVolatile();
        for(int i =0; i<10; i++){
            new Thread(){
                public void run(){
                    for(int j =0; j<1000; j++){
                        hello.mNumber ++;
                    }
                }
            }.start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println("number:"+hello.mNumber);
    }
}
複製程式碼

這段程式碼預期結果是10000,可是每次執行結果都有可能不一樣。這是因為自增或自減都是非原子操作。

(1) 假如mNumber此時等於100,執行緒1進行自增操作。

(2)執行緒1先讀取了mNumber的值100,然後它被堵塞了。

(3)這時候執行緒2讀取mNumber的值100,然後進行了自增操作,並寫入到主存中, 這時候主存中的值為101。

(4)這時候執行緒1繼續執行,因為此前執行緒1已經讀取到值100,然後進行自增操作101,然後將101寫入到主存中。

可以看到兩個執行緒分別對100進行了+1操作,預期主存中的nNumber = 102,實際mNumebr = 101; 這就是因為非原子操作造成的。

使用場景

(1)併發程式設計中不依賴於程式中任意其狀態的狀態標識。可以通過關鍵字volatile代替synchronized, 提高程式執行效率,並簡化程式碼。

(2)單例模式的雙重檢查模式DCL

public class DclSingleton {
    private volatile static DclSingleton mInstance = null;
    public static DclSingleton getInstance(){
        if(mInstance==null){
            synchronized (DclSingleton.class){
                if(mInstance==null){
                    mInstance = new DclSingleton();
                }
            }
        }
        return mInstance;
    }
}
複製程式碼

原理淺析

將volatile修飾的變數轉變成彙編程式碼,如下:

... lock addl $0x0,(%rsp)

通過查IA-32架構安全手冊可知,Lock字首指令在多核處理器會引發兩件事。

1)將當前處理器快取行的資料寫回到系統記憶體。

2)這個寫回記憶體的操作會使在其他CPU裡快取了該記憶體地址的資料無效。


解讀 :

為了提高,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取後再進行操作,但操作完不知道何時再寫回記憶體。如果對宣告瞭volatile的變數進行寫操作,JVM會向處理機傳送一條Lock字首指令,將這個變數所在的快取行的資料寫回到系統記憶體。

但是寫會記憶體後,如果其他處理器快取的值還是舊的,再執行計算操作就會出現問題。所以在多處理器下,為了保證各個處理器快取是一致的,就會實現快取一致性協議,如下圖:

java併發程式設計之volatile

每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取的資料是否過期了,當處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態。當處理器對這個資料進行操作的時候,就會重新從系統記憶體中把資料讀到處理器快取中。

相關文章