JAVA拾遺 - volatile關鍵字和原子性的探討

機房又只有我一個人...無聊到點開CSDN寫一篇文章吧~記錄下最近的學習

之前在學習JAVA的過程中有點模糊的地方,最近一個一個拔掉釘子,還是滿開心的。

在看到多線程後就發現有個非常不能理解的東西,比如說這篇文章即將講到的volatile關鍵字

本篇博客部分翻譯自http://tutorials.jenkov.com/java-concurrency/volatile.html

什麼是volatile

The Java volatile keyword guarantees visibility of changes to variables across threads.

volatile關鍵字的目的是爲了標記一個Java變量,使得其能夠存儲於主存中。更加具體的說,是每次都會直接從電腦的主內存中讀取這個變量,而不是從CPU的高速緩存裏面。同樣的,每次寫入都會寫入到主存中,而不是cache裏面。

事實上,從Java5開始,volatile關鍵字的作用就不是保證變量只會從主存裏面讀寫了,接下來就來闡述這一概念。

Java volatile的可視性

voliatile關鍵字保證了在進程中變量的變化的可視性。

在多線程的應用裏,如果線程操作了一個沒有被volatile關鍵字標記的變量,那麼每個線程都會在使用到這個變量時從主存裏拷貝這個變量到CPU的cache裏面(爲了性能!)。如果你的電腦有多於一個CPU,那麼每個線程都會在不同的CPU上面運行,這意味着每個線程都會把這個變量拷貝到不同的CPU cache裏面,正如下圖所示:

未加volatile關鍵字的變量在線程中的軌跡

一個不帶有volatile關鍵字的變量在JVM從主存裏面讀取數據到CPU cache或者從cache裏面寫入數據到主存時是沒有保證的,這會導致一些問題,在接下來的章節中我們就來討論這些問題。

想象這樣一個場景,當一到兩個線程允許去共享一個包含了一個計數變量的對象,這個計數變量如下所定義

public class SharedObject {

    public int counter = 0; //無關鍵字

}

然後,這線程一增加了counter變量的值,但是,但是同時線程一和線程二都有可能隨時讀取這個counter變量。

如果這個counter變量未曾使用volatile聲明,那麼我們就無法保證這個變量在兩個線程中所位於的CPU的cache和主存中的值是否保持一致了。示意圖如下:
Cache和主存中的counter變量值不同了!

那麼部分的線程就不能看到這個變量最新的樣子,因爲這個變量還沒有被線程寫回到主存中,這就是可視性的問題,這個線程更新的變量對於其他線程是不可視的。

在聲明瞭counter變量的volatile關鍵字後,所有寫入到counter變量的值會被立即寫回到主存中。同時,所有讀取這個變量的線程會直接從主存裏面讀取這個變量,下面的代碼就是聲明帶volatile關鍵字的變量的方法

public class SharedObject {

    public volatile int counter = 0;

}

如此聲明這個變量就保證了這個變量對於其他寫這個變量的線程的可視性。

Java volatile 對於happens-before的保證

什麼是happens-before?

多線程有兩個基本的問題,就是原子性和可見性,而happens-before規則就是用來解決可見性(我還是比較喜歡稱之爲可視性)的。

在時間上,動作A發生在動作B之前,能不能**保證**B可以看見A?如果可以保證的話,那麼就可以說hb(A,B)

JVM保證了一下的幾條法則:
* 如果A和B是同一個線程的,那麼hb(A, B)
* 如果A是對鎖的unlock,而B是對同一個鎖的lock,那麼hb(A, B)
* 如果A是對volatile變量的寫操作,B是對同一個變量的讀操作,那麼hb(A, B)
* 傳遞性:如果hb(A, C) 且 hb(B, C),那麼hb(A, C)

如果有兩個線程

thread1                 thread2
----------------------------------
x = 1    (A)
M.unlock (B)
x = 2    (C)
                       M.lock (D)
                       y = x  (E)

那麼執行到E的時候,E能不能保證看到C步呢?
由法則1,hb(D,E)
由法則2,hb(B,D) 由法則1, hb(A,B) 綜上可以推出,hb(A, E),但是推不出hb(C, E) 所以,E不一定能看見C,但是E一定能看見A

所以執行E的時候,有可能thread2看到的x的值還是1

次序法則見附錄:A

從Java 5開始volatile關鍵字就不只保證了只從主存中讀取和寫入變量,volatile關鍵字保證了:
* 如果線程A寫入到一個volatile的變量,隨後線程B讀取了這個volatile變量,那麼所有的變量在A寫入到volatile變量前都具有可見性,同時所有的變量在線程B讀取這個volatile變量後同樣對於B有可見性。
* 對於volatile變量的讀取與寫入命令不能被JVM重新規劃排序(其他的變量可能因爲性能原因在被JVM探測到不會在程序中改變後而重新規劃排序)。之後與之前的命令可以被重新規劃,所有在讀取或者寫入volatile變量的後的命令都會被保證安排到這次讀取與寫入之後

當一個線程寫入到一個volatile變量後,不僅僅是這個volatile變量自己會被寫入到主存中,同時所有的在這次寫入之前的被這個線程改變的變量都會被flush到主存中。當線程讀取一個volatile變量時,這個線程也會從主存中讀取隨着這個volatile變量flush到主存的所有其他的變量。

如下所示

Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

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

