JUC之Java併發基礎篇——搞懂volatile

本文首發自個人博客:blog.prcode.org ,歡迎你們點擊。java

本文連接: blog.prcode.org/2018/04/JUC…編程

版權聲明: 本博客全部文章除特別聲明外,均採用 CC BY-NC-SA 3.0 許可協議。轉載請註明出處!緩存

做爲 Java 的關鍵字,volatile 雖然沒有 synchronized 出現的頻率高,可是在 Java 源碼中仍是會常常出現的,尤爲是 JUC 當中,好比 AbstractQueuedSynchronizer 。那麼,volatile 到底意味着什麼,做用是什麼?簡而言之,有兩點,其一是保證了內存可見性,其二是禁止指令重排序。多線程

內存可見性

緩存問題

Java內存模型規定了全部的變量都存儲在主內存中,同時每一個線程還有本身的工做內存。線程的工做內存中保存了該線程使用到的從主內存拷貝的副本變量,線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存中的變量。不一樣線程之間沒法直接訪問對方工做內存中的變量,線程間變量值的傳遞均須要在主內存來完成。併發

這樣就致使了一個問題:線程1修改了共享變量的值,沒來得急寫入主存,或者寫入主存了線程2並未從主存刷新數據,這樣線程2拿到的數據就是過時數據,即內存可見性問題app

線程、主內存和工做內存的交互關係以下圖所示:ide

線程緩存

舉例

雖然說對於可見性問題說的頭頭是道,可是全是理論。那麼怎麼證實這個現象的存在呢?看下面的例子。性能

public class DemoRunnable implements Runnable {

    private boolean flag = false;
    public DemoRunnable() {
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        int i = 0;
        System.out.println("====== start ===========");
        while (!flag) {
            i++;
        }
        System.out.println("====== end ===== , i == " + i);
    }

    public static void main(String[] args) throws InterruptedException {
        DemoRunnable demo = new DemoRunnable();
        new Thread(demo).start();
        System.out.println("sleep to let demo thread run first");
        TimeUnit.MILLISECONDS.sleep(10);
        demo.setFlag(true);
    }
}
複製代碼

對於上面的代碼,若是主線程修改了變量的值,demo 線程能夠馬上發現的話,程序會正常結束,產生以下的輸出:優化

====== start ===========this

sleep done let demo thread run first

====== end ===== , i == xxxxxxx

實際上並不是如此,程序進入了死循環,沒法退出。這就說明了一個線程修改了共享變量,另外一個線程可能不會當即看到。極端狀況下,數據可能一直都不會被看到。

禁止指令重排序

指令重排序,即在執行程序時,爲了提升性能,編譯器和處理器會對指令作一些優化。而 volatile 則能夠禁止某些指令重排序。

對於重排序,舉個例子,好比作飯。有一種流程是,洗菜 -> 炒菜 -> 淘米 -> 煮飯。可是爲了提升效率,咱們能夠這麼作:淘米 -> 煮飯 -> 趁煮飯的時間洗菜炒菜。這樣一來,就能夠省了很多時間。在這裏面,有依賴關係的不能重排序,好比煮飯依賴於已經淘米了。這些步驟就是一些指令,咱們本身就是處理器。

更多關於指令重排序與 happens-before 請參考以前的博客: JUC之Java併發基礎篇——指令重排與happens-before

因爲 volatile 能夠禁止指令重排序,因此,對於文中的例子,若是不想出現結果 0, 0 ,只須要將變量 int a, b 使用 volatile 修飾便可。

原子性

volatile 是否能保證原子性,通常有兩種說法。一個是說能保證原子性,只要修飾的變量在賦值時和自己無關。一種說法是不能保證原子性。本文認爲 volatile 並不能保證被修飾變量的賦值操做原子性。可看如下代碼:

public class VolatileDemo {
    private volatile int count;
    public static void main(String[] args) throws InterruptedException {
        int num = 1000;
        int count = 0;
        VolatileDemo demo = new VolatileDemo();
        do {
            count++;
            demo.count = 0;
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < num; i++) {
                    demo.count++;
                }
            });
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < num; i++) {
                    demo.count++;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
        } while (demo.count == 2 * num);
        System.out.println("第" + count + "次,跳出循環,demo.count = " + demo.count);
    }
}
複製代碼

若是能夠保證原子性的話,能夠預見,上面的程序會是一個死循環,沒法跳出。可是實際結果呢,出現瞭如下狀況:

第22次,跳出循環,demo.count = 1622

count++ 實際上等同於 count = count + 1 ,這不是一個步驟,是分三步的:取原值、計算、賦值。volatile 保證了內存可見性,可是是保證了在取變量值的時候,取的是最新的值。在計算及賦值時,對應的值是否仍是最新的,這點是不保證的。

volatile 的解決問題之道

內存屏障

內存屏障(英語:Memory barrier),也稱內存柵欄,內存柵障,屏障指令等,是一類同步屏障指令,是CPU或編譯器在對內存隨機訪問的操做中的一個同步點,使得此點以前的全部讀寫操做都執行後才能夠開始執行此點以後的操做。

大多數現代計算機爲了提升性能而採起亂序執行,這使得內存屏障成爲必須。

參考: 維基百科-內存屏障

下載列出了內存屏障的四種類型。

