Java volatile關鍵字解析

追雲的帆發表於2018-05-20
image

volatile簡介

volatile被稱為輕量級的synchronized,執行時開銷比synchronized更小,在多執行緒併發程式設計中發揮著同步共享變數禁止處理器重排序的重要作用。建議在學習volatie之前,先看一下Java記憶體模型《什麼是Java記憶體模型?》,因為volatile和Java記憶體模型有著莫大的關係。

Java記憶體模型

在學習volatie之前,需要補充下Java記憶體模型的相關(JMM)知識,我們知道Java執行緒的所有操作都是在工作區進行的,那麼工作區和主存之間的變數是怎麼進行互動的呢,可以用下面的圖來表示。

Java volatile關鍵字解析
Java通過幾種原子操作完成工作區記憶體主存的互動

  1. lock:作用於主存,把變數標識為執行緒獨佔狀態。
  2. unlock:作用於主存,解除變數的獨佔狀態。
  3. read:作用於主存,把一個變數的值通過主存傳輸到執行緒的工作區記憶體。
  4. load:作用於工作區記憶體,把read操作傳過來的變數值儲存到工作區記憶體的變數副本中。
  5. use:作用於工作記憶體,把工作區記憶體的變數副本傳給執行引擎。
  6. assign:作用於工作區記憶體,把從執行引擎傳過來的值賦值給工作區記憶體的變數副本。
  7. store:作用於工作區記憶體,把工作區記憶體的變數副本傳給主存。
  8. write:作用於主存,把store操作傳過來的值賦值給主存變數。

8個操作每個操作都是原子性的,但是幾個操作連著一起就不是原子性了!

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,如下

//執行緒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的這兩個規則,也正是保證了共享變數的可見性

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前後插入一個記憶體屏障,而讀操作是在後面插入兩個記憶體屏障。

Java 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不是保證了共享變數的可見性了嗎,每次執行緒讀取到的都是最新的值,是的沒錯,但是執行緒每次將值寫回主存的時候並不能保證主存中的值沒有被其他的執行緒修過過

Java volatile關鍵字解析

如果所示:執行緒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/…

相關文章