volatile 學習筆記

全面理解Java內存模型(JMM)及volatile關鍵字
正確使用 Volatile 變量html

Java內存模型

在併發編程中,須要處理兩個關鍵問題:線程之間如何通訊及線程之間如何同步。通訊是指線程之間以何種機制來交換信息。同步是指程序中用於控制不一樣線程間操做發生相對順序的機制。java

線程間的通訊機制有兩種:共享內存和消息傳遞。在共享內存的併發模型中,線程之間共享程序的公共狀態,經過寫-讀內存中的公共狀態進行隱式通訊。在消息傳遞的併發模型中,線程之間沒有公共狀態,線程之間必須經過發消息來顯示進行通訊。c++

在共享內存併發模型中,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。程序員

Java的併發採用的是共享內存模型。編程

1. 主內存與工做內存

Java內存模型規定了全部的變量都存儲在主內存(Main Memory)中。每條線程還有私有的工做內存(Working Memory),線程的工做內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的全部操做(讀取、賦值等)都必須在工做內存中進行,而不能直接讀寫主內存中的變量,不一樣的線程之間也沒法直接訪問對方工做內存中的變量,線程間變量值的傳遞均須要經過主內存來完成,線程、主內存、工做內存三者的交互關係以下圖:緩存

image.png-81kB

內存間交互操做:性能優化

  1. Lock(鎖定):做用於主內存中的變量,把一個變量標識爲一條線程獨佔的狀態。
  2. Read(讀取):做用於主內存中的變量,把一個變量的值從主內存傳輸到線程的工做內存中。
  3. Load(加載):做用於工做內存中的變量,把read操做從主內存中獲得的變量的值放入工做內存的變量副本中。
  4. Use(使用):做用於工做內存中的變量,把工做內存中一個變量的值傳遞給執行引擎。
  5. Assign(賦值):做用於工做內存中的變量,把一個從執行引擎接收到的值賦值給工做內存中的變量。
  6. Store(存儲):做用於工做內存中的變量,把工做內存中的一個變量的值傳送到主內存中。
  7. Write(寫入):做用於主內存中的變量,把store操做從工做內存中獲得的變量的值放入主內存的變量中。
  8. Unlock(解鎖):做用於主內存中的變量,把一個處於鎖定狀態的變量釋放出來,以後可被其它線程鎖定。

Java內存模型規定了在執行上述八中基本操做式必須知足以下規則:多線程

  1. 不容許read和load、store和write操做之一單獨出現。
  2. 不容許一個線程丟棄最近的assign操做,變量在工做內存中改變了以後必須把該變化同步回主內存中。
  3. 不容許一個線程沒有發生過任何assign操做把數據從線程的工做內存同步回主內存中。
  4. 一個新的變量只能在主內存中誕生。
  5. 一個變量在同一時刻只容許一條線程對其進行lock操做,但能夠被同一條線程重複執行屢次。
  6. 若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量前,須要從新執行read、load操做。
  7. 若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做。
  8. 對一個變量執行unlock操做前,必須先把該變量同步回主內存中。

2. 指令重排序

在執行程序時,JVM虛擬機只保證單線程執行狀況下,程序的執行結果不會由於指令重排序而改變,可是多線程狀況下則不保證。由於爲了提升性能,編譯器和處理器都經常會對指令作重排序。重排序分爲3種類型:架構

  1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行結束來將多條指令疊加執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。
  3. 內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。

其中編譯器優化的重排屬於編譯期重排,指令並行的重排和內存系統的重排屬於處理器重排,在多線程環境中,這些重排優化可能會致使程序出現內存可見性問題,下面分別闡明這兩種重排優化可能帶來的問題。併發

編譯器重排序:

下面咱們簡單看一個編譯器重排序的例子:

線程 1             線程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

兩個線程同時執行,分別有一、二、三、4四段執行代碼,其中一、2屬於線程1 , 三、4屬於線程2 ,從程序的執行順序上看,彷佛不太可能出現x1 = 1 和x2 = 2 的狀況,但實際上這種狀況是有可能發現的,由於若是編譯器對這段程序代碼執行重排優化後,可能出現下列狀況:

線程 1              線程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

