Java併發編程學習系列七:深刻了解volatile關鍵字

前言volatile的使用volatile保證可見性volatile沒法保證原子性volatile禁止指令重排volatile的原理可見性實現禁止指令重排序擴展volatile修飾對象和數組總結參考文獻html

前言

volatile 這個關鍵字可能不少朋友都據說過,它有兩個重要的特性:可見性和禁止指令重排序。可是對於 volatile 的使用以及背後的原理咱們一無所知,因此本文將帶你好好了解一番。java

因爲 volatile 關鍵字是與 Java的內存模型有關的,所以在講述 volatile 關鍵以前,咱們先來了解一下與內存模型相關的概念和知識,原本想總結寫一篇 JMM 的文章,可是在網上看到一篇總結的很好的文章,因此此處推薦你們閱讀一下Java併發編程學習系列六:JMM,而後介紹 volatile 關鍵字的使用,最後詳解 volatile 關鍵字的原理。廢話很少說,咱們直接進入正文。程序員

volatile的使用

一旦一個共享變量(類的成員變量、類的靜態成員變量)被 volatile 修飾以後,那麼就具有了兩層語義:web

  1. 保證了不一樣線程對這個變量進行操做時的可見性,即一個線程修改了某個變量的值,這新值對其餘線程來講是當即可見的。
  2. 禁止進行指令重排序。

volatile保證可見性

先看一段代碼,假如線程A先執行,線程B後執行: 編程

public class VolatitleTest {

    private static boolean stopRequested = false;

    public static void main(String[] args) throws InterruptedException {
        int n = 0;
        Thread thread1 = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        },"A");

        Thread thread2 = new Thread(() -> {
            stopRequested = true;
        },"B");

        thread1.start();
        TimeUnit.SECONDS.sleep(1);    //爲了演示死循環,特地sleep一秒
        thread2.start();

    }
}
複製代碼

這段代碼是很典型的一段代碼,不少人在中斷線程時可能都會採用這種標記辦法。可是事實上,這段代碼會徹底運行正確麼?即必定會將線程中斷麼?不必定,也許在大多數時候,這個代碼可以把線程中斷,可是也有可能會致使沒法中斷線程(雖然這個可能性很小,可是隻要一旦發生這種狀況就會形成死循環了)。segmentfault

下面解釋一下這段代碼爲什麼有可能致使沒法中斷線程。在前面已經解釋過,每一個線程在運行過程當中都有本身的工做內存,那麼線程A在運行的時候,會將 stopRequested 變量的值拷貝一份放在本身的工做內存當中。數組

那麼當線程B更改了 stopRequested 變量的值以後,可是還沒來得及寫入主存當中,線程B轉去作其餘事情了,那麼線程A因爲不知道線程B對 stopRequested 變量的更改,所以還會一直循環下去。緩存

上述代碼將 stopRequested 定義爲 volatile,就變成了典型的狀態標記量案例。安全

當一個變量被定義成 volatile 以後,它將具有如下特性:保證此變量對全部線程的可見性,這裏的「 可見性」是指當一條線程修改了這個變量的值,新值對於其餘線程來講是能夠當即得知的。具體而言就是說,volatile 關鍵字能夠保證直接從主存中讀取一個變量,若是這個變量被修改後,老是會被寫回到主存中去。Java 內存模型是經過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存做爲傳遞媒介的方式來實現可見性的。多線程

普通變量與 volatile 變量的區別是:volatile 的特殊規則保證了新值能當即同步到主內存,以及每一個線程在每次使用 volatile 變量前都當即從主內存刷新。所以咱們能夠說 volatile 保證了多線程操做時變量的可見性,而普通變量則不能保證這一點。

在本例中,線程B更改了 stopRequested 變量的值以後,新值會被當即回寫到主存中,線程A再次讀取 stopRequested 變量時要去主存讀取。

關於 volatile 變量的可見性,常常會被開發人員誤解,他們會誤覺得下面的描述是正確的:「 volatile 變量對全部線程是當即可見的,對 volatile 變量全部的寫操做都能馬上反映到其餘線程之中。換句話說,volatile 變量在各個線程中是一致的,因此基於 volatile 變量的運算在併發下是線程安全的」。這句話的論據部分並無錯,可是由其論據並不能得出「 基於 volatile 變量的運算在併發下是線程安全的」這樣的結論。Java 裏面的運算操做符並不是原子操做,這致使 volatile 變量的運算在併發下同樣是不安全的。

volatile沒法保證原子性

在 JMM 一文中提到 volatile 不能保證原子性,接下來咱們經過案例進行分析。

public class VolatileAddNum {
    static volatile int count = 0;

    public static void main(String[] args) {
        VolatileAddNum obj = new VolatileAddNum();
        Thread t1 = new Thread(() -> {
            obj.add();
        },"A");

        Thread t2 =new Thread(() -> {
            obj.add();
        },"B");

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();

            System.out.println("main線程輸入結果爲==>" + num);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public void add() {
        for (int i = 0; i < 100000; i++) {
            count++;
        }
    }
}
複製代碼

上面這段代碼作的事情很簡單,開了 2 個線程對同一個共享整型變量分別執行十萬次加1操做,咱們指望最後打印出來 count 的值爲200000,但事與願違,運行上面的代碼,count 的值是極有可能不等於 20萬的,並且每次運行結果都不同,老是小於 20萬。爲何會出現這個狀況呢?

自增操做是不具有原子性的,它包括讀取變量的原始值、進行加1操做、寫入工做內存。那麼就是說自增操做的三個子操做可能會分割開執行,就有可能致使下面這種狀況出現:

假如某個時刻變量 count 的值爲10,

線程A對變量進行自增操做,線程A先讀取了變量 count 的原始值,而後線程A被阻塞了(可能存在的狀況);

而後線程B對變量進行自增操做,線程B也去讀取變量 count 的原始值,因爲線程A只是對變量 count 進行讀取操做,而沒有對變量進行修改操做,因此主存中 count 的值未發生改變,此時線程B會直接去主存讀取 count 的值,發現 count 的值爲10,而後進行加1操做,並把11寫入工做內存,最後寫入主存。

而後線程A接着進行加1操做,因爲已經讀取了 count 的值,注意此時在線程A的工做內存中 count 的值仍然爲10,因此線程A對 count 進行加1操做後 count 的值爲11,而後將11寫入工做內存,最後寫入主存。

那麼兩個線程分別進行了一次自增操做後,inc只增長了1。

解釋到這裏,可能有朋友會有疑問,不對啊,前面不是保證一個變量在修改 volatile 變量時,新值對於其餘線程來講是能夠當即得知的?對,這個沒錯。這個就是上面的 happens-before 規則中的 volatile 變量規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。可是要注意,線程A對變量進行讀取操做以後,被阻塞了的話,並無對 count 值進行修改。而後雖然 volatile 能保證線程B對變量 count 的值讀取是從內存中讀取的,可是線程A沒有進行修改,因此線程B根本就不會看到修改的值。

根源就在這裏,自增操做不是原子性操做,並且 volatile 也沒法保證對變量的任何操做都是原子性的。

把上面的代碼改爲如下任何一種均可以達到效果:

Synchronized 關鍵字,僞碼以下:

    public synchronized void add() {
        for (int i = 0; i < 100000; i++) {
            num ++;
        }
    }
複製代碼

Lock 鎖,代碼以下:

public static volatile int num = 0;
Lock lock = new ReentrantLock();

public synchronized void add() {
    lock.lock();
    try {
        for (int i = 0; i < 100000; i++) {
            num ++;
        }
    } finally {
        lock.unlock();
    }
複製代碼

除了上述兩種方案,咱們還能夠採用 AtomicInteger 來完成加法操做。

public class VolatileAddNum {
    public static int num = 0;
    public AtomicInteger inc = new AtomicInteger();

    public static void main(String[] args) {
        VolatileAddNum obj = new VolatileAddNum();
        Thread t1 = new Thread(() -> {
            obj.add();
        },"A");

        Thread t2 =new Thread(() -> {
            obj.add();
        },"B");

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();

            System.out.println("main線程輸入結果爲==>" + obj.inc);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public void add() {
        for (int i = 0; i < 100000; i++) {
//            num ++;
            inc.getAndIncrement();
        }
    }
}
複製代碼

在 JDK1.5的 java.util.concurrent.atomic 包下提供了一些原子操做類,即對基本數據類型的 自增(加1操做),自減(減1操做)、以及加法操做(加一個數),減法操做(減一個數)進行了封裝,保證這些操做是原子性操做。

AtomicInteger 類主要利用 CAS (compare and swap) + volatile 和 native 方法來保證原子操做,從而避免 synchronized 的高開銷,執行效率大爲提高。 CAS 其實是利用處理器提供的CMPXCHG 指令實現的,而處理器執行 CMPXCHG 指令是一個原子性操做。

volatile禁止指令重排

在前面提到 volatile 關鍵字能禁止指令重排序,因此 volatile 能在必定程度上保證有序性。

volatile 關鍵字禁止指令重排序有兩層意思:

  • 當程序執行到 volatile 變量的讀操做或者寫操做時,在其前面的操做的更改確定所有已經進行,且結果已經對後面的操做可見;在其後面的操做確定尚未進行;
  • 在進行指令優化時,不能將在對 volatile 變量訪問的語句放在其後面執行,也不能把 volatile 變量後面的語句放到其前面執行。

咱們從一個最經典的例子來分析重排序問題。你們應該都很熟悉單例模式的實現,而在併發環境下的單例實現方式,咱們一般能夠採用雙重檢查加鎖(DCL)的方式來實現。其源碼以下:

public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 構造函數私有,禁止外部實例化
     */

    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
複製代碼

如今咱們分析一下爲何要在變量 singleton 之間加上 volatile 關鍵字。要理解這個問題,先要了解對象的構造過程,實例化一個對象其實能夠分爲三個步驟:

(1)分配內存空間。

(2)初始化對象。

(3)將內存空間的地址賦值給對應的引用。

可是因爲操做系統能夠對指令進行重排序,因此上面的過程也可能會變成以下過程:

(1)分配內存空間。

(2)將內存空間的地址賦值給對應的引用。

(3)初始化對象

若是是這個流程,多線程環境下就可能將一個未初始化的對象引用暴露出來,從而致使不可預料的結果。所以,爲了防止這個過程的重排序,咱們須要將變量設置爲 volatile 類型的變量。

volatile的原理

可見性實現

在前文中已經說起過,線程自己並不直接與主內存進行數據的交互,而是經過線程的工做內存來完成相應的操做。這也是致使線程間數據不可見的本質緣由。 以下圖所示:

img
img

volatile 保證此變量對全部線程的可見性,這裏的「 可見性」是指當一條線程修改了這個變量的值,新值對於其餘線程來講是能夠當即得知的。

底層緣由:

volatile 使用 Lock 前綴的指令禁止線程本地內存緩存,保證不一樣線程之間的內存可見性

在瞭解 JMM 的相關知識後,咱們知道 JVM 爲了提升處理速度,處理器不直接和主內存進行通訊,而是先將主內存的數據讀到內部緩存(L1,L2或其餘)後再進行操做,但操做完不知道什麼時候會將緩存中的數據寫回到主內存。若是對聲明瞭 volatile 的變量進行寫操做,JVM 就會向處理器發送一條 Lock 前綴的指令,將這個變量所在緩存行的數據會當即寫回到主內存。可是,就算寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題。因此在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操做的時候,會從新從主內存中把數據讀處處理器緩存裏。

Lock 前綴的指令在多核處理器下會引起了兩件事情:

  • 將當前處理器緩存行的數據寫回到主內存。
  • 一個處理器的緩存回寫到主內存會致使其餘處理器的緩存無效。

理解 volatile 特性的一個好方法是把對 volatile 變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。從內存語義的角度來講,volatile 的寫-讀與鎖的釋放-獲取有相同的內存效果:volatile 寫和鎖的釋放有相同的內存語義;volatile 讀與鎖的獲取有相同的內存語義——這使得 volatile 變量的寫-讀能夠實現線程之間的通訊。

volatile的內存語義:

  • volatile 寫的內存語義:當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存
  • volatile 讀的內存語義:當讀一個volatile變量時,JMM 會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

volatile寫 - 讀的內存語義:

  • 線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所作修改的)消息。
  • 線程B讀一個volatile變量,實質上是線程B接收了以前某個線程發出的(在寫這個volatile變量以前對共享變量所作修改的)消息。
  • 線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A經過主內存向線程B發送消息。

以下圖所示:

img
img

禁止指令重排序

在 JMM 一文中有說起編譯器和處理器關於重排序的內容,單線程環境下因爲遵照數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操做的執行順序,也就不會出現錯誤。可是多線程環境下,重排序可能會致使沒法獲取準確的數據。

首先咱們來看下指令重排序對內存可見性的影響:

img
img

當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4相似)。這樣的結果就是:讀線程B執行4時,不必定能看到寫線程A在執行1時對共享變量的修改。

volatile禁止指令重排序語義的實現關鍵在於內存屏障。

重排序可能會致使多線程程序出現內存可見性問題。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,經過內存屏障指令來禁止特定類型的處理器重排序。經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。

img
img

StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。現代的多處理器大多支持該屏障(其餘類型的屏障不必定被全部處理器支持)。執行該屏障開銷會很昂貴,由於當前處理器一般要把寫緩衝區中的數據所有刷新到內存中(Buffer Fully Flush)。

JMM針對編譯器制定volatile重排序規則表:

img
img

  • 當第一個操做是 volatile 讀時,無論第二個操做是什麼,都不能重排序。這個規則確保 volatile 讀以後的操做不會被編譯器重排序到volatile讀以前。
  • 當第一個操做是 volatile 寫,第二個操做是 volatile 讀時,不能重排序
  • 當第二個操做是 volatile 寫時,無論第一個操做是什麼,都不能重排序。這個規則確保 volatile 寫以前的操做不會被編譯器重排序到 volatile 寫以後。

爲了實現 volatile 的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。

下面是基於保守策略的 JMM 內存屏障插入策略:

  • 在每一個 volatile 寫操做的前面插入一個 StoreStore 屏障。
  • 在每一個 volatile 寫操做的後面插入一個 StoreLoad 屏障。
  • 在每一個 volatile 讀操做的後面插入一個 LoadLoad 屏障。
  • 在每一個 volatile 讀操做的後面插入一個 LoadStore 屏障。

從編譯器重排序規則和處理器內存屏障插入策略來看,只要 volatile 變量與普通變量之間的重排序可能會破壞volatile 的內存語義(內存可見性),這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。

擴展

volatile修飾對象和數組

volatile 修飾對象和數組時,只是保證其引用地址的可見性。

以下述代碼所示,nums 加了 volatile以後下面的代碼會立刻打印「結束」,若是不給數組加 volatile 就永遠不會打印。

public class VolatileWork {

    static volatile int[] nums = new int[5];

    public static void main(String[] args) {
        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            nums[0] = 2;
        },"A").start();

        new Thread(()->{
            while (true){
//                int i = num;
                if (nums[0] == 2) {
                    System.out.println("結束");
                    break;
                }
//                System.out.println("waiting");
            }
        },"B").start();
    }
}
複製代碼

首先須要瞭解的一點是:數組存放在主內存中,當線程訪問該對象時,會將數組引用複製一份到線程的工做內存,甚至有可能將 nums[0] 複製到工做內存中,參考《深刻理解Java虛擬機》 以下敘述:

根據 volatile 可見性的實現原理分析,咱們知道當執行 nums[0] = 2;語句時,數組引用會回寫到主內存中,而且致使線程B工做內存中關於數組引用的緩存行失效,從而致使從新從主內存中讀取。可是有一點須要注意的是:nums 引用和 nums[0] 不位於同一緩存行中,因此沒法保證 nums[0] 在線程之間的可見性。

爲了測試多線程狀況下,沒法實時讀取 nums[0] 的最新值,咱們利用下面代碼進行演示:

public class VolatileWork {

    static volatile int[] nums = new int[5];

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            nums[0] = 2;
            System.out.println("寫入成功");
        }, "A");

        t1.start();


        for (int i = 0; i < 500; i++) {
            new Thread(() -> {
                if (nums[0] != 2){
                    System.out.println(nums[0]);
                }
            }).start();
        }

    }
}
複製代碼