屏障類型 指令示例 說明
LoadLoad Barriers Load1;LoadLoad;Load2 該屏障確保Load1數據的裝載先於Load2及其後全部裝載指令的的操做
StoreStore Barriers Store1;StoreStore;Store2 該屏障確保Store1馬上刷新數據到內存(使其對其餘處理器可見)的操做先於Store2及其後全部存儲指令的操做
LoadStore Barriers Load1;LoadStore;Store2 確保Load1的數據裝載先於Store2及其後全部的存儲指令刷新數據到內存的操做
StoreLoad Barriers Store1;StoreLoad;Load2 該屏障確保Store1馬上刷新數據到內存的操做先於Load2及其後全部裝載裝載指令的操做。它會使該屏障以前的全部內存訪問指令(存儲指令和訪問指令)完成以後,才執行該屏障以後的內存訪問指令

StoreLoad Barriers同時具有其餘三個屏障的效果,所以也稱之爲全能屏障,是目前大多數處理器所支持的,可是相對其餘屏障,該屏障的開銷相對昂貴。

volatile 內存語義

當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值當即刷新到主內存中,並通知其它線程,使其它線程的變量副本無效。

當讀一個volatile變量時,JMM會把該線程對應的本地內存設置爲無效,從主內存中讀取共享變量。

volatile 實現

在重排序一文中,咱們有提到,重排序分爲編譯器重排序和處理器重排序。

對於編譯器

爲了實現 volatile 的內存語義,JMM 會限制 volatile 的重排序,以下表。

可否重排序 第二個操做
第一個操做 普通讀/寫 volatile 讀 volatile 寫
普通讀/寫 no
volatile 讀 no no no
volatile 寫 no no
  • 當第一個操做是 volatile 讀時,不論第二個操做是什麼,都不能重排序
  • 當第二個操做是 volatile 寫時,不論第一個操做是什麼,都不能重排序
  • 當第一個操做是 volatile 寫,第二個操做是 volatile 讀時,不容許重排序

對於處理器

爲了實現 volatile 的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障。這樣,處理器在執行指令時就不會進行優化處理。

  • 在每一個 volatile 寫操做前面插入一個 StoreStore 屏障。禁止了 volatile 寫與前一個有可能的寫重排序,同時保證了內存可見性。

  • 在每一個 volatile 寫操做後面插入一個 StoreLoad 屏障。禁止了 volatile 寫與後一個有可能的讀重排序,同時保證了內存可見性。

  • 在每一個 volatile 讀操做後面插入一個 LoadLoad 屏障。禁止了 volatile 讀與後續有可能的讀重排序。

  • 在每一個 volatile 讀操做後面插入一個 LoadStore 屏障。禁止了 volatile 讀與後續有可能的寫重排序。

注:參考 《Java併發編程的藝術》(方騰飛)

volatile 應用

狀態標記

因爲 volatile 保證了內存可見性,因此可用於修飾共享變量。可是,因爲其不具有原子性,爲了保證多線程狀況下不出問題,最好來修飾賦值能夠一步完成的變量。好比,狀態標記。

public class DemoRunnable implements Runnable {

    private volatile boolean flag = false;
    public DemoRunnable() {
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        int i = 0;
        System.out.println("====== start ===========");
        while (!flag) {
            i++;
        }
        System.out.println("====== end ===== , i == " + i);
    }

    public static void main(String[] args) throws InterruptedException {
        DemoRunnable demo = new DemoRunnable();
        new Thread(demo).start();
        System.out.println("sleep to let demo thread run first");
        TimeUnit.MILLISECONDS.sleep(10);
        demo.setFlag(true);
    }
}
複製代碼

單例模式雙重鎖

單例模式的懶漢模式,若是不當心的話是會出錯的。如下代碼是一份不會出問題的代碼。

public class SingletonDemo {
    private static volatile SingletonDemo INSTANCE;//標記3
    private SingletonDemo() {}//標記1
    public static SingletonDemo getINSTANCE() {
        if (INSTANCE == null) {
            synchronized (SingletonDemo.class) {
                if (INSTANCE == null) {//標記2
                    INSTANCE = new SingletonDemo();
                }
            }
        }
        return INSTANCE;
    }
}
複製代碼

注1:構造方法必須私有化,避免外部再 new 出新對象。

注2:著名的 double-check ,若是不進行二次判斷的話,極可能多個線程同時得出第一個 INSTANCE == NULL 的結論,而後依次進入同步代碼塊,這樣就會致使 INSTANCE 會被從新 new。

注3:INSTANCE 必須使用 volatile 進行修飾,由於 new 一個對象不是一步完成的,指令重排可能會使某線程拿到的是半個對象。

new 一個對象,可能出現下面兩種步驟:

順序1 順序2
1. 申請內存 1. 申請內存
2. 初始化屬性 2. 指向對象
3. 指向對象 3. 初始化屬性

也就是說,指令重排,可能會出現如下這種狀況

線程1(順序2初始化) 線程2
1. 執行 INSTANCE = new SingletonDemo()
2. 申請內存
3. 指向對象
4. 第一個 INSTANCE == null 判斷
5. INSTANCE != null
6. 拿到一個假對象,因此是半個
7. 完成對象屬性初始化

能夠看出,在指令重排時,是有可能出現併發問題的。因此,INSTANCE 要用 volatile 修飾,在禁止重排序後,使用順序1就不會出現這個問題。

總結

  • volatile 的做用主要有兩點,一個是保證了內存可見性,一個是禁止指令重排序
  • volatile 並不能保證原子性,使用 volatile 修改的變量,在賦值時必定不要和自己原來的值相關
  • volatile 的內存語義是經過插入內存屏障來實現的
  • volatile 可用於修飾狀態標記變量
  • 單例模式的懶漢模式,變量要用 volatile 來修飾,這樣來禁止指令重排序
相關文章
相關標籤/搜索