Java併發編程(八):volatile使用和原理詳解

一、背景

大家都知道volatile作爲一個“輕量級”的關鍵字,它能夠保證可見性、有序性,但是不能保證原子性。那麼它到底是怎麼保證可見性和有序性的呢?爲什麼不能保證原子性呢?我們該如何正確使用volatile呢?下面我們一一進行解釋。

二、volatile之可見性

對於可見性,我們在前面的博文已經介紹過了,這裏直接出一個實際的例子:

public class Test {
    public static boolean stoped = false;

    public static void say() {
        while (!stoped) {
        }
        System.out.println("stoped");
    }

    public static void stop() {
        System.out.println("stop");
        stoped = true;
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                say();
            }
        });
        t1.start();
        //睡眠1s
        Thread.sleep(1000L);
        //設置closed爲true
        stop();
        //等待t1線程結束
        t1.join();
    }
}

在上述代碼中,Test類有一個普通的靜態布爾類型的stoped屬性,默認爲false;say方法主要執行一個while循環,循環的跳出條件是stoped==true;而stop方法會將stoped設置爲true。

然後在main方法中,我們先啓動一個線程t1去執行say方法,接着在睡眠1s之後再在主線程中調用close方法。原意是在stop()方法的調用之後,closed==true,這樣t1線程執行的say方法也就會跳出while循環了,然後整個程序就執行完畢了。

但是運行該代碼,我們發現在stop()方法調用之後,程序並沒有結束運行,而是處於一種死循環的狀態之中。這就是一個典型的由於可見性引發的一個線程安全問題。

分析:

基於我們前面說的主內存和工作內存的理論,closed變量是一個共享變量。線程t1先執行say()方法,會從主內存拷貝一份closed變量到自己的工作內存,然後使用本地副本進行while操作,此時closed==false,所以會一直循環;然後睡眠1秒之後,在主線程中調用close()方法,主線程也會從主內存拷貝一份closed變量到自己的工作內存作爲本地副本,然後將副本值設置爲true。但是,主線程工作內存中的closed變量的最新值(true)並沒有及時同步回主內存,而即使我們將其同步回了主內存,也不能保證t1線程會馬上從主內存獲取最新的closed值。簡單的說就是主線程對closed變量更新操作的結果,並沒有及時反映到t1線程中。所以就出現了我們上面的死循環情況。

而我們前面的博文也講了,Java內存模型對volatile定義了一些特殊的訪問規則,它能保證修改後的最新值能立即同步到主存,並且每次使用都會從主存獲取。這正好解決了我們上述主線程操作的結果沒有及時反映到t1線程的問題。那麼它是怎麼實現這些規則的呢?我們來看一下有volatile修飾和沒有volatile修飾的代碼,反映到彙編是一個什麼樣的情況(關於如何查看JIT的彙編代碼請參考這篇博文)。由於彙編代碼太多,我這裏只截取我們關注的點:設置closed變量。

無volatile修飾:

有volatile修飾:

我們不用關心賦值指令,但通過對比我們發現,如果變量有volatile修飾,那麼在變量的賦值操作之後會執行一個lock addl $0x0,(%rsp)操作。而addl $0x0,(%rsp) 就是簡單的把RSP寄存器(博主爲64位環境)的值加0,這顯然是一個“空操作”,之所以這樣處理是因爲lock前綴不允許配合nop指令使用。而這裏我們更應該關注lock前綴,它的作用是使本CPU的Cache寫入內存,且該操作會使其他CPU(內核)內的Cache轉換爲無效(Invalid)狀態(回想一下前面提到的CPU緩存一致性協議)。所以通過這種方式就能使得volatile修飾的變量對其它CPU立即可見,也就解決了我們上面的可見性問題。

注:我們在計算機基礎相關的博文中已經提過了彙編,如果要明確對應的彙編指令的具體描述,讀者需要自行尋找實際運行環境CPU架構相關的開發手冊查詢。

這會兒我們來再看一個“有趣”的問題,還是上面的代碼,我們稍微改造一下say()方法:在while循環體中加一條輸出語句,如下所示:

public static void say() {
        while (!stoped) {
            System.out.println("saying");
        }
        System.out.println("stoped");
    }

然後我們發現,這時候即使stoped變量沒有被volatile修飾,程序也會按照預期結束,這是爲什麼呢?其實原因就在於System.out.println()。println方法是包含synchronized關鍵字的,其源碼如下所示:

public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

關鍵就在於這個synchronized,我們先在這裏把這點拋出來,後面在總結synchronized關鍵字的時候,再詳細解釋。

