1. 程式人生 > >java的記憶體模型與volatile關鍵字詳解

java的記憶體模型與volatile關鍵字詳解

由於各種硬體及作業系統的記憶體訪問差異,java虛擬機器使用java記憶體模型(java Memory Model,JMM)來規範java對記憶體的訪問。這套模型在jdk 1.2中開始建立,經jdk 1.5的修訂,現已逐步完善起來。

什麼是java記憶體模型

什麼是java記憶體模型,為什麼會有這個模型?關於這個問題,就不得不從併發的問題講起。在多核系統中,處理器一般設定快取來加速資料的讀取,快取大大提升了程式效能,卻也帶來了“快取一致性”的新問題。比如,當多個處理器寫同一塊主記憶體時,以誰的快取資料為準?讀取、寫入記憶體的變數需遵循怎樣保證執行緒安全?針對這些問題,java設計了一套記憶體模型以用來定義程式中各個變數的訪問規則。

java的記憶體模型採用的是共享記憶體的執行緒通訊機制。執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,本地記憶體儲存了共享變數的副本。
這裡寫圖片描述

圖片來自《深入理解java虛擬機器 第2版》

關於共享變數,可以對應為儲存在堆記憶體的例項變數、類變數及陣列元素(堆記憶體是執行緒共享的)。私有變數可對應虛擬機器棧中的區域性變數。事實上,他們是java記憶體不同層次的劃分,並沒有一定聯絡。

記憶體間的互動操作

要完成主記憶體與工作記憶體的互動操作,需遵守一定的規則。java記憶體模型定義了相當嚴謹而複雜的訪問規則。主要有8種原子性的操作。分別是:lock(鎖定)、unlock(解鎖)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(儲存)、write(寫入)

記憶體互動時,必須使用以上幾種操作搭配完成,且這8種操作要滿足一定規則。如read和load,store和write必須成對出現;對變數實施use、store時,必須先執行assign和load操作。

幸好,這些難以記憶的規則有一個等效判定的原則,即先行發生原則。

  1. 程式次序規則:在一個執行緒中,程式控制流前面的操作先行發生於後面的操作。
  2. 監視器鎖規則:一個unlock操作先行發生於對同一個鎖的lock操作。
  3. volatile變數規則:對於一個volatile變數,寫操作先行發生於對這個變數的讀操作。
  4. 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,則操作A先行發生於操作C。

一個記憶體互動的例子

我們知道java的多執行緒通訊採用共享記憶體的方式。執行緒對變數的所有操作都要在工作記憶體中進行,不能直接訪問主記憶體。執行緒間變數傳遞均需主記憶體間接完成。

這裡寫圖片描述

則,執行緒A要與執行緒B通訊(比如B執行緒要讀取A執行緒經操作後的值),需要:

  1. 執行緒A修改本地記憶體A的值,並將其寫入主記憶體的共享變數。
  2. 執行緒B到主記憶體讀取執行緒A修改後的值。

記憶體模型的3個重要特徵

原子性

前面我們提到的8種原子操作都是原子性的,這樣可以保證對基本資料型別的訪問讀寫是原子性的。這裡有個例外是JVM沒有強制規定long、double一定是原子操作。但幾乎所有的商業JVM都實現了long、double的原子操作。

可見性

可見性是指,當一個執行緒修改了共享變數的值,其他變數能得知這個修改。

這裡需要引出本文第二個關鍵點:volatile。volatile有兩個語義。這裡用其可見性語義。經volatile修飾的變數保證新值能立即同步到主記憶體中,每次使用前立即從主記憶體重新整理。保證了多執行緒操作時變數的可見性。後面會有更詳細解釋。

除volatile外,synchronized和final也能實現可見性。
synchronized的可見性由“對一個變數執行unlock前,必須先把此變數同步回主記憶體”。獲得。

final關鍵字的可見性指:被final修飾的欄位在構造器中初始完成,則其他執行緒就能看到final的值。

有序性

java程式本身具有的有序性可以總結為:如果在同一執行緒觀察,所有操作都是有序的。而如果在一個執行緒觀察另一執行緒,所有操作都是無序的。前部分指在單執行緒環境中程式的順序性,後部分說的無序是指“指令的重排序”和“工作記憶體與主記憶體的同步延遲”。

指令重排序

