天天看點

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