1. 程式人生 > >java volatile關鍵字總結

java volatile關鍵字總結

之前就看過很多關於volatile的資料,本文是作者對volatile關鍵字的一些總結,在這裡先感謝《java記憶體模型》的作者程曉明。

目錄

java關鍵字volatile總結

關於volatile修飾的變數,虛擬機器做出如下保證:

  • 執行緒的可見性
  • 禁止指令的重排序

執行緒的可見性

java記憶體模型(簡稱JMM)規定了所有的變數都儲存在主存中,每個執行緒都有自己的工作記憶體,工作記憶體中儲存了主存中對應變數的拷貝,對變數的修改是在工作記憶體中完成,然後同步至主存中。JMM模型如圖:
這裡寫圖片描述

由上述可以得出,多個執行緒對主存中同一普通變數的修改,是存在”可見性”問題的,也就是指在一個執行緒中對變數修改後,其他執行緒不一定及時知道。而虛擬機器會保證對於volatile的變數,修改是對其他執行緒立即可見的。那麼虛擬機器是如何做到這一點的呢?
在JMM中定義了八種操作來實現工作記憶體與主存的互動,這些操作都是原子操作,期間不會發生其他的執行緒切換:

  • Lock:將主存中的變數標記為一條執行緒獨佔狀態;
  • Unlock:將鎖定的變數釋放;
  • Read:將主存中的變數傳輸到工作記憶體中;
  • Load:把read操作接收到的變數值放入工作記憶體的變數副本中;
  • Use:把工作記憶體中的值傳遞給執行引擎;
  • Assign:把從執行引擎中接收到的值賦值給工作記憶體中的變數;
  • Store:把工作記憶體中的變數傳遞至主存;
  • Write:將store接收到的變數的值賦值給主存中的變數;

在虛擬機器中,對於volatile有如下規則,假設T表示一個執行緒,P和Q表示兩個volatile變數,在進行上面描述的操作時:

  • 只有當T對P執行的前一個動作是load時,T才能對P執行use動作,並且只有T對P執行的後一個動作是use時,T才能對P進行load操作;這樣就保證執行引擎每次在使用變數之前,都會從主存中讀取最新的值。
  • 只有當T對P執行的前一個動作是assign時,T才能對P進行store操作,並且只有T對P執行的後一個動作是store時,T才能對P執行assign;這樣就保證每次工作記憶體中的值修改後,會馬上寫入主存中。
  • 保證volatile的重排序規則(下文會有說明)

既然虛擬機器對volatile變數做了這麼多規定,這樣可以保證volatile修飾的變數就是執行緒安全的嗎?看例子:

package test;

import java.util.concurrent.CountDownLatch;

public class Test {

    public static volatile
int num = 0; private static CountDownLatch end = new CountDownLatch(20); public static void addNum() { num++; } public static void main(String[] args) { for(int i = 0; i < 20; i++) { new Thread(new Runnable() { @Override public void run() { try { for(int i = 0; i < 10000; i++) { addNum(); } } finally { end.countDown(); } } }).start(); } try { end.await(); System.out.println(num); } catch (InterruptedException e) { e.printStackTrace(); } } }

說明:20個執行緒,每個執行緒對num進行10000次自增操作,如果volatile是執行緒安全的,那執行完所有執行緒後應輸出200000,但結果每次輸出都不同,但都小於200000.
但是虛擬機器不是規定對volatile變數的操作會對其他執行緒立即可見嗎?怎麼還會輸出錯誤的結果呢?原因是:對num的操作 num++其實是一個複合操作而不是原子操作,也就是說,在執行num++時,會出現”可見性”問題。為了便於理解,可以參照synchronized關鍵字:

public class SynaTest {

    private volatile int num;//volatile變數

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    public void add() {
        num++;
    }
}

等價於

public class SynaTest {

    private int num; //普通變數

    public synchronized int getNum() {
        return num;
    }

    public synchronized void setNum(int num) {
        this.num = num;
    }

    public void add() {
        int tmp = getNum();
        tmp = tmp+1;
        setNum(tmp);
    }
}

至此,關於第一點”對其他執行緒的可見”說完。

指令重排序

處理器和編譯器為提高效率,可能會對程式進行指令重排序,但我們不會意識到這種操作,因為重排序不會影響程式的輸出結果,當然,這裡不影響輸出結果只是在單執行緒中。那麼JMM是如何是volatile修飾的變數不會發生指令重排序呢?

先來說說記憶體屏障,在JMM中,記憶體屏障可以分為:

屏障型別 指令示例 說明
LoadLoad Load1;LoadLoad;Load2 確保Load1資料的裝載,之前於Load2及所有後續裝載指令的裝載
StoreStore Store1;StoreStore;Store2 確保Store1資料對其他處理器可見(重新整理到記憶體),之前於Store2及後續儲存指令的儲存
LoadStore Load1;LoadStore;Store2 確保Load1資料裝載,之前於Store2及後續的儲存指令
StoreLoad Store1;StoreLoad;Load2 確保Store1資料對其他處理器變得可見(重新整理到記憶體),之前於Load2及後續裝載指令的裝載。StoreLoad會使屏障之前的所有記憶體指令(儲存和裝載)完成之後,才執行該屏障之後的記憶體訪問指令

在JMM中,關於volatile的重排序規則定義如下:

  • 當第二個操作是volatile寫時,不論前一個操作是什麼,都不能進行重排序。
  • 當第一個操作是volatile讀時,不論後一個操作是什麼,都不能進行重排序。
  • 第一個操作是volatile寫,後一個操作是volatile讀時,不能進行重排序

為了實現上述三點,JMM採用插入記憶體屏障:

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

通過這幾個記憶體屏障,JMM就可以保證volatile語義:當寫一個volatile變數時,JMM會把該執行緒對應的工作記憶體中的值重新整理到主存中;檔讀一個volatile變數時,JMM會把工作記憶體中對應的變數值設為無效,從主存中獲取變數值。

通過上述的描述,可以看出其實volatile並不是” 執行緒安全”的,如果要保證同步,還需要額外的同步手段,比如通過synchronized關鍵字或者java.util.concurrent工具,但是volatile在某些情況下是非常適用的,比如只有單一執行緒對volatile變數進行寫操作:

public class VolaTest {

    volatile boolean stop = false;

    public void shutdown() {//呼叫該方法後,可以使所有執行緒的doWork立即停下來
        stop = true;
    }

    public void doWork() {
        while(!stop) {
            //...
        }
    }
}

如果有不對的地方,歡迎大家指正。