屢次執行上述代碼,觀察結果變化,最後發現有這麼一種狀況:

也許我這種測試方式不正確,但只是想證實 volatile 修飾數組時,並不會保證數組元素在線程之間的可見性。一樣能夠這點的是 ConcurrentHashMap,在 ConcurrentHashMap(1.8)中,內部使用一個 volatile 的數組 table保存數據,細心的同窗能夠發現,Doug Lea 每次在獲取數組的元素時,採用 Unsafe 類的 getObjectVolatile 方法,在設置數組元素時,採用 compareAndSwapObject 方法,而不是直接經過下標去操做。這是什麼緣由呢?

網上看到文章裏是這樣總結的:由於 Java 數組在元素層面的元數據設計上的缺失,沒法表達元素是 final、volatile 等語義,因此開了後門,使用 getObjectVolatile 用來補上沒法表達元素是 volatile 的坑,@Stable用來補上 final 的坑,數組元素就跟沒有標 volatile 的成員字段同樣,沒法保證線程之間可見性。

關於 volatile 修飾對象一樣存在這麼一個狀況,因此除了要當心對待。

此外在網上看到這樣一個案例,有興趣的朋友能夠去了解一下,R大親自回答,講解的很是詳細。

import java.util.concurrent.TimeUnit;

public class ThreadTest {
   private static boolean stopRequested;

   public static void main(String[] args) throws InterruptedException {
      Thread backgroundThread = new Thread(new Runnable() {
         public void run() {
            int i = 0;
            while (!stopRequested){
               i++;
               //這段System.out語句會致使線程結束,緣由?
               System.out.println(i);
            }
         }
      });
      backgroundThread.start();
      TimeUnit.SECONDS.sleep(1);
      stopRequested = true;
   }

}
複製代碼

System.out語句會引發線程結束,若是去掉System.out語句,線程是永遠不會結束的

總結

開始研究 volatile 源於在學習 CopyOnWriteArrayList 類中的 add 方法,在該方法中將數組複製了一份,而後增長完新值以後,而後再覆蓋原數組。這個數組被 volatile 修飾,當時看的那篇文章中博主說了這麼一句話「 若是將 array 數組設定爲 volitile 的, 對 volatile 變量寫 happens-before 讀,讀線程不是可以感知到 volatile 變量的變化。 」我當時只是簡單知道 volatile 的兩個特性,僅限於口頭上了解,對於 happens-before 原則也不清晰,而後我就在網上查看相關資料,一步一步去了解,最後瞭解到 JMM,而後到 JMM 下的線程間通訊,以及 volatile 的使用及背後原理。這一路看下來內容仍是比較多的,某一點不理解,就要去網上查資料或者看相關書籍,由此我也明白了一個道理:單純的去看書,很容易疲勞,帶着問題去讀,每句每字都會用心去看,更利於加深我的理解。

上面的內容不少都是從網上和書上整理出來的,目前我也只是對 volatile 有個基本的瞭解,但願能對你們有所幫助,若是文中內容有錯誤,望不吝賜教。在後續的學習中會常常遇到它,好比線程安全類以及 Spring 源碼。整體來講,volatile 是併發編程中的一種優化,在某些場景下能夠代替 Synchronized。可是,volatile 的不能徹底取代 Synchronized 的位置,只有在一些特殊的場景下,才能適用volatile。總的來講,加鎖機制既能夠保證可見性又能夠確保原子性,而 volatile 變量能夠確保可見性和禁止指令重排。因此當變量的寫操做屬於原子操做時,才能夠單獨使用 volatile,咱們常見的狀態標記量案例。關於禁止指令重排,比較典型的就是單例實現中的雙重檢查鎖,有興趣的朋友能夠去閱讀一下這位朋友寫的單例模式文章內容。

參考文獻

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

Java 併發編程:volatile的使用及其原理

java volatile數組,個人測試結果與預期不符

如何保證數組元素的可見性

《Java併發編程實戰》

《深刻理解Java虛擬機》

《Java併發編程的藝術》

相關文章
相關標籤/搜索