volatile關鍵字的作用、原理

monkeysayhi發表於2017-10-09

在只有雙重檢查鎖,沒有volatile的懶載入單例模式中,由於指令重排序的問題,我確實不會拿到兩個不同的單例了,但我會拿到“半個”單例

而發揮神奇作用的volatile,可以當之無愧的被稱為Java併發程式設計中“出現頻率最高的關鍵字”,常用於保持記憶體可見性和防止指令重排序。

保持記憶體可見性

記憶體可見性(Memory Visibility):所有執行緒都能看到共享記憶體的最新狀態。

失效資料

以下是一個簡單的可變整數類:

public class MutableInteger {
    private int value;
    public int get(){
        return value;
    }
    public void set(int value){
        this.value = value;
    }
}複製程式碼

MutableInteger不是執行緒安全的,因為getset方法都是在沒有同步的情況下進行的。如果執行緒1呼叫了set方法,那麼正在呼叫的get的執行緒2可能會看到更新後的value值,也可能看不到

解決方法很簡單,將value宣告為volatile變數:

private volatile int value;複製程式碼

神奇的volatile關鍵字

神奇的volatile關鍵字解決了神奇的失效資料問題。

Java變數的讀寫

Java通過幾種原子操作完成工作記憶體主記憶體的互動:

  1. lock:作用於主記憶體,把變數標識為執行緒獨佔狀態。
  2. unlock:作用於主記憶體,解除獨佔狀態。
  3. read:作用主記憶體,把一個變數的值從主記憶體傳輸到執行緒的工作記憶體。
  4. load:作用於工作記憶體,把read操作傳過來的變數值放入工作記憶體的變數副本中。
  5. use:作用工作記憶體,把工作記憶體當中的一個變數值傳給執行引擎。
  6. assign:作用工作記憶體,把一個從執行引擎接收到的值賦值給工作記憶體的變數。
  7. store:作用於工作記憶體的變數,把工作記憶體的一個變數的值傳送到主記憶體中。
  8. write:作用於主記憶體的變數,把store操作傳來的變數的值放入主記憶體的變數中。

volatile如何保持記憶體可見性

volatile的特殊規則就是:

  • read、load、use動作必須連續出現
  • assign、store、write動作必須連續出現

所以,使用volatile變數能夠保證:

  • 每次讀取前必須先從主記憶體重新整理最新的值。
  • 每次寫入後必須立即同步回主記憶體當中。

也就是說,volatile關鍵字修飾的變數看到的隨時是自己的最新值。執行緒1中對變數v的最新修改,對執行緒2是可見的。

防止指令重排

在基於偏序關係Happens-Before記憶體模型中,指令重排技術大大提高了程式執行效率,但同時也引入了一些問題。

一個指令重排的問題——被部分初始化的物件

懶載入單例模式和競態條件

一個懶載入單例模式實現如下:

class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        if ( instance == null ) { //這裡存在競態條件
            instance = new Singleton();
        }
        return instance;
    }
}複製程式碼

競態條件會導致instance引用被多次賦值,使使用者得到兩個不同的單例。

DCL和被部分初始化的物件

為了解決這個問題,可以使用synchronized關鍵字將getInstance方法改為同步方法;但這樣序列化的單例是不能忍的。所以我猿族前輩設計了DCL(Double Check Lock,雙重檢查鎖)機制,使得大部分請求都不會進入阻塞程式碼塊:

class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        if ( instance == null ) { //當instance不為null時,仍可能指向一個“被部分初始化的物件”
            synchronized (Singleton.class) {
                if ( instance == null ) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}複製程式碼

“看起來”非常完美:既減少了阻塞,又避免了競態條件。不錯,但實際上仍然存在一個問題——當instance不為null時,仍可能指向一個"被部分初始化的物件"

問題出在這行簡單的賦值語句:

instance = new Singleton();複製程式碼

它並不是一個原子操作。事實上,它可以”抽象“為下面幾條JVM指令:

memory = allocate();    //1:分配物件的記憶體空間
initInstance(memory);    //2:初始化物件
instance = memory;        //3:設定instance指向剛分配的記憶體地址複製程式碼

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM可以以“優化”為目的對它們進行重排序,經過重排序後如下:

memory = allocate();    //1:分配物件的記憶體空間
instance = memory;        //3:設定instance指向剛分配的記憶體地址(此時物件還未初始化)
ctorInstance(memory);    //2:初始化物件複製程式碼

可以看到指令重排之後,操作 3 排在了操作 2 之前,即引用instance指向記憶體memory時,這段嶄新的記憶體還沒有初始化——即,引用instance指向了一個"被部分初始化的物件"。此時,如果另一個執行緒呼叫getInstance方法,由於instance已經指向了一塊記憶體空間,從而if條件判為false,方法返回instance引用,使用者得到了沒有完成初始化的“半個”單例。
解決這個該問題,只需要將instance宣告為volatile變數:

private static volatile Singleton instance;複製程式碼

也就是說,在只有DCL沒有volatile的懶載入單例模式中,仍然存在著併發陷阱。我確實不會拿到兩個不同的單例了,但我會拿到“半個”單例(未完成初始化)。
然而,許多面試書籍中,涉及懶載入的單例模式最多深入到DCL,卻隻字不提volatile。這“看似聰明”的機制,曾經被我廣大初入Java世界的猿胞大加吹捧——我在大四實習面試跟誰學的時候,也得意洋洋的從飽漢、餓漢講到Double Check,現在看來真是傻逼。對於考查併發的面試官而言,單例模式的實現就是一個很好的切入點,看似考查設計模式,其實期望你從設計模式答到併發和記憶體模型。

volatile如何防止指令重排

volatile關鍵字通過“記憶體屏障”來防止指令被重排序。

為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。然而,對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。

下面是基於保守策略的JMM記憶體屏障插入策略:

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

進階

在一次回答上述問題時,忘記了解釋一個很容易引起疑惑的問題:

如果存在這種重排序問題,那麼synchronized程式碼塊內部不是也可能出現相同的問題嗎?

即這種情況:

class Singleton {
    ...
        if ( instance == null ) { //可能發生不期望的指令重排
            synchronized (Singleton.class) {
                if ( instance == null ) {
                    instance = new Singleton();
                    System.out.println(instance.toString()); //程式順序規則發揮效力的地方
                }
            }
        }
    ...
}複製程式碼

難道呼叫instance.toString()方法時,instance也可能未完成初始化嗎?

首先還請放寬心,synchronized程式碼塊內部雖然會重排序,但不會在程式碼塊的範圍內導致執行緒安全問題

Happens-Before記憶體模型和程式順序規則

程式順序規則:如果程式中操作A在操作B之前,那麼執行緒中操作A將在操作B之前執行。

前面說過,只有在Happens-Before記憶體模型中才會出現這樣的指令重排序問題。Happens-Before記憶體模型維護了幾種Happens-Before規則,程式順序規則最基本的規則。程式順序規則的目標物件是一段程式程式碼中的兩個操作A、B,其保證此處的指令重排不會破壞操作A、B在程式碼中的先後順序,但與不同程式碼甚至不同執行緒中的順序無關

因此,在synchronized程式碼塊內部,instance = new Singleton()仍然會指令重排序,但重排序之後的所有指令,仍然能夠保證在instance.toString()之前執行。進一步的,單執行緒中,if ( instance == null )能保證在synchronized程式碼塊之前執行;但多執行緒中,執行緒1中的if ( instance == null )卻與執行緒2中的synchronized程式碼塊之間沒有偏序關係,因此執行緒2中synchronized程式碼塊內部的指令重排對於執行緒1是不期望的,導致了此處的併發陷阱。

類似的Happens-Before規則還有volatile變數規則監視器鎖規則等。程式猿可以藉助(Piggyback)現有的Happens-Before規則來保持記憶體可見性和防止指令重排。

注意點

上面簡單講解了volatile關鍵字的作用和原理,但對volatile的使用過程中很容易出現的一個問題是:

錯把volatile變數當做原子變數。

出現這種誤解的原因,主要是volatile關鍵字使變數的讀、寫具有了“原子性”。然而這種原子性僅限於變數(包括引用)的讀和寫,無法涵蓋變數上的任何操作,即:

  • 基本型別的自增(如count++)等操作不是原子的。
  • 物件的任何非原子成員呼叫(包括成員變數成員方法)不是原子的。

如果希望上述操作也具有原子性,那麼只能採取鎖、原子變數更多的措施。

總結

綜上,其實volatile保持記憶體可見性和防止指令重排序的原理,本質上是同一個問題,也都依靠記憶體屏障得到解決。更多內容請參見JVM相關書籍。


參考:


本文連結:volatile關鍵字的作用、原理
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章