這種執行順序下就有可能出現x1 = 1 和x2 = 2 的狀況,這也就說明在多線程環境下,因爲編譯器優化重排的存在,兩個線程中使用的變量可否保證一致性是沒法肯定的。

指令重排序:

先了解一下指令重排的概念,處理器指令重排是對CPU的性能優化,從指令的執行角度來講一條指令能夠分爲多個步驟完成,以下:

  • 取指 (IF)
  • 譯碼和取寄存器操做數(ID)
  • 執行或者有效地址計算 (EX)
  • 存儲器訪問 (MEM)
  • 寫回 (WB)

CPU在工做時,須要將上述指令分爲多個步驟依次執行(注意硬件不一樣有可能不同),因爲每個步會使用到不一樣的硬件操做,好比取指時會只有PC寄存器和存儲器,譯碼時會執行到指令寄存器組,執行時會執行ALU(算術邏輯單元)、寫回時使用到寄存器組。爲了提升硬件利用率,CPU指令是按流水線技術來執行的,以下:

image.png-30.8kB

從圖中能夠看出當指令1還未執行完成時,第2條指令便利用空閒的硬件開始執行,這樣作是有好處的,若是每一個步驟花費1ms,那麼若是第2條指令須要等待第1條指令執行完成後再執行的話,則須要等待5ms,但若是使用流水線技術的話,指令2只需等待1ms就能夠開始執行了,這樣就能大大提高CPU的執行性能。雖然流水線技術能夠大大提高CPU的性能,但不幸的是一旦出現流水中斷,全部硬件設備將會進入一輪停頓期,當再次彌補中斷點可能須要幾個週期,這樣性能損失也會很大,就比如工廠組裝手機的流水線,一旦某個零件組裝中斷,那麼該零件日後的工人都有可能進入一輪或者幾輪等待組裝零件的過程。所以咱們須要儘可能阻止指令中斷的狀況,指令重排就是其中一種優化中斷的手段,咱們經過一個例子來闡明指令重排是如何阻止流水線技術中斷的:

a = b + c ;
d = e + f ;

下面經過彙編指令展現了上述代碼在CPU執行的處理過程:

image.png-84kB

  • LW指令 表示 load,其中LW R1,b :表示把b的值加載到寄存器R1中
  • LW R2,c :表示把c的值加載到寄存器R2中
  • ADD 指令:表示加法,把R1 、R2的值相加,並存入R3寄存器中。
  • SW 指令:表示 store 即將 R3寄存器的值保持到變量a中
  • LW R4,e :表示把e的值加載到寄存器R4中
  • LW R5,f :表示把f的值加載到寄存器R5中
  • SUB 指令:表示減法,把R4 、R5的值相減,並存入R6寄存器中。
  • SW d,R6 :表示將R6寄存器的值保持到變量d中

上述即是彙編指令的執行過程,在某些指令上存在X的標誌,X表明中斷的含義,也就是隻要有X的地方就會致使指令流水線技術停頓,同時也會影響後續指令的執行,可能須要通過1個或幾個指令週期纔可能恢復正常,那爲何停頓呢?這是由於部分數據還沒準備好,如執行ADD指令時,須要使用到前面指令的數據R1,R2,而此時R2的MEM操做沒有完成,即未拷貝到存儲器中,這樣加法計算就沒法進行,必須等到MEM操做完成後才能執行,也就所以而停頓了,其餘指令也是相似的狀況。前面闡述過,停頓會形成CPU性能降低,所以咱們應該想辦法消除這些停頓,這時就須要使用到指令重排了,以下圖,既然ADD指令須要等待,那咱們就利用等待的時間作些別的事情,如把LW R4,eLW R5,f 移動到前面執行,畢竟LW R4,eLW R5,f執行並無數據依賴關係,對他們有數據依賴關係的SUB R6,R5,R4指令在R4,R5加載完成後才執行的,沒有影響,過程以下:

image.png-213.2kB

正如上圖所示,全部的停頓都完美消除了,指令流水線也無需中斷了,這樣CPU的性能也能帶來很好的提高,這就是處理器指令重排的做用。關於編譯器重排以及指令重排(這兩種重排咱們後面統一稱爲指令重排)相關內容已闡述清晰了,咱們必須意識到對於單線程而已指令重排幾乎不會帶來任何影響,比竟重排的前提是保證串行語義執行的一致性,但對於多線程環境而已,指令重排就可能致使嚴重的程序輪序執行問題,以下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代碼,同時存在線程A和線程B對該實例對象進行操做,其中A線程調用寫入方法,而B線程調用讀取方法,因爲指令重排等緣由,可能致使程序執行順序變爲以下:

線程A                    線程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //誤讀
                          3: i = 1 ;

因爲指令重排的緣由,線程A的flag置爲true被提早執行了,而a賦值爲1的程序還未執行完,此時線程B,剛好讀取flag的值爲true,直接獲取a的值(此時B線程並不知道a爲0)並執行i賦值操做,結果i的值爲1,而不是預期的2,這就是多線程環境下,指令重排致使的程序亂序執行的結果。所以,請記住,指令重排只會保證單線程中串行語義的執行的一致性,但並不會關心多線程間的語義一致性。

as-if-serial語義:

as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就可能被編譯器和處理器重排序。

數據依賴關係常見的場景:後一個操做依賴於前一個操做的執行結果,此時這兩個操做之間就存在數據依賴關係,編譯器、處理器等都必須as-if-serial語義,不能對這兩個操做進行重排序。

3. happens-before

在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必需要存在happens-before關係。這裏提到的兩個操做既能夠是在一個線程以內,也能夠是在不一樣線程之間。這裏提到的「可見」包括修改了內存中共享變量的值、發送了消息、調用了方法等。

下面是Java內存模型下一些「自然」的happens-before關係,這些happens-before關係無須任何同步器協助就已存在,能夠在編碼中直接使用。若是兩個操做之間的關係不在此列,而且沒法從下列規則推導出來的話,他們就沒有順序性保障,虛擬機能夠對它們進行隨意地重排序。

  • 程序次序規則:在同一個線程內,按照程序代碼順序,書寫在前面的操做happens-before於書寫在後面的操做。準確地說應該是控制流順序而不是程序代碼順序,由於要考慮分支、循環等結構。
  • 管程鎖定規則:一個unlock操做happens-before於後面對同一個鎖的lock操做。這裏必須強調的是同一個鎖,而「後面」是指時間上的前後順序。
  • volatile變量規則:對一個volatile變量的寫操做happens-before於後面的讀操做,這裏「後面」是指時間上的前後順序。
  • 線程啓動規則:Thread對象的start()方法happens-before與此線程的每個動做。
  • 線程終止規則:線程中的全部操做都happens-before與對此線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則:對線程interrupt()方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生,能夠經過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)happens-before與它的finalize()方法的開始。
  • 傳遞性:若是操做A happens-before與操做B,操做B happens-before 與操做C,那就能夠得出操做A happens-before 與操做C的結論。

volatile

可見性:

當一個變量被定義爲volatile以後,它將具有兩種特性,第一是保證此變量對全部線程的可見性,這裏的「可見性」是指當一條線程修改了這個變量的值,新值對於其餘線程來講是能夠當即感知的。換句話說,被volatile修飾的變量,全部線程看到的變量值都是一致的。

根據happens-before規則,對一個volatile變量的寫操做happens-before於後面的讀操做,這裏「後面」是指時間上的前後順序。

理解volatile特性的一個好方法是把對volatile變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。下面經過具體的示例來講明,示例代碼以下:

class VolatileFeaturesExample {

    volatile long vl = 0L; // 使用volatile聲明64位的long型變量
    
    public void set(long l) {
        vl = l; // 單個volatile變量的寫
    }
    
    public void getAndIncrement () {
        vl++; // 複合(多個)volatile變量的讀/寫
    }
    
    public long get() {
        return vl; // 單個volatile變量的讀
    }
}

假設有多個線程分別調用上面程序的3個方法,這個程序在語義上和下面程序等價:

class VolatileFeaturesExample {
    long vl = 0L; // 64位的long型普通變量
    public synchronized void set(long l) { // 對單個的普通變量的寫用同一個鎖同步
        vl = l;
    }
    public void getAndIncrement () { // 普通方法調用
        long temp = get(); // 調用已同步的讀方法
        temp += 1L; // 普通寫操做
        set(temp); // 調用已同步的寫方法
    }
    public synchronized long get() { // 對單個的普通變量的讀用同一個鎖同步
        return vl;
    }
}

