1. 程式人生 > 實用技巧 >Java 併發程式設計系列(Ⅱ):深入剖析volatile關鍵字

Java 併發程式設計系列(Ⅱ):深入剖析volatile關鍵字

語義

volatile關鍵字是Java虛擬機器提供的最輕量級的同步機制,volatile修飾的變數具備兩個特性:

  1. 保證此變數對所有執行緒的可見性。
  2. 禁止指令重排序優化。

實現原理

可見性

加鎖如何解決可見性問題?

因為某一個執行緒進入synchronized程式碼塊前後,執行緒會獲得鎖,清空工作記憶體,從主記憶體拷貝共享變數最新的值到工作記憶體成為副本,執行程式碼,將修改後的副本的值重新整理回主記憶體中,執行緒釋放鎖。

而獲取不到鎖的執行緒會阻塞等待,所以變數的值肯定一直都是最新的。

volatile如何解決可見性問題?

每個執行緒操作資料的時候會把資料從主記憶體讀取到自己的工作記憶體,如果操作了資料並且寫回主記憶體,則其他執行緒已經讀取的變數副本就會失效,需要再次去主記憶體中讀取。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,仍然要通過加鎖(使用synchronized、java.util.concurrent中的鎖或原子類)來保證原子性:

  • 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  • 變數不需要與其他的狀態變數共同參與不變約束。

指令重排序

指令重排序

為了提高效能,編譯器和處理器常常會對既定的程式碼執行順序進行指令重排序。一般重排序可以分為如下三種:

  • 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行的。

但是不管怎麼重排序,單執行緒下的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

volatile如何禁止指令重排序?

下面是一段標準的雙鎖檢測(Double Check Lock,DCL)單例程式碼,通過觀察加入volatile
和未加入volatile關鍵字時所生成的彙編程式碼的差別(如何獲得即時編譯的彙編程式碼?請參考附錄關於HSDIS外掛的介紹)。

public class Singleton {

    private volatile static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
	    synchronized (Singleton.class) {
		if (instance == null) {
		    instance = new Singleton();
		}
	    }
	}
	return instance;
    }
}

通過對比發現,關鍵變化在於有volatile修飾的變數,賦值後多執行了一個“lock addl $0x0,(%rsp)”操作,

這個操作的作用相當於一個記憶體屏障
(Memory Barrier或Memory Fence,指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置)。

IA-32架構軟體開發者手冊中規定,Lock字首的指令在多核處理器下會引發了兩件事情:

  1. 將當前處理器快取行的資料寫回到系統記憶體。
  2. 這個寫回記憶體的操作會使在其他CPU裡快取了該記憶體地址的資料無效。

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。

如果對聲明瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。

在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。

由此可見,Java編譯器會在生成指令系列時在適當的位置插入記憶體屏障指令來禁止特定型別的處理器重排序。

JMM針對編譯器制定volatile重排序規則表:

需要注意的是:volatile寫是在前面和後面分別插入記憶體屏障,而volatile讀操作是在後面插入兩個記憶體屏障。

  • 寫操作:

  • 讀操作:

使用場景

解決單例雙重檢查物件初始化程式碼執行亂序問題

建立物件步驟:

  • 分配記憶體空間
  • 呼叫構造器,初始化例項
  • 返回地址給引用

物件建立過程有可能發生指令重排序:在記憶體裡面開闢了一片儲存區域後直接返回記憶體的引用,這個時候還沒真正的初始化完物件,因而發生異常。使用volatile禁止指令重排可解決。

補充

volatile與synchronized的區別

  • volatile只能修飾例項變數和類變數,而synchronized可以用在變數、方法、類、以及程式碼塊。
  • volatile保證資料的可見性,但是不保證原子性(多執行緒進行寫操作,不保證執行緒安全); 而synchronized是一種排他(互斥)的機制,保證變數的修改可見性和原子性。
  • volatile不會造成執行緒阻塞。synchronized可能會造成執行緒阻塞。
  • volatile可以看做是輕量版的synchronized,volatile不保證原子性,但是如果是對一個共享變數進行多個執行緒的賦值,而沒有其他的操作,那麼就可以用volatile來代替synchronized,因為賦值本身是有原子性的,而volatile又保證了可見性,可以保證執行緒安全。

附錄

HSDIS 反彙編外掛

虛擬機器提供了一組通用的反彙編介面,可以接入各種平臺下的反彙編介面卡,64位x86平臺選用hsdis-amd64,下載後將其放置在JAVA_HOME/lib/amd64/server下,只要與jvm.dll或libjvm.so的路徑相同即可被虛擬機器呼叫。為虛擬機器安裝反彙編介面卡後,就可以使用-XX:+PrintAssembly引數要求虛擬機器列印編譯方法的彙編程式碼。

  1. 下載hsdis-amd64.dll放到JRE_HOME/bin/server路徑下
  2. 新增虛擬機器引數 -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Singleton.*並啟動

參考文獻: