Java中的volatile關鍵字

最愛芒果 9年前發布 | 18K 次閱讀 Java Java開發

Java 語言中的 volatile 變量可以被看作是一種 “程度較輕的 synchronized”;與 synchronized 塊相比,volatile 變量所需的編碼較少, 并且運行時開銷也較少,但是它所能實現的功能也僅是 synchronized 的一部分。

當volatile用于一個作用域時,Java保證如下:

1.(適用于Java所有版本)讀和寫一個volatile變量有全局的排序。也就是說每個線程訪問一個volatile作用域時會在繼續執行之前讀取它的當前值, 而不是(可能)使用一個緩存的值。(但是并不保證經常讀寫volatile作用域時讀和寫的相對順序,也就是說通常這并不是有用的線程構建)。

2.(適用于Java5及其之后的版本)volatile的讀和寫建立了一個happens-before關系,類似于申請和釋放一個互斥鎖。

使用volatile會比使用鎖更快,但是在一些情況下它不能工作。volatile使用范圍在Java5中得到了擴展,特別是雙重檢查鎖定現在能夠正確工作。 volatile可以用在任何變量前面,但不能用于final變量前面,因為final型的變量是禁止修改的。也不存在線程安全的問題。

了解volatile關鍵字關鍵字之前,首先了解下原子操作和happens-before關系的相關概念。

原子操作

原子(atom)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為"不可被中斷的一個或一系列操作" 。 原子操作在操作完畢之前不會線程調度器中斷。在Java中,對除了long和double之外的基本類型的簡單操作都具有原子性。 簡單操作就是賦值或者return。比如”a = 1;“和 “return a;”這樣的操作都具有原子性。但是在Java中,類似“a += b”這樣的操作不具有原子性, 所以如果方法不是同步的就會出現難以預料的結果。在某些JVM中”a += b”可能要經過這樣三個步驟: 1.取出a和b。2.計算a+b。3.將計算結果寫入內存。

如果有兩個線程t1,t2在進行這樣的操作。t1在第二步做完之后還沒來得及把數據寫回內存就被線程調度器中斷了,于是t2開始執行, t2執行完畢后t1又把沒有完成的第三步做完。這個時候就出現了錯誤,相當于t2的計算結果被無視掉了。 類似的,像“a++”這樣的操作也都不具有原子性。所以在多線程的環境下一定要記得進行同步操作。

要搞清楚這個問題,首先應該明白計算機內部都做什么了。比如做了一個i++操作,計算機內部做了三次處理:讀取-修改-寫入。 同樣,對于一個long型數據,做了個賦值操作,在32系統下需要經過兩步才能完成,先修改低32位,然后修改高32位。

假想一下,當將以上的操作放到一個多線程環境下操作時候,有可能出現的問題,是這些步驟執行了一部分,而另外一個線程就已經引用了變量值, 這樣就導致了讀取臟數據的問題。

happens-before關系

Java語言中有一個“先行發生”(happen—before)的規則,它是Java內存模型中定義的兩項操作之間的偏序關系,如果操作A先行發生于操作B, 其意思就是說,在發生操作B之前,操作A產生的影響都能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等, 它與時間上的先后發生基本沒有太大關系。這個原則特別重要,它是判斷數據是否存在競爭、線程是否安全的主要依據。

舉例來說,假設存在如下三個線程,分別執行對應的操作:

線程A中執行如下操作:i=1。

線程B中執行如下操作:j=i。

線程C中執行如下操作:i=2。

假設線程A中的操作”i=1“ happen—before線程B中的操作“j=i”,那么就可以保證在線程B的操作執行后,變量j的值一定為1, 即線程B觀察到了線程A中操作“i=1”所產生的影響;現在,我們依然保持線程A和線程B之間的happen—before關系,同時線程C出現在 了線程A和線程B的操作之間,但是C與B并沒有happen—before關系,那么j的值就不確定了,線程C對變量i的影響可能會被線程B觀察到, 也可能不會,這時線程B就存在讀取到不是最新數據的風險,不具備線程安全性。

下面是Java內存模型中的八條可保證happen—before的規則,它們無需任何同步器協助就已經存在,可以在編碼中直接使用。 如果兩個操作之間的關系不在此列,并且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們進行隨機地重排序。

1、程序次序規則:在一個單獨的線程中,按照程序代碼的執行流順序,(時間上)先執行的操作happen—before(時間上)后執行的操作。

2、管理鎖定規則:一個unlock操作happen—before后面(時間上的先后順序,下同)對同一個鎖的lock操作。

3、volatile變量規則:對一個volatile變量的寫操作happen—before后面對該變量的讀操作。

