Java併發編程之Volatile

該文章屬於《Java併發編程》系列文章,若是想了解更多,請點擊《Java併發編程之總目錄》編程

前言

在前面的文章中,咱們已經瞭解了Java的內存模型,瞭解了其可見性問題及指令重排序及Happen-Before原則,如今咱們來了解一下關鍵字volatile。在Java中volatile能夠算是Java提供的輕量級同步實現機制,可是在平時開發中,咱們更多的是使用synchronized來進行同步。對於volatile,你們老是不能正確的且完整的理解。因此下面,我就和你們一塊兒來了解一下volatile。緩存

volatile的做用

線程的可見性

當一個變量定義爲volatile後,那麼該變量對全部線程都是「可見的」,其中「可見的」是指當一條線程修改了這個變量的值,那麼新值對於其餘線程來講是能夠當即知道的。可能你們仍是很差的理解。若是你閱讀過上篇文章Java併發編程之Java內存模型,你應該很快的理解。不過沒有大礙,經過下列圖片你們應該很快的瞭解。安全

volatile可見性.png

咱們已經知道在Java內存模型中,內存分爲了線程的工做內存及主內存。在上圖中,線程A與線程B分別從主內存中獲取變量a(用volatile修飾)到本身的工做內存中,也就是如今線程A與線程B中工做內存中的a如今的變量爲12,當線程A修改a的值爲8時,會將修改後的值(a=8)同步到主內存中,同時那麼會致使線程B中的緩存a變量的值(a=12)無效,會讓線程B從新重主內存中獲取新的值(a=8)bash

volatile可見性的原理

在上篇文章Java併發編程之Java內存模型中咱們曾經講過,物理計算機爲了處理緩存不一致的問題。提出了緩存一致性的協議,其中緩存一致性的核心思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。多線程

既然volatile修飾的變量能具備「可見性」,那麼volatile內部確定是走的底層,同時也確定知足緩存一致性原則。由於涉及到底層彙編,這裏咱們不要去了解彙編語言,咱們只要知道當用volatile修飾變量時,生成的彙編指令會比普通的變量聲明會多一個Lock指令。那麼Lock指令會在多核處理器下會作兩件事情。併發

  • 將當前處理器緩存行的數據直接寫會到系統內存中(從Java內存模型來理解,就是將線程中的工做內存的數據直接寫入到主內存中)
  • 這個寫回內存的操做會使在其餘CPU裏緩存了該地址的數據無效(從Java內存模型理解,當線程A將工做內存的數據修改後(新值),同步到主內存中,那麼線程B從主內存中初始的值(舊值)就無效了)

防止重排序

一樣的在上篇文章Java併發編程之Java內存模型中,咱們提到了爲了提升CPU(處理器)的處理數據的速度,CPU(處理器)會對沒有數據依賴性的指令進行重排序,可是CPU(處理器)的重排序會對多線程帶來問題。具體問題咱們用下列僞代碼來闡述:app

public class Demo {
    private int a = 0;
    private boolean isInit = false;
    private Config config;

    public void init() {
        config = readConfig();//1
        isInit = true;//2
    }
    public void doSomething() {
        if (isInit) {//3
            doSomethingWithconfig();//4
        }
    }
}
複製代碼

isInit用來標誌是否已經初始化配置。其中1,2操做是沒有數據依賴性,同理三、4操做也是沒有數據依賴性的。那麼CPU(處理器)可能對一、2操做進行重排序。對三、4操做進行重排序。如今咱們加入線程A操做Init()方法,線程B操做doSomething()方法,那麼咱們看看重排序對多線程狀況下的影響。post

程序執行順序.png

上圖中2操做排在了1操做前面。當CPU時間片轉到線程B。線程B判斷 if (isInit)爲true,接下來接着執行 doSomethingWithconfig(),可是咱們Config尚未初始化。因此在多線程的狀況下。重排序會影響程序的執行結果。因此爲了防止重排序帶來的問題。Java內存模型規定了使用volatile來修飾相應變量時,能夠防止CPU(處理器)在處理指令的時候禁止重排序。具體以下圖所示。優化

public class Demo {
    private int a = 0;
    private volatile boolean isInit = false;
    private Config config;
	 public void init() {
        config = readConfig();//1
        isInit = true;//2
    }
    public void doSomething() {
        if (isInit) {//3
            doSomethingWithconfig();//4
        }
    }
}
複製代碼

volatile防止重排序規則

那麼爲了處理CPU重排序的問題。Java定義瞭如下規則防止CPU的重排序。ui

volatile重排序規則.png

從上表咱們能夠看出

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

volatile防止重排序原理

爲了具體實現上訴咱們提到的重排序規則,在Java中對於volatile修飾的變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序問題。在瞭解內存屏障以前,咱們先複習以前的主內存與工做內存交互的8種原子操做,由於內存屏障主要是對Java內存模型的幾種原子操做進行限制的。具體內存8種原子操做,以下圖所示:

8種操做.png
上述8中原子操做中,咱們所涉及的是store與load操做,若是須要了解剩餘6種操做,請參看上篇文章 Java併發編程之Java內存模型

這裏對內存屏障所涉及到的兩種操做進行解釋:

  • load:做用於工做內存的變量,它把read操做從主內存中獲得的變量值放入到工做內存變量副本中。
  • store:做用於工做內存的變量,它把工做內存中一個變量值傳送到主內存中。以便隨後的write操做。
內存屏障插入策略

下面是基於volatile修飾的變量,編譯器在指令序列插入的內存屏障保守插入策略以下:

  • 在每一個volatile寫操做的前面插入一個storestore屏障。
  • 在每一個volatile寫操做的後面插入一個storeload屏障。
  • 在每一個volatile讀操做的後面插入一個loadload屏障。
  • 在每一個volatile讀操做的後面插入一個loadstore屏障。

volatile寫內存屏障

volatile寫屏障.png

  • storestore屏障:對於這樣的語句store1; storestore; store2,在store2及後續寫入操做執行前,保證store1的寫入操做對其它處理器可見。(也就是說若是出現storestore屏障,那麼store1指令必定會在store2以前執行,CPU不會store1與store2進行重排序)
  • storeload屏障:對於這樣的語句store1; storeload; load2,在load2及後續全部讀取操做執行前,保證store1的寫入對全部處理器可見。(也就是說若是出現storeload屏障,那麼store1指令必定會在load2以前執行,CPU不會對store1與load2進行重排序)

volatile讀內存屏障

volatile讀屏障.png

  • loadload屏障:對於這樣的語句load1; loadload; load2,在load2及後續讀取操做要讀取的數據被訪問前,保證load1要讀取的數據被讀取完畢。(也就是說,若是出現loadload屏障,那麼load1指令必定會在load2以前執行,CPU不會對load1與load2進行重排序)
  • loadstore屏障:對於這樣的語句load1; loadstore; store2,在store2及後續寫入操做被刷出前,保證load1要讀取的數據被讀取完畢。(也就是說,若是出現loadstore屏障,那麼load1指令必定會在store2以前執行,CPU不會對load1與store2進行重排序)

編譯器內存屏障的優化

上面咱們講到了在插入內存屏障時,編譯器若是採用保守策略的狀況下,分別會在volatile寫與volatile讀插入不一樣的內存屏障,那如今咱們來看一下,在實際開發中,編譯器在使用內存屏障時的優化。

public class VolatileBarrierDemo {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    public void readAndWrite() {
        int i = v1;//第一個volatile讀
        int j = v2;//第二個volatile讀
        a = i + j;//普通寫
        v1 = i + 1;//第一個volatile寫
        v2 = j * 2;//第二個volatile寫
    }
}
複製代碼

那麼針對上述代碼,咱們生成相應的屏障(圖片在手機端觀看可能會不太清除,建議在pc端上觀看)

屏障優化圖.png

觀察上圖,咱們發現,在編譯器生成屏障時,省略了第一個volatile讀下的loadstore屏障,省略了第二個volatile讀下的loadload屏障,省略了第一個volatile寫下的storeload屏障。結合上訴咱們所講的loadstore屏障、loadload屏障、storeload屏障下的語義,咱們能獲得省略如下屏障的緣由。

  • 省略第一個volatile讀下的loadstore屏障:由於第一個volatile讀下的下一個操做是第二個volatile的讀,並不涉及到寫的操做(也就是store)。因此能夠省略。
  • 省略第二個volatile讀下的loadload屏障:由於第二個volatile讀的下一個操做是普通寫,並不涉及到讀的操做(也就是load)。因此能夠省略
  • 省略第一個volatile寫下的storeload屏障:由於第一個volatile寫的下一個操做是第二個volatile的寫,並不涉及到讀的操做(也就是load)。因此能夠省略。

其中你們要注意的是,優化結束後的storeload屏障時不能省略的,由於在第二個volatile寫以後,方法理解return,此時編譯器可能沒法肯定後面是否會有讀寫操做,爲了安全起見,編譯器一般會在這裏加入一個storeload屏障。

處理器內存屏障的優化

上面咱們講了編譯器在生成屏障的時候,會根據程序的邏輯操做省略沒必要要的內存屏障。可是因爲不一樣的處理器有不一樣的「鬆耦度」的內存模型,內存屏障的優化根據不一樣的處理器有着不一樣的優化方式。以x86處理器爲例。針對咱們上面所描述的編譯器內存屏障優化圖。在x86處理器中,除最後的storeload屏障外,其餘的屏障都會省略。

x86處理器優化後.png

x86處理器與其餘處理器的內存屏障的優化,這裏不過的描述,有興趣的小夥伴能夠查閱相關資料繼續研究。

