Java volatile關鍵字解析

image

volatile簡介

volatile被稱爲輕量級的synchronized,運行時開銷比synchronized更小,在多線程併發編程中發揮着同步共享變量禁止處理器重排序的重要做用。建議在學習volatie以前,先看一下Java內存模型《什麼是Java內存模型?》,由於volatile和Java內存模型有着莫大的關係。html

Java內存模型

在學習volatie以前,須要補充下Java內存模型的相關(JMM)知識,咱們知道Java線程的全部操做都是在工做區進行的,那麼工做區和主存之間的變量是怎麼進行交互的呢,能夠用下面的圖來表示。java

Java經過幾種原子操做完成 工做區內存主存的交互

  1. lock:做用於主存,把變量標識爲線程獨佔狀態。
  2. unlock:做用於主存,解除變量的獨佔狀態。
  3. read:做用於主存,把一個變量的值經過主存傳輸到線程的工做區內存。
  4. load:做用於工做區內存,把read操做傳過來的變量值儲存到工做區內存的變量副本中。
  5. use:做用於工做內存,把工做區內存的變量副本傳給執行引擎。
  6. assign:做用於工做區內存,把從執行引擎傳過來的值賦值給工做區內存的變量副本。
  7. store:做用於工做區內存,把工做區內存的變量副本傳給主存。
  8. write:做用於主存,把store操做傳過來的值賦值給主存變量。

8個操做每一個操做都是原子性的,可是幾個操做連着一塊兒就不是原子性了!c++

volatile原理

上面介紹了Java模型的8個操做,那麼這8個操做和volatile又有着什麼關係呢。面試

volatile的可見性

什麼是可見性,用一個例子來解釋,先看一段代碼,加入線程1先執行,線程2再執行編程

//線程1
boolean stop = false;
while (!stop) {
    do();
} 

//線程2
stop = true;
複製代碼

線程1執行後會進入到一個死循環中,當線程2執行後,線程1的死循環就必定會立刻結束嗎?答案是不必定,由於線程2執行完stop = true後,並不會立刻將變量stop的值true寫回主存中,也就是上圖中的assign執行完成以後,storewrite並不會隨着執行,線程1沒有當即將修改後的變量的值更新到主存中,即便線程2及時將變量stop的值寫回主存中了,線程1也沒有了解到變量stop的值已被修改而去主存中從新獲取,也就是線程1loadread操做並不會立刻執行形成線程1的工做區內存中的變量副本不是最新的。這兩個緣由形成了線程1的死循環也就不會立刻結束。
那麼如何避免上訴的問題呢?咱們可使用volatile關鍵字修飾變量stop,以下bash

//線程1
volatile boolean stop = false;
while (!stop) {
    do();
} 

//線程2
stop = true;
複製代碼

這樣線程1每次讀取變量stop的時候都會先去主存中獲取變量stop最新的值,線程2每次修改變量stop的值以後都會立刻將變量的值寫回主存中,這樣也就不會出現上述的問題了。多線程

那麼關鍵字volatie是如何作到的呢?volatie規定了上述8個操做的規則併發

  1. 只有當線程對變量執行的前一個操做load時,線程才能對變量執行use操做;只有線程的後一個操做是use時,線程才能對變量執行load操做。即規定了useloadread三個操做之間的約束關係,規定這三個操做必須連續的出現,保證了線程每次讀取變量的值前都必須去主存獲取最新的值
  2. 只有當前程對變量執行的前一個操做assign時,線程才能對變量執行store操做;只有線程的後一個操做是store時,線程才能對變量執行assign操做,即規定了assignstorewrite三個操做之間的約束關係,規定了這三個操做必須連續的出現,保證線程每次修改變量後都必須將變量的值寫回主存

volatile的這兩個規則,也正是保證了共享變量的可見性post

volatile的有序性

有序性即程序執行的順序按照代碼的前後順序執行,Java內存模型(JMM)容許編譯器和處理器對指令進行重排序,可是規定了as-if-serial語義,即保證單線程狀況下無論怎麼重排序,程序的結果不能改變,如學習

double pi = 3.14;  //A
double r = 1;     //B
double s = pi * r * r; //C
複製代碼

上面的代碼可能按照A->B->C順序執行,也有可能按照B->A->C順序執行,這兩種順序都不會影響程序的結果。可是不會以C->A(B)->B(A)的順序去執行,由於C語句是依賴於AB的,若是按照這樣的順序去執行就不能保證結果不變了(違背了as-if-serial)。

上面介紹的是單線程的執行,無論指令怎麼重排序都不會影響結果,可是在多線程下就會出現問題了。
下面看個例子

double pi = 3.14;
double r = 0;
double s = 0;
boolean start = false;
//線程1
r = 10; //A
start = true; //B

//線程2
if (start) {  //C
    s = pi * r * r;  //D
}
複製代碼