4、線程啟動規則:Thread對象的start()方法happen—before此線程的每一個動作。

5、線程終止規則:線程的所有操作都happen—before對此線程的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive() 的返回值等手段檢測到線程已經終止執行。

6、線程中斷規則:對線程interrupt()方法的調用happen—before發生于被中斷線程的代碼檢測到中斷時事件的發生。

7、對象終結規則:一個對象的初始化完成(構造函數執行結束)happen—before它的finalize()方法的開始。

8、傳遞性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。

時間上先后順序和happen—before原則

“時間上執行的先后順序”與“happen—before”之間有何不同呢?

1、首先來看操作A在時間上先與操作B發生,是否意味著操作A happen—before操作B?

一個常用來分析的例子如下:


    private int value = 0;
    public int get(){
        return value;
    }
    public void set(int value){
        this.value = value;
    }
}
   

假設存在線程A和線程B,線程A先(時間上的先)調用了setValue(3)操作,然后(時間上的后)線程B調用了同一對象的getValue()方法, 那么線程B得到的返回值一定是3嗎? 對照以上八條happen—before規則,發現沒有一條規則適合于這里的value變量,從而我們可以判定線程A中的setValue(3)操作與 線程B中的getValue()操作不存在happen—before關系。因此,盡管線程A的setValue(3)在操作時間上先于操作B的getvalue(), 但無法保證線程B的getValue()操作一定觀察到了線程A的setValue(3)操作所產生的結果,也即是getValue()的返回值不一定為3 (有可能是之前setValue所設置的值)。這里的操作不是線程安全的。

因此,“一個操作時間上先發生于另一個操作”并不代表“一個操作happen—before另一個操作”。

解決方法:可以將setValue(int)方法和getValue()方法均定義為synchronized方法,也可以把value定義為volatile變量 (value的修改并不依賴value的原值,符合volatile的使用場景),分別對應happen—before規則的第2和第3條。注意, 只將setValue(int)方法和getvalue()方法中的一個定義為synchronized方法是不行的,必須對同一個變量的所有讀寫同步, 才能保證不讀取到陳舊的數據,僅僅同步讀或寫是不夠的 。

2、其次來看,操作A happen—before操作B,是否意味著操作A在時間上先與操作B發生?

如果A happens- before B,JMM并不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對后一個操作可見, 且前一個操作按順序排在第二個操作之前。CPU是可以不按我們寫代碼的順序執行內存的存取過程的,也就是指令會亂序或并行運行, 只有上面的happens-before所規定的情況下,才保證順序性,確保任何內存的寫,對其他語句都是可見的。

假設同一個線程執行上面兩個操作:操作A:x=1和操作B:y=2。根據happen—before規則的第1條,操作A happen—before 操作B, 但是由于編譯器的指令重排序(Java語言規范規定了JVM線程內部維持順序化語義,也就是說只要程序的最終結果等同于它在嚴格的順序化環境下的結果, 那么指令的執行順序就可能與代碼的順序不一致。這個過程通過叫做指令的重排序。指令重排序存在的意義在于:JVM能夠根據處理器的特性 (CPU的多級緩存系統、多核處理器等)適當的重新排序機器指令,使機器指令更符合CPU的執行特點,最大限度的發揮機器的性能。 在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整)等原因,操作A在時間上有可能后于操作B被 處理器執行,但這并不影響happen—before原則的正確性。因此,“一個操作happen—before另一個操作”并不代表“一個操作時間上先發生于另一個操作”。

最后,一個操作和另一個操作必定存在某個順序,要么一個操作或者是先于或者是后于另一個操作,或者與兩個操作同時發生。 同時發生是完全可能存在的,特別是在多CPU的情況下。而兩個操作之間卻可能沒有happen-before關系,也就是說有可能發生這樣的情況, 操作A不happen-before操作B,操作B也不happen-before操作A,用數學上的術語happen-before關系是個偏序關系。 兩個存在happen-before關系的操作不可能同時發生,一個操作A happen-before操作B,它們必定在時間上是完全錯開的, 這實際上也是同步的語義之一(獨占訪問)。

舉例說明如下:


public class Test {
    private int a = 0;
    private long b = 0;
    public void set() {
        a = 1;
        b = -1;
    }
    public void check() {
        if (! ((b == 0) || (b == -1 && a == 1))
            throw new Exception("check Error!");
    }
}
   

對于set()方法的執行: 1. 編譯器可以重新安排語句的執行順序,這樣b就可以在a之前賦值。如果方法是內嵌的(inline),編譯器還可以把其它語句重新排序。 2. 處理器可以改變這些語句的機器指令的執行順序,甚到同時執行這些語句。 3. 存儲系統(由于被緩存控制單元控制)也可以重新安排對應存儲單元的寫操作順序,這些寫操作可能與其他計算和存儲操作同時發生。 4. 編譯器,處理器和存儲系統都可以把這兩條語句的機器指令交叉執行。 例如:在一臺32位的機器上,可以先寫b的高位,然后寫a,最后寫b的低位,(注:b為long類型,在32位的機器上分高低位存儲) 5. 編譯器,處理器和存儲系統都可以使對應于變量的存儲單元一直保留著原來的值, 以某種方式維護相應的值(例如,在CPU的寄存器中) 以保證代碼正常運行,直到下一個check調用才更新。

在單線程(或同步)的情況下,上面的check()永遠不會報錯, 但非同步多線程運行時卻很有可能。 并且,多個CPU之間的緩存也不保證實時同步,也就是說你剛給一個變量賦值,另一個線程立即獲取它的值,可能拿到的卻是舊值(或null), 因為兩個線程在不同的CPU執行,它們看到的緩存值不一樣,只有在synchronized或volatile或final的性況下才能保證正確性。

例如:


    public class Test {
        private int n;
        public void set(int n) {
            this.n = n;
        }
        public void check() {
            if (n != n)
                throw new Exception("check Error!");
        }
    }
    

check()中的 n != n 好像永遠不會成立,因為他們指向同一個值,但非同步時卻很有可能發生。

volatile使用情形

只能在有限的一些情形下使用 volatile 變量替代鎖。要使 volatile 變量提供理想的線程安全,必須同時滿足下面兩個條件:

1.對變量的寫操作不依賴于當前值。

2.該變量沒有包含在具有其他變量的不變式中。

模式1:狀態標志

也許實現 volatile 變量的規范使用僅僅是使用一個布爾狀態標志,用于指示發生了一個重要的一次性事件,例如完成初始化或請求停機。 很多應用程序包含了一種控制結構,形式為 “在還沒有準備好停止程序時再執行一些工作”,如下所示:


volatile boolean shutdownRequested;
...
public void shutdown() { shutdownRequested = true; }
public void doWork() {
    while (!shutdownRequested) {
        // do stuff
    }
}
    

很可能會從循環外部調用 shutdown() 方法,即在另一個線程中。因此,需要執行某種同步來確保正確實現 shutdownRequested 變量的可見性。 然而,使用 synchronized 塊編寫循環要比使用如上所示的 volatile 狀態標志編寫麻煩很多。由于 volatile 簡化了編碼,并且狀態標志并不依賴于程序內任何其他狀態,因此此處非常適合使用 volatile。

模式2:一次性安全發布(one-time safe publication)

缺乏同步會導致無法實現可見性,這使得確定何時寫入對象引用而不是原語值變得更加困難。在缺乏同步的情況下,可能會遇到某個對象引用的更新值 (由另一個線程寫入)和該對象狀態的舊值同時存在。(這就是造成著名的雙重檢查鎖定(double-checked-locking)問題的根源, 其中對象引用在沒有同步的情況下進行讀操作,產生的問題是您可能會看到一個更新的引用,但是仍然會通過該引用看到不完全構造的對象)。 實現安全發布對象的一種技術就是將對象引用定義為 volatile 類型。下面的代碼展示了一個示例,其中后臺線程在啟動階段從數據庫加載一些數據。 其他代碼在能夠利用這些數據時,在使用之前將檢查這些數據是否曾經發布過。


public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
public class SomeOtherClass {
    public void doWork() {
        while (true) {
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null)
                doSomething(floobleLoader.theFlooble);
        }
    }
}
    

如果 theFlooble 引用不是 volatile 類型,doWork() 中的代碼在解除對 theFlooble 的引用時,將會得到一個不完全構造的 Flooble。 該模式的一個必要條件是:被發布的對象必須是線程安全的,或者是有效的不可變對象(有效不可變意味著對象的狀態在發布之后永遠不會被修改)。 volatile 類型的引用可以確保對象的發布形式的可見性,但是如果對象的狀態在發布后將發生更改,那么就需要額外的同步。

模式3:獨立觀察(independent observation)