volatile的使用注意事項

在volatile使用的時候,須要注意volatile只保證可見性,並不能保證原子性,這裏所提到的原子性須要給你們補充一個知識點。

原子性定義

在Java中,對基本的數據類型的變量的訪問和讀寫操做都是原子性操做,且這些操做在CPU中不能夠在中途暫停而後再調度,既不被中斷操做,要不執行完成,要不就不執行。

直接經過定義來理解確實比較困難,經過下面這個例子,讓咱們一塊兒來了解。

x = 10;         //語句1
x++;           //語句2
x = x + 1;     //語句3
複製代碼

你們能夠來猜一猜,以上3個語句有哪些是具備原子性呢。好了。我告訴答案吧,只有語句1具備原子性。你們對此會感到很疑惑。

  • 對於語句1:是直接將數值10賦給x,也就是直接將數值10賦值到工做內存中
  • 對於語句2:先去讀取x的值,而後計算x加上1後的值,最後將計算後的值賦值給x,
  • 對於語句3:同語句2。

對於語句2,3由於涉及到多個操做,且在多線程的狀況下,CPU能夠進行時間片的切換操做(也就是能夠暫停在某個操做後)。那麼就可能出現線程安全的問題。

volatile爲何不具有原子性

描述了原子性後,相信你們都會有個疑問「volatile不具有原子性有什麼關係呢?其實緣由很簡單,雖然volatile是具有可見性的(也就是指當一條線程修改了這個變量的值,那麼新值對於其餘線程來講是能夠當即知道的),可是對於該變量有可能有多個操做例如上文提到的x++。那麼在有多個操做的狀況下,CPU任然能夠先暫停而後在調度的。既然能被暫停後繼續在調度,那麼volatile確定是不具有原子性的了。

volatile的使用場景

如今咱們已經瞭解了volatile的相關特性,那麼就來講說,volatile的具體使用場景,由於volatie變量只能保證可見性,並不能保證原子性,因此在輕量級線程同步中咱們可使用volatile關鍵字。可是有兩個前提條件:

  • 第一個條件:運算結果並不依賴變量的當前值,或者可以確保只有單一的線程修改變量的值。
  • 第二個條件:變量不須要與其餘的狀態變量共同參與不變約束。

直接理解上述兩個條件,可能會有點困難,下面分別對着兩個前提條件進行解釋:

針對第一個條件

volatile int a  = 0;
	//在多線程狀況下錯誤,在單線程狀況下正確的方式
    public void doSomeThingA() {
    //在單線程狀況下,不會出現線程安全的問題,正確
	//在多線程狀況下,a最終的值依賴於當前a的值,錯誤
         a++;     
    }
    //正確的使用方式
    public void doSomeThingB() {
	    //不論是在單線程仍是多線程的狀況下,都不會出現線程安全的問題
		if(a==0){
		 a = 1;
		}
    }
複製代碼

在上述僞代碼中,咱們能明確的看出,只要volatile修飾的變量不涉及與運算結果的依賴,那麼不論是在多線程,仍是單線程的狀況下,都是正確的。固然我這裏只是將a變量定義成成int,對於其餘剩下的基礎類型數據也是適用的。

針對第二個條件

其實理解第二個條件,你們能夠反過來理解,即便用volatile的變量不能包含在其餘變量的不變式中,下面僞代碼將會經過反例說明:

private volatile int lower;
    private volatile int upper;  
  
    public void setLower(int value) {   
        if (value > upper)   
            throw new IllegalArgumentException(...);  
        lower = value;  
    }  
  
    public void setUpper(int value) {   
        if (value < lower)   
            throw new IllegalArgumentException(...);  
        upper = value;  
    }  
}
複製代碼

在上述代碼中,咱們明顯發現其中包含了一個不變式 —— 下界老是小於或等於上界(也就是lower<=upper)。那麼在多線程的狀況下,兩個線程在同一時間使用不一致的值執行 setLower 和 setUpper 的話,則會使範圍處於不一致的狀態。例如,若是初始狀態是(0, 5),同一時間內,線程 A 調用setLower(4) 而且線程 B 調用setUpper(3),顯然這兩個操做交叉存入的值是不符合條件的,那麼兩個線程都會經過用於保護不變式的檢查,使得最後的範圍值是(4, 3)。很顯然這個結果是錯誤的。

總結

  • volatile具備可見性不具備原子性,同時能防止指令重排序。
  • volatile之因此具備可見性,是由於底層中的Lock指令,該指令會將當前處理器緩存行的數據直接寫會到系統內存中,且這個寫回內存的操做會使在其餘CPU裏緩存了該地址的數據無效。
  • volatile之因此能防止指令重排序,是由於Java編譯器對於volatile修飾的變量,會插入內存屏障。內存屏障會防止CPU處理指令的時候重排序的問題
相關文章
相關標籤/搜索