1. 程式人生 > >【搞定Java併發程式設計】第8篇:volatile關鍵字詳解

【搞定Java併發程式設計】第8篇:volatile關鍵字詳解

上一篇:Java記憶體模型詳解:https://blog.csdn.net/pcwl1206/article/details/84871090

目  錄

1、volatile的作用

1.1、volatile的可見性

1.2、volatile禁止指令重排序

2、volatile寫-讀建立的happens-before關係

3、volatile寫-讀的記憶體語義

4、volatile的記憶體語義


1、volatile的作用

volatile 在併發程式設計中很常見,但也容易被濫用,現在我們就分析下 volatile 關鍵字的語義。volatile 是Java虛擬機器提供的輕量級的同步機制

。volatile 關鍵字有如下兩個作用

1、保證被 volatile 修飾的共享變數對所有執行緒總數可見的,也就是當一個執行緒修改了一個被 volatile 修飾共享變數的值,新值總數可以被其他執行緒立即得知;

2、禁止指令重排序優化。 

1.1、volatile的可見性

關於volatile的可見性作用,我們必須意識到被volatile修飾的變數對所有執行緒總是立即可見的,對volatile變數的所有寫操作總是能立刻反應到其他執行緒中的,但是對於volatile變數運算操作在多執行緒環境並不保證安全性,如下:

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述程式碼所示,i 變數的任何改變都會立馬反應到其他執行緒中。但是如果存在多條執行緒同時呼叫 increase() 方法的話,就會出現執行緒安全問題,畢竟 i++ 操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成。如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取 i 的域值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗。因此對於 increase() 方法必須使用 synchronized 修飾,以便保證執行緒安全,需要注意的是一旦使用 synchronized 修飾方法後,由於synchronized 本身也具備與volatile相同的特性,即可見性,因此在這樣種情況下就完全可以省去 volatile 修飾變數。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

現在來看另外一種場景,可以使用volatile修飾變數達到執行緒安全的目的,如下:

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close = true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由於對於boolean變數close值的修改屬於原子性操作,因此可以通過使用volatile修飾變數close,使用該變數對其他執行緒立即可見,從而達到執行緒安全的目的。

那麼JMM是如何實現讓volatile變數對其他執行緒立即可見的呢?實際上,當寫一個volatile變數時,JMM會把該執行緒對應的工作記憶體中的共享變數值重新整理到主記憶體中,當讀取一個volatile變數時,JMM會把該執行緒對應的工作記憶體置為無效,那麼該執行緒將只能從主記憶體中重新讀取共享變數。volatile變數正是通過這種寫-讀方式實現對其他執行緒可見(但其記憶體語義實現則是通過記憶體屏障,稍後會說明)。

1.2、volatile禁止指令重排序

volatile關鍵字另一個作用就是禁止指令重排優化,從而避免多執行緒環境下程式出現亂序執行的現象,關於指令重排優化上篇文章中已經詳細分析過,這裡主要簡單說明一下volatile是如何實現禁止指令重排優化的。先了解一個概念,記憶體屏障(Memory Barrier)。 

記憶體屏障:又稱記憶體柵欄是一個CPU指令。它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變數的記憶體可見性(利用該特性實現volatile的記憶體可見性)。由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說通過插入記憶體屏障禁止在記憶體屏障前後的指令執行重排序優化。Memory Barrier的另外一個作用是強制重新整理各種CPU的快取資料,因此任何CPU上的執行緒都能讀取到這些資料的最新版本。總之,volatile變數正是通過記憶體屏障實現其在記憶體中的語義,即可見性和禁止重排優化。下面看一個非常典型的禁止重排優化的例子DCL,如下:

public class DoubleCheckLock {

	private static DoubleCheckLock instance;
	
	private DoubleCheckLock(){
	
	}
	
	public static DoubleCheckLock getInstance(){
		
		// 第一次檢查
		if(instance == null){
			// 同步
			synchronized (DoubleCheckLock.class) {
				if(instance == null){
					// 多執行緒環境下可能會出現問題的地方
					instance = new DoubleCheckLock();
				}
			}
		}
		return instance;
	}
}

上述程式碼一個經典的單例的雙重檢測的程式碼。這段程式碼在單執行緒環境下並沒有什麼問題,但如果在多執行緒環境下就可能出現執行緒安全問題。原因在於某一個執行緒執行到第一次檢測,讀取到的 instance 不為null時,instance的引用物件可能沒有完成初始化。因為 instance = new DoubleCheckLock() 可以分為以下3步完成(虛擬碼):

memory = allocate(); //1.分配物件記憶體空間
instance(memory);    //2.初始化物件
instance = memory;   //3.設定instance指向剛分配的記憶體地址,此時instance!=null

由於步驟1和步驟2間可能會重排序,如下:

memory = allocate(); //1.分配物件記憶體空間
instance = memory;   //3.設定instance指向剛分配的記憶體地址,此時instance!=null,但是物件還沒有初始化完成!
instance(memory);    //2.初始化物件