當線程A寫了一個未標記的變量 shareObject.nonVolatile後與寫入volatile變量counter前,這兩個變量都會隨着counter的寫入而寫入到主存中。

當線程B開始讀取volatile變量counter時,counter和nonVolatile都會從主存中被讀取到CPU cache裏面從而被線程B使用,同時B讀取nonVolatile時會看到這個被A改變的變量。

開發者可能會利用這個額外的可視性原則在線程之間去優化變量的可視性,除去聲明所有的變量爲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()方法,這個Exchanger類能夠在不使用synchronized鎖的前提下,只使用volatile關鍵字變量來使得只有在線程A調用了put()後線程B調用take()。

前面說了,JVM會根據性能調優的緣故去調換操作的順序,如果JVM調換了put和take方法內部的變量讀取與寫入的順序,那麼put方法可能會怎樣執行呢:

while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //天啊,換到前面去了volatile write
object = newObject;

可以發現JVM會先對hasNewObject改值,再去新建一個變量Object,這對於JVM來說是沒有問題的,因爲這兩個變量的值並沒有相關性。

這樣,重新排列命令會對object變量的可視性造成毀滅打擊。第一是線程B可能在線程A新建object之前就發現了hasNewObject變成了TRUE;然後是現在對於object來說,不在會有把它flush到主存的保證了。

爲了預防這個情況的出現,volatile有了個“happens before 保證”,這個保證了JVM不再回去重排讀取與寫入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變量的寫入之前完成。

相似的,JVM會重排後三個操作的順序,但是後三個操作都不會重排到volatile變量寫入完成之前。

volatile變量變成了一個關卡!

Volatile可不夠你用

儘管volatile關鍵字保證了所有對於volatile變量的讀取都會直接調用主存,而所有對於volatile的寫入都會直接寫到主存,但這裏面依然有些情況單單聲明volatile變量是不夠的。

在我們之前提到的情況裏,當只有Thread 1寫入到共享的counter變量,聲明counter變量爲volatile足夠保證線程B看到的counter變量總是最新的。

但是事實上,多線程會寫入到同一個共享的volatile變量了,如果對於這個volatile變量的寫入不是基於他目前的值,換句話說,如果線程寫入到一個共享的volatile變量不會首先去讀取它的值去弄清楚它接下來的值。

當一個線程需要去第一次讀取一個volatile變量,同時生成一個基於這個變量值的變量時(i = i + 1),這個volatile關鍵字就不夠來保證其的可視性了。在這短短的讀取、寫入volatile變量的間隔中,當多個線程可能回去讀取這同一個值的時候,就會建立一個競爭的環境,在一個線程爲這個變量生成一個新的值,和寫入這個值到內存中這個過程中,可能就會把其他人的值給複寫了。

在多線程計數的情況下,volatile關鍵字變量是完全不夠的,接下來的例子會講一些細節的東西

想象如果線程1讀取了共享的counter變量value 0到他的CPU cache中,增加其到1但還沒來得及寫回到主存中,線程2也可以從主存中讀取同一個counter變量(此時這個變量還是0),線程2也可以令這個counter從0變成1,也還沒來得及寫回主存。
如圖所示,counter變量是線程不安全的
線程1和線程2現在出現了不同步的現象,counter的真實值應該是2,但是每一個線程的counter都變是1(存於CPU cache中),而主存中的變量甚至還是0。是不是很恐怖!儘管每個線程都會直接把他們的counter值寫回到主存中,但是這個counter的值依然是錯誤的。

那什麼時候該用volatile

在之前提到,當兩個線程同時讀取與寫入到一個變量時,使用volatile關鍵字是不夠的,你還是需要給它上個鎖來保證這個變量的原子性。讀取與寫入一個volatile變量不會暫停一個線程的讀取與寫入,所以你需要利用synchronized關鍵字來保證準確的行動。

爲了替代synchronized的暫停現象,你也可以利用那些原子性的數據形式,你可以在 java.util.concurrent package(http://tutorials.jenkov.com/java-util-concurrent/index.html)裏找到這些數據類型。

在只利用一個線程讀取和寫入volatile變量,而其他線程只讀取變量時,那麼這個讀取的線程就保證能夠看到這個volatile值的最終值,換句話說,如果你不用volatile關鍵字,這可不能保證哦~

volatile關鍵字只能對32位和64位的變量使用

volatile的性能

讀取與寫入一個volatile變量會從主存裏面直接獲取。而對註冊你的操作是更低效於Cpu的cache的,同樣使用volatile關鍵字會減少JVM自帶的調整命令順序調優性能這一黑科技。所以你應該在你真正需要這個關鍵字的時候再去使用它!

附錄:

附錄A:次序法則
1, 程序次序法則,如果A一定在B之前發生,則happen before
2, 監視器法則,對一個監視器的解鎖一定發生在後續對同一監視器加鎖之前 
3, Volatie變量法則:寫volatile變量一定發生在後續對它的讀之前  
4, 線程啓動法則:Thread.start一定是發生在線程中的動作  
5, 線程終結法則:線程中的任何動作一定發生在括號中的動作之前(其他線程檢測到這個線程已經終止,從Thread.join調用成功返回,Thread.isAlive()返回false)  
6, 中斷法則:一個線程調用另一個線程的interrupt一定發生在另一線程發現中斷。  
7, 終結法則:一個對象的構造函數結束一定發生在對象的finalizer之前  
8, 傳遞性:A發生在B之前,B發生在C之前,A一定發生在C之前。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章