三、volatile之有序性

這裏我們着重說兩點,一個是上篇博文沒有詳細解釋的happens-before(先行發生)原則,另一個是指令重排。

3.1 happens-before

首先,happens-before是Java中“先天”的有序性,它不需要任何同步手段保證,前面我們也提到了先行發生原則和時間先後順序是沒有直接聯繫的,該怎麼理解這句話呢?現在有如下對象:

class Obj{
    private int value = 0;
    public void setValue(){
        this.value = 1;
    }
    public void getValue(){
        return value;
    }
}

假設我們創建了一個obj實例,現在有A和B兩個線程,它們會分別執行obj實例的setValue()和getValue()方法,並且A線程先調用了obj.setValue()方法,B線程後調用obj.getValue()方法,這裏的操作是線程安全的嗎?換句話說,B線程獲取到的value值是1嗎?我們用前面提到的先行發生原則來套一下:

首先,由於obj實例的setValue和getValue方法分別在不同的線程中執行,所以程序次序規則在這裏不適用;而我們也沒有使用任何的加鎖措施,這裏也就不會發生lock和unlock操作,所以管程鎖定規則同樣不適用;也沒有使用volatile修飾,所以volatile變量規則不適用;更是和線程啓動、終止、中斷規則,對象終結規則沒有什麼關係;也不存在傳遞性的情況。所以我們斷定這裏的操作不是線程安全的,也就是我們並不能確定線程B獲取到的value值是不是1。

上述的分析是根據先行發生原則推斷的理論結果,那麼實際上的情況呢?其實也很好理解,即使A線程先調用的setValue()方法,但是它不一定就能立即將value的最新值通過主內存反映到B線程中,所以我們不能確定B線程獲取到的value值。

要解決這個問題其實也很簡單,我們可以將setValue和getValue方法定義爲synchronized方法(synchorinzed在後面總結),然後就符合我們的管程鎖定規則了;或者把value變量增加volatile關鍵字修飾,就能滿足volatile規則了。這兩種方式都能實現先行發生關係。當然我們現在的重點是volatile,而使用volatile能達到目的的原因其實也就是我們前面提到的內容了。

3.2 指令重排

和前面一樣,我們從一個可能由指令重排引發問題的一個實際場景開始:

public class Util {
    private static Util instance;