由於步驟2和步驟3不存在資料依賴關係,而且無論重排前還是重排後程序的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證序列語義的執行的一致性(單執行緒),但並不會關心多執行緒間的語義一致性。所以當一條執行緒訪問 instance 不為null時,由於 instance 例項未必已初始化完成,也就造成了執行緒安全問題。那麼該如何解決呢?很簡單,我們使用 volatile 禁止 instance 變數被執行指令重排優化即可。

//禁止指令重排優化
private volatile static DoubleCheckLock instance;

2、volatile寫-讀建立的happens-before關係

從JSR-133開始,volatile 變數的寫-讀可以實現執行緒之間的通訊。

從記憶體語義的角度來說,volatile與監視器鎖有相同的效果:volatile寫和監視器的釋放有相同的記憶體語義;volatile讀與監視器的獲取有相同的記憶體語義。

請看下面使用volatile變數的示例程式碼:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
 
    public void writer() {
        a = 1;                     // 1
        flag = true;               // 2
    }
 
    public void reader() {
        if (flag) {                // 3
            int i =  a;            // 4
            ……
        }
    }
}

假設執行緒A執行writer()方法之後,執行緒B執行reader()方法。根據happens before規則,這個過程建立的happens before 關係可以分為三類:

  1. 根據程式次序規則,1 happens before 2; 3 happens before 4。
  2. 根據volatile規則,2 happens before 3。
  3. 根據happens before 的傳遞性規則,1 happens before 4。

上述happens before 關係的圖形化表現形式如下:

在上圖中,每一個箭頭連結的兩個節點,代表了一個happens before 關係。黑色箭頭表示程式順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens before保證。

這裡A執行緒寫一個volatile變數後,B執行緒讀同一個volatile變數。A執行緒在寫volatile變數之前所有可見的共享變數在B執行緒讀同一個volatile變數後,將立即變得對B執行緒可見。


3、volatile寫-讀的記憶體語義

volatile寫的記憶體語義如下:

  • 當寫一個 volatile 變數時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體。

以上面示例程式 VolatileExample 為例,假設執行緒A首先執行 writer() 方法,隨後執行緒B執行 reader() 方法,初始時兩個執行緒的本地記憶體中的 flag 和 a 都是初始狀態。下圖是執行緒A執行 volatile 寫後,共享變數的狀態示意圖:

如上圖所示,執行緒A在寫 flag 變數後,本地記憶體A中被執行緒A更新過的共享變數的值被重新整理到主記憶體中。此時,本地記憶體A和主記憶體中的共享變數的值是一致的。

volatile讀的記憶體語義如下:

  • 當讀一個 volatile 變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

下面是執行緒B讀同一個volatile變數後,共享變數的狀態示意圖:

如上圖所示,在讀 flag 變數時,本地記憶體B已經被置為無效。此時,執行緒B必須從主記憶體中讀取共享變數。執行緒B的讀取操作將導致本地記憶體B與主記憶體中的共享變數的值也變成一致的了。

如果我們把 volatile 寫和 volatile 讀這兩個步驟綜合起來看的話。線上程B讀一個 volatile 變數後和執行緒A在寫這個volatile 變數之前所有可見的共享變數的值都將立即變得對讀執行緒B可見。

下面對volatile寫和volatile讀的記憶體語義做個總結:

1、執行緒A寫一個 volatile 變數時,實質上是執行緒A向接下來將要讀這個 volatile 變數的某個執行緒發出了(其對共享變數所在修改的)訊息;

2、執行緒B讀一個 volatile 變數,實質上是執行緒B接收了之前某個執行緒發出的(在寫這個volatile變數之前對共享變數所做修改的)訊息;

3、執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。


4、volatile的記憶體語義

下面,讓我們來看看JMM如何實現 volatile 寫/讀的記憶體語義。

前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現 volatile 記憶體語義,JMM會分別限制這兩種型別的重排序型別。下面是JMM針對編譯器制定的 volatile 重排序規則表:

舉例來說,第三行最後一個單元格的意思是:在程式順序中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作

從上表我們可以看出:

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

為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略

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

上述記憶體屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程式中都能得到正確的 volatile 記憶體語義。

下面是保守策略下,volatile 寫插入記憶體屏障後生成的指令序列示意圖:

上圖中的 StoreStore 屏障可以保證在 volatile 寫之前,其前面的所有普通寫操作已經對任意處理器可見了。這是因為StoreStore 屏障將保障上面所有的普通寫在 volatile 寫之前重新整理到主記憶體。

