Java volatile 關鍵詞

@[toc]
Java中的volatile關鍵詞被用來將變量標記爲“存儲在內存中”。準確地的講每次volatile變量的讀取和寫入都是直接操作內存,而不是cpu cache。
實際上自從java 5之後,__volatile__關鍵詞保證除了volatile變量直接讀寫內存外,它也被賦予了更多的含義,文章後續會解釋。

變量可見性問題

java volatile 關鍵詞保證變量在多線程間變化的可見性。聽起來有點抽閒,讓我詳細說明下。

在多線程應用中,當線程操作非volatile變量時,因爲性能上的考慮,每個線程會把變量從內存中拷貝一份到cpu cache裏(譯者注:讀寫一次磁盤需要100ns,level1 cache只需要1ns)。如果你的電腦有多個cpu,每個線程運行在不同的cpu上,這就意味着每個線程會將變量拷貝到不同的cpu cache上,如下圖。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-t6i5t6sU-1582458028402)(http://note.youdao.com/yws/res/34449/F285A84E6F1540149190E27A1FEF12D6)]
對於非volatile變量,JVM不會保證每次都寫都是從內存中讀寫,這可能會導致一系列的問題。

試想下這樣一個場景,多個線程操作一個包含計數器的變量,如下。

public class SharedObject {
    public int counter = 0;
}

如果只有線程1會增加counter,但線程1和線程2會時不時讀counter。

如果counter沒有被聲明成volatile,jvm不會保證每次counter寫cpu cache都會被同步到主存。這就意味着cpu cache裏的數據和主存中的有可能不一致,如下圖所示。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-OYeZckOT-1582458028405)(http://note.youdao.com/yws/res/34465/E8CC82943BB04F76ACB0FCCF8AE85F1A)]

另一個線程線程沒法讀到最新的變量值,因爲數據還沒有從cpu cache裏同步到主存中,這就是 __可見性問題__,數據的更新對其他線程不可見。

Java volatile可見性保證

Java volatile的誕生就是爲了解決可見性問題。把counter變量聲明爲volatile,所有counter的寫入都會被立刻寫會到主存裏,所有的讀都會從主存裏直接讀。

volatile的用法如下:

public class SharedObject {
    public volatile int counter = 0;
}

把變量聲明爲volatile保證了其他線程對這個變量更新的可見性。
在上面的例子中,線程1修改counter變量,線程2只讀不修改,把counter聲明爲volatile就可以保證線程2讀取數據的正確性了。
當時,如果線程1線程2都會修改這個變量,那volatile也無法保證數據的準確性了,後續會詳解。

volatile 完全可見性保證

實際上,Java volatile可見性保證超出了volatile變量本身。可見性保證如下。

  • 如果線程A修改一個volatile變量,並且線程B隨後讀取了同一個變量,你們線程A在寫volatile變量前的所有變量操作在線程B讀取volatile變化後對線程B都可見。
  • 如果線程A讀取了volatile變量,那麼在它之後線程A讀取都所有變量都將從主存中重新讀取。
    測試代碼如下:
public class MyClass {
    private int years;
    private int months
    private volatile int days;


    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

update()方法寫了三個變量,但只有days被聲明爲volatile。
volatile完全可見性保證的含義是:當線程修改了days,所有的變量都會被同步到主存中,在這裏years和months也會被同步到主存中。

在讀取years、months、days的時候,你可以這麼做。

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

當調用totalDays()方法後,當讀取days之後到total變量後,months和years也會從主存中同步。如果按上面的順序,可以保證你一定讀到days,months和years的最新值。

譯者注:在上面這個特定讀寫順序下,雖然只有days是volatile變量,但days和months也實現了volatile。我猜測原因和cpu硬件有關,volatile變量讀取前將要讀取的地址在cpu cache中置爲失效,這樣就保證了每次讀取前必須從內存中做數據更新。同樣寫入後會強制同步cache數據到主存中,這樣就實現了volatile語義。但實際上cpu cache在管理cache數據的時候並不是以單個地址爲單位,而是以一個block爲單位,所以一個block中只要有一個volatile變量,那麼讀寫這個變量都會導致整個block和主存同步。
綜上所述,我認爲原作者博客中這部分內容不具備參考性,java沒有承諾過類似的保證,而且這種可見性估計和具體的cpu實現有關,可能不具備可遷移性,不建議大家這麼用。所以如果有多個變量需要可見性保證,還是得都加volatile標識。

指令重排序挑戰

Jvm和cpu爲性能考慮都可能會最大指令進行重排序,但都會保證語義的一致性。例如:

int a = 1;
int b = 2;

a++;
b++;

這些指令在保證語義正確性下可以被重排爲下面的次序。

int a = 1;
a++;

int b = 2;
b++;

但當一個變量是volatile的時候,指令重排序會面臨一個挑戰。 讓我們再來看下上面提到的MyClass()的例子。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

當update()方法寫入days變量後,years和months最新的變量也會被寫入,但如果jvm像下面一樣重新排列了指令:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

雖然months和years最終也會被寫入到主存中,但卻不是實時的,無法保證對其他線程的立即可見。實際語義也會因爲指令重排序而改變。
Java 實際上已經解決了這個問題,讓我們接着看下去。

Java volatile和有序性(Happens-Before)保證

爲了解決重排序的挑戰,java volatile關鍵詞可見性之上也保證了"有序性(happens-before)",有序性的保證含義如下。

  • 對其他變量的讀和寫如果原來就在volatile變量寫之前,就不能重排到volatile變量的寫之後。 一個volatile變量寫之前的的讀/寫保證在寫之前。請注意有特殊情況,例如,對volatile的寫操作之後的其他變量的讀/寫操作會在對volatile的寫操作之前重新排序。而不是反過來。從後到前是允許的,但從前到後是不允許的。
  • 如果對其他變量的讀/寫如果最初就在對volatile變量的讀/寫之後,則不能將其重排序到volatile讀之前。請注意,在讀取volatile變量之前發生的其他變量的讀取可以在讀取volatile變量之後重新排序。而不是反過來。從前到後是允許的,但從後到前是不允許的。

上面的happens-before保證確保了volatile關鍵字強可見性。

volatile還不夠

儘管volatile保證數據的讀寫都是從主存中直接操作的,但還有好多情況下volatile語義還是不夠的。在前面的例子中,線程1寫counter變量,如果將counter聲明爲volatile,線程2總能看到最新的值。

但事實上,如果多個線程都可以寫共享的volatile變量且每次寫入的新值不依賴於舊值,依舊可以保證變量值的準確性,換句話說就是有個線程寫之前不需要先讀一次再在讀入的數據上計算出下一個值。

在讀出-計算-寫入的模式下就無法再保證數值的正確性了,因爲在計算的過程中,這個時間差下數據可能已經被其他線程更新過了,多個線程可能競爭寫入數據,就會產生數據覆蓋的情況。

所以在上面例子中,多個線程共同更新counter的情況下,volatile就無法保證counter數值的準確性了。下面會詳細解釋這種情況。

想象下線程1讀到的counter變量是0,然後它加了1,但沒有寫回到主存裏,而是寫在cpu cache裏。這個時候線程2同樣也看到的counter是0,它也同樣加了1並只寫到自己的cpu cache中,就像下圖這樣。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-gZS3nLAL-1582458028407)(http://note.youdao.com/yws/res/34603/EA677F096A7E40A9B8119C28F65C07D2)]

這個時候線程1和線程2的數據實際上是不同步的。我們預期的counter實際值應該是2,但在主存中是0,在某個cpu cache中是1。最終某個線程cpu cache中的數據會同步會主存,但數據是錯的。

什麼時候volatile就足夠了?

像上文中提到的一樣,如果有多個線程都讀寫volatile變量,只用volatile遠遠不夠,你需要用synchronized來保證讀和寫是一個原子操作。 讀和寫一個volatile變量不會阻塞其他的線程,爲了避免這種情況發生,你必須使用synchronized關鍵詞。

除了synchronized之外,你還可以使用java.util.concurrent包中提供的原子數據類型,比如AtomicLong或者AtomicReferences。
如果只有一個線程寫入,其他線程都是隻讀,那麼volatile就足夠了,但不使用volatile的話是不能保證數據可見性的。

注意:volatile只保證32位和64位變量的可見性。

volatile的性能考量

volatile會導致數據的讀寫都直接操作主存,讀寫主存要不讀寫cpu cache慢的多。volatile也禁止了指令重排序,指令重排序是常見的性能優化手段,所以你應該只在真正需要強制變量可見性時才使用volatile。

原文地址

http://tutorials.jenkov.com/java-concurrency/volatile.html

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