13.Java的Volatile關鍵字

這個Java的volatile關鍵字是用來標示一個Java變量作爲“正在被存儲在主內存的”。更加準確地說意味着,一個volatile變量的每一次讀取都是從計算機的主內存中讀取,而不是從CPU緩存中,並且對於一個volatile變量的每一次寫將會寫到主內存中,而不只是寫入到CPU緩存中。

事實上,自從Java5開始這個volatile關鍵字不只是保證變量寫到主內存,而且還從主內存中讀取。我將會在接下來的部分中解釋。

Java的volatile關鍵字的可見性的保證

這個Java的volatile關鍵字保證橫跨線程中對於變量改變的可見性。這個可能聽起來有點抽象,讓我們詳細闡述一下。

在一個多線程的應用中,線程操作在非volatile變量上,每一個線程在工作的時候可能會從主內存拷貝變量進入到CPU緩存中,因爲性能原因。如果你的計算機包含不只是一個CPU,那每一個線程可能會運行在不同的CPU中。那就意味着,每一個線程就會拷貝變量進入到不同的CPU的CPU緩存中去。如下圖所示:


使用非volatile的變量,這裏不能保證JVM什麼時間從主內存讀取數據進入到CPU緩存中,或者寫數據從CPU緩存進入主內存中。這個可能就會引起在下面部分將會解釋的幾個問題。

想象一個場景,兩個或者更多的線程訪問一個包含聲明瞭一個counter變量的一個共享對象,像下面這樣:

public class SharedObject {

    public int counter = 0;

}

也想象一下,只有線程1增加counter變量,但是線程1和線程2偶爾會讀取這個counter變量。

如果這個counter變量沒有聲明爲volatile,那就不能保證這個counter變量什麼時間從CPU緩存中寫回到主內存中。這個就意味着,這個在CPU緩存中的counter變量跟在主內存中的值是不同的。如下圖所示:


線程不能看到這個變量的最新的值的這個問題是因爲它還沒有被其他的線程寫回到主內存中,別稱之爲”可見性”問題。一個線程的更新對於其他的線程是不可見的。

通過聲明這個counter變量爲volatile,對於counter這個變量的所有寫入將會立刻寫回到主內存中。同時,對於counter變量的所有讀取將會直接從主內存中讀取。這裏有一個counter變量怎麼樣聲明爲volatile:

public class SharedObject {

    public volatile int counter = 0;

}

聲明一個變量爲volatile,因此可以保證針對這個變量的寫對於其他線程的可見性。

這個Java的volatile關鍵字保證了前後順序

自從Java5以來,這個volatile關鍵字不只是保證了變量從主內存的讀和寫。實際上,volatile關鍵字還保證了這個:

  • 如果線程A寫向一個volatile變量,以及線程B隨後讀取這個變量,然後在寫這個volatile變量之前,所有的變量對於線程A是可見的,在它已經讀取這個volatile變量之後也會對線程B可見的。
  • volatile變量的讀和寫的指令不會被JVM重排序(只要JVM檢測到只要來自於重排序的程序活動沒有改變,JVM可能因爲性能原因重排序指令)。指令可以在前和後重排序,但是volatile關鍵字的讀或者寫不會跟這些指令混合。無論跟隨一個volatile變量的讀或者寫的指令是什麼,都會保證讀或者寫的前後順序。
這些表述需要更深的解釋。
當一個線程寫一個volatile變量的時候,然後不只是這個volatile變量他自己被寫回到主內存。在寫這個volatile變量之前的被這個線程改變的所有其他的變量也會被寫回到主內存。當一個線程讀取一個volatile變量的時候,它也會讀取跟這個volatile變量一起被寫回到主內存的所有的其他變量。
看這個例子:
Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;

因爲在寫這個volatile的counter之前,線程A寫了非volatile得nonVolatile變量,然後當線程A寫這個counter(volatile變量)的時候,非volatile得變量也被寫回到了主內存中。
因爲線程B開始讀取counter這個volatile變量,然後這個counter變量和nonVolatile變量都會被線程B從主內存讀取到CPU緩存中。這個時候線程B也會看到被線程A寫的這個nonVolatile變量。
開發者可能使用這個擴展的可見性保證來優化線程之間變量的可見性。代替聲明每一個變量爲volatile,只是一個或者幾個需要聲明爲volatile。這裏有一個例子:
public class Exchanger {

    private Object   object       = null;
    private volatile hasNewObject = false;

    public void put(Object newObject) {
        while(hasNewObject) {
            //wait - do not overwrite existing new object
        }
        object = newObject;
        hasNewObject = true; //volatile write
    }

    public Object take(){
        while(!hasNewObject){ //volatile read
            //wait - don't take old object (or null)
        }
        Object obj = object;
        hasNewObject = false; //volatile write
        return obj;
    }
}