這裡比較有意思的是 volatile 寫後面的 StoreLoad 屏障。這個屏障的作用是避免 volatile 寫與後面可能有的 volatile 讀/寫操作重排序。因為編譯器常常無法準確判斷在一個 volatile 寫的後面,是否需要插入一個 StoreLoad 屏障(比如,一個volatile 寫之後方法立即 return)。為了保證能正確實現 volatile 的記憶體語義,JMM在這裡採取了保守策略:在每個 volatile 寫的後面或在每個 volatile 讀的前面插入一個 StoreLoad 屏障。從整體執行效率的角度考慮,JMM選擇了在每個 volatile 寫的後面插入一個 StoreLoad 屏障。因為 volatile 寫-讀記憶體語義的常見使用模式是:一個寫執行緒寫 volatile 變數,多個讀執行緒讀同一個 volatile 變數。當讀執行緒的數量大大超過寫執行緒時,選擇在 volatile 寫之後插入 StoreLoad 屏障將帶來可觀的執行效率的提升。從這裡我們可以看到JMM在實現上的一個特點:首先確保正確性,然後再去追求執行效率。

下面是在保守策略下,volatile 讀插入記憶體屏障後生成的指令序列示意圖:

上圖中的 LoadLoad 屏障用來禁止處理器把上面的 volatile 讀與下面的普通讀重排序。LoadStore 屏障用來禁止處理器把上面的 volatile 讀與下面的普通寫重排序。

上述 volatile 寫和 volatile 讀的記憶體屏障插入策略非常保守。在實際執行時,只要不改變 volatile 寫-讀的記憶體語義,編譯器可以根據具體情況省略不必要的屏障。下面我們通過具體的示例程式碼來說明:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
 
    void readAndWrite() {
        int i = v1;           // 第一個volatile讀
        int j = v2;           // 第二個volatile讀
        a = i + j;            // 普通寫
        v1 = i + 1;           // 第一個volatile寫
        v2 = j * 2;           // 第二個 volatile寫
    }
 
    …                         // 其他方法
}

針對 readAndWrite() 方法,編譯器在生成位元組碼時可以做如下的優化:

注意,最後的 StoreLoad 屏障不能省略。因為第二個 volatile 寫之後,方法立即 return。此時編譯器可能無法準確斷定後面是否會有 volatile 讀或寫,為了安全起見,編譯器常常會在這裡插入一個 StoreLoad 屏障。

上面的優化是針對任意處理器平臺,由於不同的處理器有不同“鬆緊度”的處理器記憶體模型,記憶體屏障的插入還可以根據具體的處理器記憶體模型繼續優化。以x86處理器為例,上圖中除最後的 StoreLoad 屏障外,其它的屏障都會被省略。

前面保守策略下的 volatile 讀和寫,在 x86處理器平臺可以優化成:

前文提到過,x86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀,讀-寫和寫-寫操作做重排序,因此在x86處理器中會省略掉這三種操作型別對應的記憶體屏障。在x86中,JMM僅需在 volatile 寫後面插入一個 StoreLoad 屏障,即可正確實現volatile寫-讀的記憶體語義。這意味著在x86處理器中,volatile 寫的開銷比 volatile 讀的開銷會大很多(因為執行StoreLoad屏障開銷會比較大)。

  • JSR-133為什麼要增強volatile的記憶體語義

在JSR-133之前的舊Java記憶體模型中,雖然不允許 volatile 變數之間重排序,但舊的Java記憶體模型允許 volatile 變數與普通變數之間重排序。在舊的記憶體模型中,VolatileExample示例程式可能被重排序成下列時序來執行:

在舊的記憶體模型中,當1和2之間沒有資料依賴關係時,1和2之間就可能被重排序(3和4類似)。其結果就是:讀執行緒B執行4時,不一定能看到寫執行緒A在執行1時對共享變數的修改。

因此在舊的記憶體模型中 ,volatile的寫-讀沒有監視器的釋放-獲所具有的記憶體語義。為了提供一種比監視器鎖更輕量級的執行緒之間通訊的機制,JSR-133專家組決定增強 volatile 的記憶體語義:嚴格限制編譯器和處理器對 volatile 變數與普通變數的重排序,確保 volatile 的寫-讀和監視器的釋放-獲取一樣,具有相同的記憶體語義。從編譯器重排序規則和處理器記憶體屏障插入策略來看,只要 volatile 變數與普通變數之間的重排序可能會破壞 volatile 的記憶體語意,這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止。

由於 volatile 僅僅保證對單個 volatile 變數的讀/寫具有原子性,而監視器鎖的互斥執行的特性可以確保對整個臨界區程式碼的執行具有原子性。在功能上,監視器鎖比 volatile 更強大;在可伸縮性和執行效能上,volatile 更有優勢。如果讀者想在程式中用 volatile 代替監視器鎖,請一定謹慎。


上一篇:Java記憶體模型詳解:https://blog.csdn.net/pcwl1206/article/details/84871090

本文內容主要轉發自以下文章中的內容:

1、全面理解Java記憶體模型(JMM)及volatile關鍵字

2、併發三大問題與volatile關鍵字,CAS操作

3、volatile關鍵字