volatile關鍵字淺析

大棋發表於2019-04-20

一、記憶體模型

      程式執行過程中的臨時資料存放在主記憶體(實體記憶體)當中的。而從記憶體中讀寫的速度跟CPU執行指令的速度比起來要慢的多,如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令的執行速度。因此CPU裡面存在快取記憶體。
      在程式執行的過程中,CPU會將運算需要的資料從主存複製一份到其快取記憶體中。CPU在進行計算時,直接從其快取記憶體讀寫資料。當運算結束之後,再將快取記憶體的資料重新整理到主存當中。
      當一個變數在多個CPU都存在快取時,就有可能存在快取不一致的問題。解決辦法:

1)、通過在匯流排加LOCK#鎖

      因為CPU和其他部件進行通訊時都是通過匯流排進行的,如果對匯流排加LOCK#鎖,阻塞了其他CPU對其他部件的訪問(如主存)。從而使只有一個CPU能使用這個變數的記憶體。
      但在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。

2)、快取一致性協議

      Intel的MESI協議:當CPU寫資料時,如果發現操作的資料時共享變數,即在其他CPU中也存在該變數的副本。則會傳送訊號通知其他CPU將該變數的快取置為無效狀態。因此其他CPU需要讀取該變數時,發現自身快取中該變數的快取是無效的,則會從主存中重新獲取。

二、併發中的三個概念

      1、原子性:即一個或多個操作,要麼全部執行完畢,要麼都不執行。
      2、可見性:當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看到修改的值。
      3、有序性:程式執行的順序按照程式碼的先後順序執行。(處理器會考慮指令間的資料依賴關係來進行重排序)
       如以下例子,程式碼1和程式碼2進行重排序對結果並無影響,但程式碼3必須在程式碼1,2之後。即可能出現的排序順序是 1,2,3 或者 2,1,3

int a = 10;
int b = 17;
a = a + b;
複製程式碼

三、Java記憶體模型

      Java虛擬機器中定義一種Java記憶體模型以遮蔽各個硬體平臺和作業系統的記憶體訪問差異。
      Java記憶體模型沒有限制執行引擎使用處理器的暫存器或快取記憶體來提升執行指令,也沒有限制編譯器對指令進行重排序。即Java記憶體模型也會出現快取一致性問題和指令重排序的問題。
      Java記憶體模型規定所有的變數都是存在主存中(類似實體記憶體),每個執行緒都有自己的工作記憶體(類似CPU的快取記憶體)。執行緒對變數的所有操作必須在工作記憶體中進行,而不能直接對主存進行操作。並且每個執行緒不能訪問其他執行緒的工作記憶體。

1、原子性

x = 10 //語句1
y = x  //語句2
x++    //語句3
x = x + 1  //語句4
複製程式碼

      只有語句1是原子性操作,其他三個語句都不是原子性操作。
      語句1直接將10賦值給x,也就是說執行緒執行這個語句時會直接將數值10寫入到工作記憶體中。而其他語句實際上包含2個操作,先去讀取x的值,再將x的值寫入工作記憶體。

      總結:Java記憶體模型只保證簡單的讀取和賦值(變數之間相互賦值不是原子操作)才是原子操作。想要保證大範圍操作的原子性,需要通過synchronized和Lock實現,確保任意時刻只有一個執行緒執行該程式碼塊。

2、可見性

      Java提供volatile關鍵字保證可見性。
      當一個共享變數被volatile修飾時,它會保證修改的值會立即被更新到主存中,當有其他執行緒需要讀取時,它會去主存讀取新值。
      而普通的共享變數不能保證可見性,因為普通共享變數被修改後,什麼時候寫入主存是不確定的。當其他執行緒去讀取,此時記憶體可能還是原來的舊值。因此無法保證可見性。
      通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個執行緒獲取鎖,然後執行同步程式碼,並且釋放鎖之前會將變數的修改重新整理到主存當中,因此可以確保可見性。

3、有序性

      在Java記憶體模型中,允許編譯器對指令進行重排序,但是重排序不會影響到單執行緒的執行,卻影響到多執行緒併發執行的正確性。

比如new物件時,會進行三件事件:
      (1)、給例項分配記憶體;
      (2)、呼叫構造方法,初始化成員變數。
      (3)、將物件指向分配的記憶體空間。
而在JVM中(2)和(3)的順序是無法被保證的,只能通過volalite保證其有序性。

四、深入剖析volatile關鍵字

1)保證不同執行緒對變數進行操作時的可見性(即一個執行緒修改某個變數的值,這新值對其他執行緒來說是立即可見的)

2)禁止進行指令重排序。

volatile的原理和實現機制:
      “觀察加入volalite關鍵字和沒有加入volalite關鍵字鎖生成的彙編程式碼發現,加入volatile關鍵字時,會多出一個lock字首指令”
      lock前準指令實際上相當於一個記憶體屏障,記憶體屏障會提供3個功能:
      1、確保指令重排序時,不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面。
      2、強制對快取的修改操作立即寫入記憶體。
      3、如果是寫操作,會導致其他CPU中對應的快取無效。

五、使用volatile關鍵字的場景

      synchronized關鍵字是防止多個執行緒同時執行一段程式碼,但會影響執行效率。而volatile關鍵字在某些情況下效能優於synchronized,但不能替代synchronized,因為volatile不能提供原子性。
      1)對變數的寫操作不依賴於當前值
      2)該變數沒有包含在具有其他變數的不變式中

個人總結:

      配合計算機的記憶體模型,可以很好的理解Java的記憶體模型。以及瞭解到,可見性在高併發時的重要性。
      在確保某個變數(比如某個flag值)在單執行緒進行修改操作時,可以使用volatile確保該變數的可見性。相比synchronized效率有所提升。
      在單例DCL中,synchronized時確保了變數的原子性、可見性。但並沒有確保有序性,這時就需要將變數修飾成volatile來確保有序性

public class Singleton{
    private volatile static Singleton mInstance;
    private Singleton() {}
    
    public static Singleton getInstance(){
        if( mInstance == null){// 語句A
            synchronized(Singleton.class){ 
                if( mInstance == null){ // 語句B
                    mInstance = new Singleton();
                }
            }
        }
        return mInstance;
    }
}
複製程式碼

因為當指令進行重排序後會出現以下情況:
(1)、給Singleton的例項分配記憶體;
(3)、將mInstance物件指向分配的記憶體空間。
(2)、呼叫Singleton()的構造方法,初始化成員變數。
      當多執行緒併發時,執行緒A先執行到語句A中,mInstance是null,執行緒A進行單例物件的初始化。但因為指令重排序,出現了(1)(3)(2)的情況,當執行緒A還沒執行完(2),也就是還沒初始化完單例物件時。執行緒B執行到語句A。此時單例物件已不為null,自然語句A為false,執行緒B會返回一個還沒初始化完畢的mInstance物件。

參考文章:

1、www.cnblogs.com/dolphin0520…
2、《Android原始碼設計模式》——《單例設計模式》

相關文章