線程A可能會通過不斷的調用put方法設置對象。線程B可能會通過不斷的調用take方法獲取這個對象。這個類可以工作的很好通過使用一個volatile變量(沒有使用synchronized鎖),只要只是線程A調用put方法,線程B調用take方法。
然而,JVM可能重排序Java指令去優化性能,如果JVM可以做這個沒有改變這個重排序的指令。如果JVM改變了put方法和take方法內部的讀和寫的順序將會發生什麼呢?如果put方法真的像下面這樣執行:
while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //volatile write
object = newObject;

注意這個volatile變量的寫是在新的對象被真實賦值之前執行的。對於JVM這個可能看起來是完全正確的。這兩個寫的執行的值不會互相依賴。
然而,重排序這個執行的執行將會危害object變量的可見性。首先,線程B可能在線程A確定的寫一個新的值給object變量之前看到hasNewObject這個值設爲true了。第二,現在甚至不能保證對於object的新的值是否會寫回到主內存中。
爲了阻止上面所說的那種場景發生,這個volatile關鍵字提供了一個“發生前保證”。保證volatile變量的讀和寫指令執行前不會發生重排序。指令前和後是可以重排序的,但是這個volatile關鍵字的讀和寫指令是不能發生重排序的。
看這個例子:
sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile     = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM可能會重排序前面的三個指令,只要他們中的所有在volatile寫執行發生前(他們必須在volatile寫指令發生前執行)。
類似的,JVM可能重排序最後3個指令,只要volatile寫指令在他們之前發生。最後三個指令在volatile寫指令之前都不會被重排序。
那個基本上就是Java的volatile保證先行發生的含義了。
volatile關鍵字不總是足夠的
甚至如果volatile關鍵字保證了volatile變量的所有讀取都是從主內存中讀取,以及所有的寫也是直接寫入到主內存中,但是這裏仍然有些場景聲明volatile是不夠的。
在更早解釋的場景中,只有線程1寫這個共享的counter變量,聲明這個counter變量爲volatile是足夠確保線程2總是看到最新寫的值。
事實上,如果在寫這個變量的新的值不依賴它之前的值得情況下,甚至多個線程寫這個共享的volatile變量,仍然有正確的值存儲在主內存中。換句話說,如果一個線程寫一個值到這個共享的volatile變量值中首先不需要讀取它的值去計算它的下一個值。
如果一個線程需要首先去讀取這個volatile變量的值,並且建立在這個值的基礎上去生成一個新的值,那麼這個volatile變量對於保證正確的可見性就不夠了。在讀這個volatile變量和寫新的值之間的短時間間隔,出現了一個競態條件,在這裏多個線程可能會讀取到volatile變量的相同的值生成一個新的值,並且當寫回到主內存中的時候,會互相覆蓋彼此的值。
多個線程增加相同的值得這個場景,正好一個volatile變量不夠的。下面的部分將會詳細解析這個場景。
想象下,如果線程1讀取值爲0的共享變量counter進入到CPU緩存中,增加1並且沒有把改變的值寫回到主內存中。線程2讀取相同的counter變量從主內存中進入到CPU緩存中,這個值仍然爲0。線程2也是加1,並且也沒有寫入到主內存中。這個場景如下圖所示:

線程1和線程2現在是不同步的。這個共享變量的真實值應該是2,但是每一個線程在他們的CPU緩存中都爲1,並且在主內存中的值仍然是0.它是混亂的。甚至如果線程最後寫他們的值進入主內存中,這個值是錯誤的。
什麼時候volatile是足夠的
正如我前面提到的,如果兩個線程都在讀和寫一個共享的變量,然後使用volatile關鍵字是不夠的。你需要使用一個synchronized在這種場景去保證這個變量的讀和寫是原子性的。讀或者寫一個volatile變量不會堵塞正在讀或者寫的線程。因爲這個發生,你必須使用synchronized關鍵字在臨界區域周圍。
作爲一個synchronized鎖可選擇的,你也可以使用在java.util.concurrent包中的許多原子數據類型中的一個。例如,這個AtomicLong或者AtomicReference或者是其他中的一個。
假如只有一個線程讀和寫這個volatile變量的值,其他的線程只是讀取這個變量,然後讀的這個線程就會保證看到最新的值了。不使用這個volatile變量,這個就不能保證。
volatile關鍵字的性能考慮
volatile變量的讀和寫引起了這個變量將會讀或者寫到主內存。從主內存讀或者寫到主內存比訪問CPU緩存有更大的消耗。訪問volatile變量也會阻止指令重排序,這也是一個標準的性能增加技術。因此,你應該只有當你真正的需要變量的強烈可見性的時候應該使用volatile變量。


翻譯地址:http://tutorials.jenkov.com/java-concurrency/volatile.html

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