安全使用 volatile 的另一種簡單模式是:定期 “發布” 觀察結果供程序內部使用。例如,假設有一種環境傳感器能夠感覺環境溫度。 一個后臺線程可能會每隔幾秒讀取一次該傳感器,并更新包含當前文檔的 volatile 變量。然后,其他線程可以讀取這個變量, 從而隨時能夠看到最新的溫度值。 使用該模式的另一種應用程序就是收集程序的統計信息。下面展示了身份驗證機制如何記憶最近一次登錄的用戶的名字。將反復使用 lastUser 引用來發布值,以供程序的其他部分使用。


    public class UserManager {
        public volatile String lastUser;
        public boolean authenticate(String user, String password) {
            boolean valid = passwordIsValid(user, password);
            if (valid) {
                User u = new User();
                activeUsers.add(u);
                lastUser = user;
            }
            return valid;
        }
    }
    

該模式是前面模式的擴展;將某個值發布以在程序內的其他地方使用,但是與一次性事件的發布不同, 這是一系列獨立事件。這個模式要求被發布的值是有效不可變的 —— 即值的狀態在發布后不會更改。使用該值的代碼需要清楚該值可能隨時發生變化。

模式4:“volatile bean” 模式

volatile bean 模式適用于將 JavaBeans 作為“榮譽結構”使用的框架。在 volatile bean 模式中,JavaBean 被用作一組具有 getter 和/或 setter 方法 的獨立屬性的容器。volatile bean 模式的基本原理是:很多框架為易變數據的持有者(例如 HttpSession) 提供了容器,但是放入這些容器中的對象必須是線程安全的。

在 volatile bean 模式中,JavaBean 的所有數據成員都是 volatile 類型的,并且 getter 和 setter 方法必須非常普通, 除了獲取或設置相應的屬性外,不能包含任何邏輯。此外,對于對象引用的數據成員,引用的對象必須是有效不可變的。 (這將禁止具有數組值的屬性,因為當數組引用被聲明為 volatile 時,只有引用而不是數組本身具有 volatile 語義)。 對于任何 volatile 變量,不變式或約束都不能包含 JavaBean 屬性。下面示例展示了遵守 volatile bean 模式的 JavaBean:


@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
    

模式5:開銷較低的讀-寫鎖策略

目前為止,您應該了解了 volatile 的功能還不足以實現計數器。因為 ++x 實際上是三種操作(讀、添加、存儲)的簡單組合, 如果多個線程湊巧試圖同時對 volatile 計數器執行增量操作,那么它的更新值有可能會丟失。 然而,如果讀操作遠遠超過寫操作,您可以結合使用內部鎖和 volatile 變量來減少公共代碼路徑的開銷。下面的代碼顯示的線程安全的計數器使用 synchronized 確保增量操作是原子的,并使用 volatile 保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的性能, 因為讀路徑的開銷僅僅涉及 volatile 讀操作,這通常要優于一個無競爭的鎖獲取的開銷。


@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
    public int getValue() { return value; }
    public synchronized int increment() {
        return value++;
    }
}  

    

之所以將這種技術稱之為 “開銷較低的讀-寫鎖” 是因為您使用了不同的同步機制進行讀寫操作。因為本例中的寫操作違反了使用 volatile 的第一個條件,因此不能使用 volatile 安全地實現計數器 —— 您必須使用鎖。然而,您可以在讀操作中使用 volatile 確保當前值的可見性,因此可以使用鎖進行所有變化的操作,使用 volatile 進行只讀操作。其中,鎖一次只允許一個線程訪問值, volatile 允許多個線程執行讀操作,因此當使用 volatile 保證讀代碼路徑時,要比使用鎖執行全部代碼路徑獲得更高的共享度, 就像讀-寫操作一樣。然而,要隨時牢記這種模式的弱點:如果超越了該模式的最基本應用,結合這兩個競爭的同步機制將變得非常困難。

volatile的原理和實現機制

觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的匯編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令, lock前綴指令實際上相當于一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

1)它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面; 即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;

2)它會強制將對緩存的修改操作立即寫入主存;

3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。

變量在有了volatile修飾之后,對變量的修改會有一個內存屏障的保護,使得后面的指令不能被重排序到內存屏障之前的位置。 volalite變量的讀性能與普通變量類似,但是寫性能要低一些,因為它需要插入內存屏障指令來保證處理器不會發生亂序執行。 即便如此,大多數場景下volatile的總開銷仍然要比鎖低,所以volatile的語義能滿足需求時候,選擇volatile要優于使用鎖。

參考資料:

Java 理論與實踐: 正確使用 Volatile 變量

深入理解Java內存模型(四)——volatile

Java多線程:volatile變量、happens-before關系及內存一致性

Java內存模型與volatile關鍵字

Java并發編程:volatile關鍵字解析

來自: http://souly.cn/技術博文/2016/04/11/Java中的volatile關鍵字/

 本文由用戶 最愛芒果 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!