編譯器能夠自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。是為指令的重排序。在單執行緒環境中,程式執行結果不會受到指令重排序的影響。

但有時,我們在多執行緒情況下,並不希望發生指令重排序來影響併發結果。

java提供了volatile和synchronized來保證執行緒之間操作的有序性。volatile含有禁止指令重排序的語義(即它的第二個語義),synchronized規定一個變數在同一時刻只允許一條執行緒對其lock操作,也就是說同一個鎖的兩個同步塊只能序列進入。禁止了指令的重排序。

關於指令重排序,下文還有更多解釋。

volatile語義

介紹完java記憶體模型的3個特徵,現在來詳細介紹volatile及它代表的語義。

準確來說,volatile是java提供的輕量的同步機制。它有兩個特性:
1. 保證修飾的變數對所有執行緒的可見性。
2. 禁止指令的重排序優化。

根據上面的介紹,我們對可見性及禁止重排序背後的順序性都不陌生。下面我們來詳細說明下。

驗證volatile具有可見性

volatile變數對所有執行緒是立即可見的,對volatile變數的寫操作都能立即反應到其他執行緒中。

volatile boolean flag;

public void shundown(){
   flag = true;
}

public void doWork(){
   while(!flag){
     doSomething();
   }   
}

上面的例子即是volatile的典型應用。任一執行緒呼叫了shundown()方法,都能保證所有執行緒執行doWork()時doSomething()方法不執行。

假設flag 不是由volatile修飾,則不能保證記憶體可見性,當某個執行緒修改了flag的值後,其他執行緒不一定會馬上看到或根本看不到,就會引起錯誤。

需注意的是,volatile變數保證可見性時,需滿足以下規則:

  1. 運算結果不依賴變數的當前值,或保證只有單一執行緒修改變數值。(如i++,運算依賴當前值,就不滿足)
  2. 變數不需要與其他狀態變數共同參與不變約束。
public class TestThread2 {
    public static volatile int race = 0;

    public static void increase(){
        race++;
    }
    private static final int THREADS_COUNT =20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];

        for(int i=0;i<THREADS_COUNT;i++){
            threads[i] = new Thread(()->{
                for(int j=0;j<1000;j++){
                    increase();
                }
            });
            threads[i].start();
        }
        System.out.println(race);
    }
}

如上例,若正確併發,則最後應輸出20*1000=20000,可結果總輸出小於20000的結果,且每次都不相同。原因就在於volatile不能保證 race++的可見性。race++ 操作實際上有1.讀取race的值;2.對race加1;3.修改race的值3步操作,而volatile顯然不能保證這些操作的原子性。

volatile禁止指令重排序

指令重排序的語句需遵守一個規則,即as-if-serial語義:

所有操作都可以為了優化而重排序,但必須保證重排序的結果和程式執行結果一致。

這裡給出重排序的例子

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

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while(true) {
            x = 0; y = 0;
            a = 0; b = 0;
            i++;
            Thread first = new Thread(()->{a = 1;x = b;});
            Thread second = new Thread(()->{b = 1;y = a;});
            first.start();second.start();
            first.join();second.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

一個執行緒執行a = 1;x = b;,另一個執行緒執行b = 1;y = a;,由於a、x,b、y不存在依賴關係,所以有可能發生先執行x=b,然後a=1的指令重排序,經試驗,在多次迴圈後出現x=b;b=1;y=a;a=1;的執行緒交替執行結果。即x=0;y=0

這裡寫圖片描述

這說明發生了指令重排序,將a,b,x,y用volatile修飾後,執行多次也沒有出現重排序情況。
這裡寫圖片描述

一個單例模式的例子

單例模式中的“雙重檢查加鎖”模式如下所示

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}

上面程式碼大家都不陌生,可為什麼instance一定要volatile修飾呢?這是由於instance = new SingletonTest();並不是一個原子操作。可分解為:

  1. memory =allocate(); //分配物件的記憶體空間
  2. ctorInstance(memory); //初始化物件
  3. instance =memory; //設定instance指向剛分配的記憶體地址

2操作依賴1操作,但3操作並不依賴2操作,也就是說,上述操作的順序可能為1-2-3,也可能為1-3-2,若是後者,當instance不為空時也可能沒有正確初始化物件,而導致錯誤。

參考