如上面示例程序所示,一個volatile變量的單個讀/寫操做,與一個普通變量的讀/寫操做都是使用同一個鎖來同步,它們之間的執行效果相同,它們都能保證任意線程對這個volatile變量讀到值都是最後的寫入。

可見性的實現原理:

volatile是如何來保證可見性的呢?讓咱們在X86處理器下經過工具獲取JIT編譯器生成的彙編指令來查看對volatile進行寫操做時,CPU會作什麼事情。Java代碼以下:

volatile int instance = new Singleton();

轉換爲彙編代碼,以下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

有volatile變量修飾的共享變量進行寫操做的時候會多出第二行彙編代碼,經過查IA-32架構軟件開發者手冊可知,Lock前綴的指令在多核處理器下會引起了兩件事情:

  • 將當前處理器緩存行的數據寫回到系統內存。
  • 這個寫回內存的操做會使在其餘CPU裏緩存了該內存地址的數據無效。

禁止指令重排序優化

前文提到太重排序分爲編譯器重排序和處理器重排序。JMM針對於編譯器重排序指定了以下規則:

image.png-47.3kB

  • 當第二個操做是volatile寫時,無論第一個操做是什麼,都不能重排序。這個規則確保volatile寫以前的操做不會被編譯器重排序到volatile寫以後。
  • 當第一個操做是volatile讀時,無論第二個操做是什麼,都不能重排序。這個規則確保volatile讀以後的操做不會被編譯器重排序到volatile讀以前。
  • 當第一個操做是volatile寫,第二個操做是volatile讀時,不能重排序。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。

  • 在每一個volatile寫操做的前面插入一個StoreStore屏障。
  • 在每一個volatile寫操做的後面插入一個StoreLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadStore屏障。

屏障指令解釋:

指令 序列 解釋
LoadLoad屏障 Load1,Loadload,Load2 確保Load1所要讀入的數據可以在被Load2和後續的load指令訪問前讀入
StoreStore屏障 Store1,StoreStore,Store2 確保Store1的數據在Store2以及後續Store指令操做相關數據以前對其它處理器可見(例如向主存刷新數據)
LoadStore屏障 Load1,LoadStore,Store2 確保Load1的數據在Store2和後續Store指令被刷新以前讀取

volatile寫插入內存屏障後生成的指令序列示意圖:

image.png-67.7kB

上面的StoreStore屏障能夠保證在volatile寫以前,其前面的全部普通寫操做已經對任何處理器可見了。這是由於StoreStore屏障將保障上面全部的普通寫在volatile寫之間刷新到主內存。

此屏障的做用是避免volatile寫與後面可能有的volatile讀/寫操做重排序。由於編譯器經常沒法準確判斷在一個volatile寫的後面是否須要插入一個StoreLoad屏障(好比,一個volatile寫以後方法當即return)。爲了保證能正確實現volatile的內存語義,JMM在採起了保守策略:在每一個volatile寫的後面,或者在每一個volatile讀的前面插入一個StoreLoad屏障。從總體執行效率的角度考慮,JMM最終選擇了在每一個volatile寫的後面插入一個StoreLoad屏障。由於volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫以後插入StoreLoad屏障將帶來可觀的執行效率的提高。從這裏能夠看到JMM在實現上的一個特色:首先確保正確性,而後再去追求執行效率。

volatile讀插入內存屏障後生成的指令序列示意圖:

image.png-72.5kB

Volatile 爲何不能保證原子性?

volatile只能保證 可見性和禁止指令重排序,可是這並不代表併發對volatile變量的修改可以保證原子性和正確性。 下面,咱們看個例子:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

你們想一下這段程序的輸出結果是多少?也許有些朋友認爲是10000。可是事實上運行它會發現每次運行結果都不一致,都是一個小於10000的數字。

可能有的朋友就會有疑問,不對啊,上面是對變量inc進行自增操做,因爲volatile保證了可見性,那麼在每一個線程中對inc自增完以後,在其餘線程中都能看到修改後的值啊,因此有10個線程分別進行了1000次操做,那麼最終inc的值應該是1000*10=10000。

這裏面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,可是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,可是volatile沒辦法保證對變量的操做的原子性。

相關文章
相關標籤/搜索