    public static Util getInstance() {
        if (instance == null) {
            synchronized (Util.class) {
                if (instance == null) {
                    instance = new Util();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Util.getInstance();
    }
}

上述代碼是一個典型的雙重檢測(double-check)實現單例模式的代碼,相信大家都使用過,這裏我們就不詳細介紹了。爲了獲取單例對象,同時要避免每次獲取實例都執行上鎖/解鎖操作,我們使用了雙重檢測機制,如果instance已經被初始化好了,那麼則直接返回。但是上述代碼沒問題嗎?

我們前面也提到過,Java裏面的運算都是非原子的操作比如i++,這裏的 instance = new Util();也並不是一個原子操作。這裏我們不用看對應的JIT彙編輸出,只需要看看字節碼就足夠了(省略了部分內容):

Code:
      stack=2, locals=2, args_size=0
         0: getstatic     #2                  // Field instance:Lcom/echat/loren/Util;
         3: ifnonnull     37
         6: ldc           #3                  // class com/echat/loren/Util
         8: dup
         9: astore_0
        10: monitorenter
        11: getstatic     #2                  // Field instance:Lcom/echat/loren/Util;
        14: ifnonnull     27
        17: new           #3                  // class com/echat/loren/Util
        20: dup
        21: invokespecial #4                  // Method "<init>":()V
        24: putstatic     #2                  // Field instance:Lcom/echat/loren/Util;
        27: aload_0
        28: monitorexit
        29: goto          37
        32: astore_1
        33: aload_0
        34: monitorexit
        35: aload_1
        36: athrow
        37: getstatic     #2                  // Field instance:Lcom/echat/loren/Util;
        40: areturn
      Exception table:
         from    to  target type
            11    29    32   any
            32    35    32   any

首先我們站在虛擬機的角度簡單描述一下對象的創建:當虛擬機遇到一條new指令的時候,首先會對指令的參數進行類加載檢查,檢查通過之後,該對象需要多大的內存空間是可以確定的,所以接下來就可以爲該對象分配內存空間了,具體如何分配我們這裏暫時不去深究;空間分配好了之後,虛擬機需要將除對象頭外的其它內存空間都初始化零值,然後設置對象頭的信息,像元數據、哈希碼、GC分代年齡等等;然後需要執行對象的<init>方法,將對象按照我們代碼的要求進行初始化;當然還需要把對象地址作爲引用設置到變量中(博主使用的HotSpot沒有使用句柄池)。

對應到我們上面貼出的字節碼信息,就對應了new、dup、invokespecial等等指令。我們目前只需要瞭解,一個簡單的instance = new Util();操作,在大體上會涉及到分配內存、初始化內存、將內存地址設置到instance變量幾個步驟。而初始化內存和將內存地址設置到instance變量兩個操作是沒有直接依賴關係的,對於單線程程序而言,誰先執行誰後執行是不會造成異常結果的,這兩個操作也就可能會發生亂序:也就是在分配內存成功之後,首先將內存地址設置到了instance變量,然後再初始化內存。

我們提到了在單線程情況下,這樣是不會有什麼問題的,但是多線程情況下呢?初始狀態instance變量爲空,如果現在有一個線程A調用getInstance()方法,執行到了instance = new Util();這裏,而這裏發生了指令重排,實例對象的內存空間被分配之後就給了instance變量,並且將最新值同步回了主內存(對於普通共享變量而言,如果沒有使用任何同步措施,工作內存中的值何時同步到主內存其實是“不明確的”,所以可能會出現提前可見的可能),接下來再去做內存空間的初始化操作。

但是在內存初始化之前,又有一個B線程調用了getInstance()方法,而這個時候instance變量已經不爲空了,將會直接返回該變量,但是instance對應的內存空間還沒有被初始化,實例的創建都還沒有真正完成,這時候就把一個還初始化完成的對象提供給了調用者,明顯就是有問題的,這就是一個不安全發佈對象的情況。

而我們使用volatile就可以解決該問題,我們前面分析彙編的時候已經提到了,如果變量增加了volatile修飾,那麼在變量賦值的操作後面會有一個lock前綴操作,這個lock前綴可以保證變量的更新對其它CPU立即可見。同時,這個lock前綴在這裏也充當了一個內存屏障(Memory Barrier)的角色。

內存屏障

關於內存屏障,它算是一個同步點,此點之前的所有讀寫操作都執行完之後才能執行此點之後的操作,指令重排不能把屏障之後的指令重排到屏障之前。內存屏障是一個底層的原語,在不同的CPU體系架構下差別可能很大,需要參考具體的硬件手冊。我們上面的lock前綴加上一個空操作就是x86/x64結構的一種手段,只是這個空操作不能是nop指令,所以採取的操作是rsp寄存器加0,這點上面也有提及。那麼內存屏障是怎麼實現禁止指令重排的呢?

我們暫且不談編譯器的行爲,對於硬件結構而言,指令重排其實是CPU將多條指令不按照我們既定的順序發送到對應的電路單元處理,但是這個重排序是建立在正確處理依賴關係的前提下的。對於指令重排本身而言,一個操作如果要依賴一個值,那麼這個值必須是正確的,所以在一個CPU內,即使發生了重排序,其結果也會是正確的(as-if-serial)。而我們使用lock前綴將修改同步到主內存時,就代表lock前綴之前的操作都已經執行完畢了,對應上面的例子,就不會出現其它線程獲取到一個還沒有初始化好的對象了。所以在其它CPU觀測同一塊內存的情況下,達到了指令重排無法越過內存屏障的效果。

四、volatile之原子性

我們都知道,volatile不能保證原子性,具體是什麼意思呢?還是以i++的例子來進行說明。前面的文章已經不止一次提到了i++,它包含獲取i、加一、賦值等操作,如果i是一個靜態變量的話,那麼涉及到的字節碼就有:

getstatic

iconst_1

iadd

putstatic

如果我們現在沒有任何同步措施,那麼多個線程執行i++,是可能會出現併發問題的,簡單的說就是可能會出現以下情況(假設i的初始值爲1):

1> A線程從主存中讀取了i的值,並且拷貝到自己的工作內存中,並執行了加一操作,此時副本2,但是並沒有立即將2同步回主內存;

2> 然後B線程從主存中讀取i的值,由於A線程並沒有將自己的最新值刷新到主存,所以B線程此時獲取的i值還是爲1,然後加1的結果爲2;

3> 然後A和B線程都把值刷新到主內存,最終結果爲2

當然,i++能發生併發問題的不止上面這個情況,這裏只是結合該情況分析volatile的效果而已。按照上面情況的描述,我們做了兩次加一操作,但是結果卻相當於做了一次加一。如果我們將變量i增加volatile修飾呢?這個時候能夠保證B線程執行getstatic指令將最新的正確的i值取到操作棧頂,但是如果B線程將當前最新的i值取到了棧頂之後,接着正在執行iconst_1、iadd等指令的時候,其它線程更新了主存中i的值,這時B線程的操作棧頂的值就成了過期數據了,這樣執行下去還是會出現併發問題。所以我們說volatile並不能保證原子性。

注:這裏有的朋友可能會有疑問。我們前面提到了,volatile指令反應到JIT彙編使用了內存屏障,而屏障會讓本Cache寫入緩存,同時讓其它CPU(內核)的Cache無效化(Invalid),就這樣實現每次使用都從主存獲取,每次更新都立即同步回主存的語義。那麼既然會使其它緩存無效化,那麼如果B線程執行到iadd等指令的時候,i的值被其它線程更新了,應該會讓B線程中的緩存無效化啊?這樣iadd指令就不會用過期數據去做操作了纔對啊?這裏我們需要注意的是,volatile只能保證getstatic指令會從主內存獲取最新的i值,如果已經獲取了i的值,將其取到了操作棧頂,那就會用獲取到的值做運算了,但是我們再次調用getstatic指令的話,是能保證從主存獲取最新值的,而不會出現從工作內存獲取“舊”值的情況,因爲工作內存中的值已經無效了。換句話說,如果沒有volatile修飾,那麼每次調用getstatic指令,我們並不能保證會從主存獲取最新值,而可能直接從工作內存獲取副本值。

五、使用場景

我們前面提到了volatile的特性,現在總結幾條適合使用volatile的場景:

1、運算並不依賴變量的當前值

i++就是一個典型的依賴自身值的一個運算,如果我們的運算並不依賴自身的值,也就不會出現B線程先獲取值,然後其它線程更新了最新值,接着B線程使用過期數據繼續運算的情況了。比如i=1,就是簡單的賦值操作而已。

2、只有單一的線程會修改變量的值

這個比較好理解,如果是單一線程修改變量,就不會出現併發更新,也就避免了併發更新的問題,而volatile也能保證更新的最新值能及時反映到其它線程中。

3、變量不需要和其它的狀態變量共同參與不變約束

關於這段話,我的理解是:在理解這句話之前需要先明白什麼叫做不變約束(不變式),不變約束表達的是一種對狀態的約束,它可以在一定程度上表徵特定的規則。比如,性別只能是男(1)或女(2):gender == 1 or gender == 2、年齡要大於0:age > 0。它其實就是一個條件表達式,而該表達式在模型的任何行爲前後都成立。

我們設想一個條件表達式:a > b,表達式包含a和b兩個變量,初始狀態它們滿足不變約束,比如a==9,b==8(9>8)。現在如果有一個線程A要更新a和b的值,當然更新的值也要滿足不變約束,假設更新的結果爲a==4,b==3,如下述代碼所示:

public static volatile int a;
public static volatile int b;
public static void change(int newA,int newB){
    a = newA;
    b = newB;
}

//A線程調用更新a和b的值
change(4,3);

//B線程檢查不變約束
a > b == true?

我們將變量a和變量b都增加了volatile修飾,現在A線程負責調用change方法更新a和b的值,B線程負責檢查不變約束。初始狀態,a==9,b==8,明顯不變約束a>b沒有被打破。現在A線程調用change(4,3),當執行了a = newA,但還沒有執行b = newB的時候,B線程檢查不變約束就已經出問題了:a變量被volatile修飾,當A線程將其更新爲4之後就立即反饋到了B線程中,此時B線程“看到”a==4,但是b==8,明顯不變約束a>b已經被打破了(現在a < b)。當然,這不能算作volatile引發的問題,我們如果不使用volatile,也不能夠保證不變約束,只能說volatile解決不了這樣的問題。

六、總結

對於volatile,我們要理解它的“原理”,明白它能解決什麼問題,不能解決什麼問題,才能在合適的場景下正確使用它。我們前文有提到,不同的CPU架構可能允許不同的重排規則,而對應這些規則也有相應的內存屏障類型,這在本文沒有詳細地進行總結,博主的想法是對於volatile,理解到這裏對我們絕大多數人來說已經足夠了,更多概念性的、更深層次的東西,大家結合自己的興趣愛好做一定程度的研究就行。我們的初衷並不是要對這些技術挖掘到電信號的深度,而是爲了以理解它爲手段,達到能更好地運用它的目的。

 

參考:<<深入理解Java虛擬機>>

注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章