線程1和線程2同時執行,線程1AB的執行順序多是A->B或者B->A(由於A和B之間沒有依賴關係,能夠指令重排序)。若是線程1按照A->B的順序執行,那麼線程2執行後的結果s就是咱們想要的正確結果,若是線程1按照B->A的順序執行,那麼線程2執行後的結果s可能就不是咱們想要的結果了,由於線程1將變量stop的值修改成true後,線程2立刻獲取到stoptrue而後執行C語句,而後執行D語句即s = 3.14 * 0 * 0,而後線程1再執行B語句,那麼結果就是有問題了。

那麼爲了解決這個問題,咱們能夠在變量true加上關鍵字volatile

double pi = 3.14;
double r = 0;
double s = 0;
volatile boolean start = false;
//線程1
r = 10; //A
start = true; //B

//線程2
if (start) {  //C
    s = pi * r * r;  //D
}
複製代碼

這樣線程1的執行順序就只能是A->B了,由於關鍵字發揮了禁止處理器指令重排序的做用,因此線程2的執行結果就不會有問題了。

那麼volatile是怎麼實現禁止處理器重排序的呢?
編譯器會在編譯生成字節碼的時候,在加有volatile關鍵字的變量的指令進行插入內存屏障來禁止特定類型的處理器重排序
咱們先看內存屏障有哪些及發揮的做用

image

  1. StoreStore屏障:禁止屏障上面變量的寫和下面全部進行寫的變量進行處理器重排序。
  2. StoreLoad屏障:禁止屏障上面變量的寫和下面全部進行讀的變量進行處理器重排序。
  3. LoadLoad屏障:禁止屏障上面變量的讀和下面全部進行讀的變量進行處理器重排序。
  4. LoadStore屏障:禁止屏障上面變量的讀和下面全部進行寫的變量進行處理器重排序。

再看volatile是怎麼插入屏障的

  1. 在每一個volatile變量的寫前面插入一個StoreStore屏障。
  2. 在每一個volatile變量的寫後面插入一個StoreLoad屏障。
  3. 在每一個volatile變量的讀後面插入一個LoadLoad屏障。
  4. 在每一個volatile變量的讀後面插入一個LoadStore屏障。

注意:寫操做是在volatile先後插入一個內存屏障,而讀操做是在後面插入兩個內存屏障。

volatile變量經過插入內存屏障禁止了處理器重排序,從而解決了多線程環境下處理器重排序的問題

volatile有沒有原子性?

上面分別介紹了volatile的可見性和有序性,那麼volatile有原子性嗎?咱們先看一段代碼

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
複製代碼

咱們開啓10個線程對volatile變量進行自增操做,每一個線程對volatile變量執行1000次自增操做,那結果變量inc會是10000嗎?答案是,變量inc的值基本都是小於10000
可能你會有疑問,volatile變量inc不是保證了共享變量的可見性了嗎,每次線程讀取到的都是最新的值,是的沒錯,可是線程每次將值寫回主存的時候並不能保證主存中的值沒有被其餘的線程修過過

若是所示:線程1在主存中獲取了i的最新值(i=1),線程2也在主存中獲取了i的最新值(i=1,注意這時候線程1並未對變量i進行修改,因此i的值仍是1)),而後線程2將i自增後寫回主存,這時候主存中i=2,到這裏尚未問題,而後線程1又對i進行了自增寫回了主存,這時候主存中i=2,也就是對i作了2次自增操做,結果i的結果只自增了1,問題就出來了這裏。

爲何會有這個問題呢,前面咱們提到了Java內存模型和主存之間交互的8個操做都是原子性的,可是他們的操做連在一塊兒就不是原子性了,而volatile關鍵字也只是保證了useloadread三個操做連在一塊兒時候的原子性,還有assignstorewrite這三個操做連在一塊兒時候的原子性,也就是volatile關鍵字保證了變量讀操做的原子性和寫操做的原子性,而變量的自增過程須要對變量進行讀和寫兩個過程,而這兩個過程連在一塊兒就不是原子性操做了。

因此說volatile變量對於變量的單獨寫操做/讀操做是保證了原子性的,而常說的原子性包括讀寫操做連在一塊兒,因此說對於volatile不保證原子性的。那麼如何解決上面程序的問題呢?只能給increase方法加鎖,讓在多線程狀況下只有一個線程能執行increase方法,也就是保證了一個線程對變量的讀寫是原子性的。固然還有個更優的方案,就是利用讀寫都爲原子性的CAS,利用CASvolatile進行操做,既解決了volatile不保證原子性的問題,同時消耗也沒加鎖的方式大

volatile和CAS

學完volatile以後,是否是以爲volatileCAS有種似曾相識的感受?那它們之間有什麼關係或者區別呢。

  1. volatile只能保證共享變量的讀和寫操做單個操做的原子性,而CAS保證了共享變量的讀和寫兩個操做一塊兒的原子性(即CAS是原子性操做的)。
  2. volatile的實現基於JMM,而CAS的實現基於硬件。

參考

Java併發編程:volatile關鍵字解析
JAVA併發六:完全理解volatile
Java內存模型與volatile
Java面試官最愛問的volatile關鍵字

原文地址:ddnd.cn/2019/03/19/…

相關文章
相關標籤/搜索