天天看點

并發volatile關鍵字如何保證可見性和有序性及底層實作原理

volatile用法

首先我們先了解一下volatile關鍵字的用法 ,volatile被喻為輕量級的"synchronized",它隻是一個變量修飾符,隻能用來修飾變量不能修飾方法和代碼塊。

經典的用法:雙重校驗鎖實作單例

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  
        }  
        }  
    }  
    return singleton;  
    }  
}  
           

上面這段程式使用了volatile關鍵字來修飾可能被多個線程同時通路到的singleton

volatile與可見性:

先說一下可見性,所謂的可見性就是指可見性是指當多個線程通路同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

緩存一緻性協定

現代處理器為了提高處理速度,在處理器和記憶體之間增加了多級緩存,處理器不會直接去和記憶體通信,将資料讀到内部緩存中再進行操作。由于引入了多級緩存,就存在緩存資料不一緻問題。

什麼是緩存一緻性協定呢?

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

volatile是兩條實作原則:

1.Lock字首指令會引起處理器緩存會寫到記憶體

當對volatile變量進行寫操作的時候,JVM會向處理器發送一條lock字首的指令,将這個緩存中的變量回寫到系統主存中

2.一個處理器的緩存回寫到記憶體會導緻其他處理器的緩存失效

處理器使用嗅探技術保證内部緩存 系統記憶體和其他處理器的緩存的資料在總線上保持一緻。

綜合上面兩條實作原則,我們了解到:如果一個變量被volatile所修飾的話,在每次資料變化之後,其值都會被強制刷入主存。而其他處理器的緩存由于遵守了緩存一緻性協定,也會把這個變量的值從主存加載到自己的緩存中。這就保證了一個volatile在并發程式設計中,其值在多個緩存中是可見的。

為了保證記憶體的可見性,除了緩存一緻性協定還有一個happends-before關系

注意:

數組與對象執行個體中的 volatile,針對的是引用,對象獲數組的位址具有可見性,但是數組或對象内部的成員改變不具備可見性。

volatile寫—讀建立的happends-before關系

volatile的寫—讀與鎖的釋放—擷取有着相同的記憶體效果,是以說一個volatile變量的單個讀/寫操作,與一個普通變量的讀/寫操作都是使用同一個鎖來同步,執行效果是一樣的。先簡單介紹一下happends-before

happends-before法則

1.程式次序法則:按照代碼順序執行

2.螢幕鎖法則:一個unlock操作要先于同一個鎖的lock操作

3.volatile變量法則:對volatile域的寫入操作happends-before于每一個後續對同一域的讀操作

4.線程啟動法則:在一個線程裡,對Thread.start()的調用會先于Thread.run();

5.線程終結法則:線程中的任何動作都happends-before于其他線程檢測到這個線程已經終結,或者從Thread.join 調用中成功傳回,或者Thread.isAlive傳回false

中斷法則:一個線程調用另一個線程的interrupt.happens-before于被中斷的線程發現中斷。(通過跑出interruptedException,或者調用isInterrupted和interrupted)

6.終結法則:一個對象的構造函數的結束happends-before于這個對象finalizer的開始。

7.傳遞性:如果A happens-before于B, 且B happends-before 于C, 則A happens-before 于C

volatile變量法則:對volatile域的寫入操作happends-before于每一個後續對同一域的讀操作
           

當我們去寫一個volatile變量的時候,JMM會把該線程對應的本地記憶體中的共享變量值重新整理到主記憶體中,讀一個volatile變量的時候,JMM會把該線程對應的本地記憶體置為無效,接下來線程從主記憶體中讀取共享變量。兩個線程,線程A寫一個volatile變量,線程B随後讀這個volatile變量。這個過程實際上就是線程A和線程B通過主記憶體進行通信(線程間通信)。

volatile與有序性

我們都知道多線程通過搶占時間片來執行自己的代碼體,是以我們會感覺到線程是同時執行完的,除了引入了時間片以外,由于處理器優化和指令重排等,CPU還可能對輸入代碼進行亂序執行,比如我們拿到資料要執行寫庫,查詢,删除這三個操作,這就會可能要涉及到有序性的問題了。

volatile可以禁止指令重排,這就保證了代碼的程式會嚴格按照代碼的先後順序執行。這就保證了有序性。被volatile修飾的變量的操作,會嚴格按照代碼順序執行接下來我們就說一下為了實作volatile記憶體語義JMM是怎樣限制重排序(包括編譯器重排序和處理器重排序)的。

volatile重排序規則表(針對編譯器重排序):

并發volatile關鍵字如何保證可見性和有序性及底層實作原理

從這張表我們可以看出:

當第一個操作是Volatile讀時,不管第二個操作是什麼,都不能重排序;

當第一個操作是Volatile寫時,第二個操作是Volatile讀或寫,不能重排序;

當第一個操作是普通讀寫,第二個操作是Volatile寫時,不能重排序。

記憶體屏障(針對處理器重排序):

編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定類型的處理器重排序。(首先保證了正确性,再去追求執行效率)

1.在每個volatile寫操作前插入StoreStore屏障;

對于這樣的語句Store1; StoreLoad; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。

2.在每個volatile寫操作後插入StoreLoad屏障;

對于這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。

3.在每個volatile讀操作前插入LoadLoad屏障;

對于這樣的語句Load1;LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被通路前,保證Load1要讀取的資料被讀取完畢。

4.在每個volatile讀操作後插入LoadStore屏障;

對于這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。

如果編譯器無法确定後面是否還會有volatile讀或者寫的時候,為了安全,編譯器通常會在這裡插入一個StoreLoad屏障

并發volatile關鍵字如何保證可見性和有序性及底層實作原理

volatile與原子性

因為volatile它不是鎖隻是一個變量修飾符,是以無法保證原子性。

舉個栗子:

public class Test {
    public volatile int i = 0;

    public void increase() {
        i++;
    }

    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.i);
    }
}

           

上面這段代碼就是建立10個線程,然後分别執行1000次

i++

操作。正常情況下,程式的輸出結果應該是10000,但是,多次執行的結果都小于10000。是以說volatile無法滿足原子性。

i++

操作在編譯後位元組碼如下:

getfield      #2                  // Field i:I
iconst_1
iadd
putfield      #2                  // Field i:I
           

i++指令也包含了四個步驟,由于CPU按照時間片來進行線程排程的,隻要是包含多個步驟的操作的執行,天然就是無法保證原子性的。因為這種線程執行,不像資料庫一樣可以復原。如果一個線程要執行的步驟有5步,執行完3步就失去了CPU了,失去後就可能再也不會被排程,這怎麼可能保證原子性呢。

是以在以下兩個場景中可以使用volatile來代替synchronized:

1、運算結果并不依賴變量的目前值,或者能夠確定隻有單一的線程會修改變量的值。

2、變量不需要與其他狀态變量共同參與不變限制。

參考資料:

《深入了解java虛拟機》周志明

《java并發程式設計的藝術》方騰飛 魏鵬 程曉明