使用 volatile 關鍵字保證變數可見性和禁止指令重排序

低吟不作語發表於2020-10-17

volatile 概述

volatile 是 Java 提供的一種輕量級的同步機制。相比於傳統的 synchronize,雖然 volatile 能實現的同步性要差一些,但開銷更低,因為它不會引起頻繁的執行緒上下文切換和排程。


為了更好的理解 volatile 的作用,首先要了解一下 Java 記憶體模型與併發程式設計三要素


Java 記憶體模型

Java 虛擬機器規範中定義了 Java 記憶體模型(Java Memory Model,JMM),用於遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各種平臺下都能達到一致的併發效果。

JMM 規定了 Java 虛擬機器與計算機記憶體如何協同工作:一個執行緒如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數。注意這裡的變數是指例項欄位,靜態欄位,構成陣列物件的元素,不包括區域性變數和方法引數(因為這是執行緒私有的),可以簡單理解為主記憶體是 Java 虛擬機器記憶體區域中的堆,區域性變數和方法引數是在虛擬機器棧中定義的。如果堆中的變數在多執行緒中都被使用,就涉及到了堆和不同虛擬機器棧中變數的值的一致性問題了。

Java 記憶體模型中涉及到的概念有:

  • 主記憶體

    Java 虛擬機器規定所有的變數都必須在主記憶體中產生,該記憶體是執行緒公有的,為了方便理解,可以認為是堆區。

  • 工作記憶體

    Java 虛擬機器中每個執行緒都有自己的工作記憶體,該記憶體是執行緒私有的,為了方便理解,可以認為是虛擬機器棧。

Java 虛擬機器規定,執行緒對主記憶體變數的修改必須線上程的工作記憶體中進行,不能直接讀寫主記憶體中的變數。不同的執行緒之間也不能相互訪問對方的工作記憶體。如果執行緒之間需要傳遞變數的值,必須通過主記憶體來作為中介進行傳遞。


併發程式設計三要素

在併發程式設計中,以下三要素是我們經常需要考慮的:

  • 原子性

    原子是世界上最小的單位,具有不可分割性。同理,將一個操作或多個操作視為一個整體,它們是不可再分的,並且要麼全部成功,要麼全部失敗,那麼這個操作就具有原子性。

    int a = 10; //1
    a++; //2
    int b = a; //3
    a = a + 1; //4
    

    上面這四個語句中只有第 1 個語句是原子操作,將 10 賦值給執行緒工作記憶體的變數 a,而語句2(a++),實際上包含了三個操作:

    1. 讀取變數 a 的值
    2. 對 a 進行加一的操作
    3. 將計算後的值再賦值給變數 a,而這三個操作無法構成原子操作

    對語句 3,4 的分析同理可得這兩條語句不具備原子性。

  • 可見性

    指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。舉個簡單的例子:

    // 執行緒 1 執行的程式碼
    int i = 0;
    i = 10;
    //執行緒 2 執行的程式碼
    j = i;
    

    之前在 Java 記憶體模型已經講過,執行緒 1 執行 i = 10 時,會先把 i 的初始值載入到自己的工作記憶體,然後賦值為 10,卻沒有立即寫入到主存當中。此時執行緒 2 執行 j = i,它會先去主存讀取 i 的值並載入到自己的工作記憶體中,注意此時記憶體當中 i 的值還是 0,那麼就會使得 j 的值為 0,而不是 10。

    這就是可見性問題,執行緒 1 對變數 i 修改了之後,執行緒 2 沒有立即看到執行緒 1 修改的值。

  • 有序性

    程式的執行順序按照程式碼的先後順序執行。有序性從不同的角度來看是不同的,單純從單執行緒的角度來看,所有操作都是有序的,但到了多執行緒就不一樣了。可以這麼說:如果在本執行緒內部觀察,所有操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。


volatile 保證變數可見性

假如有 A、B 兩個執行緒,主記憶體有變數 i = 0,A 執行緒將主記憶體中的 i 拷貝一份到自己的工作記憶體,並修改為 i = 1,但並沒有立即寫回到主記憶體,什麼時候寫回主存是不確定的。此時 B 執行緒也將主記憶體中的 i 拷貝一份到自己的工作記憶體,而主記憶體中的 i 還是 0,並不是預想中的 1,這就可能導致一些問題。

volatile 的一個重要作用就是實現了變數可見性。當一個共享變數被 volatile 修飾,它會保證修改的值會立即更新到主存,當其他執行緒需要讀取時,它會去記憶體中讀取新值。


volatile 不保證原子性

假如有 A、B 兩個執行緒,同時對初始值為 0 的變數 i 做加 1 操作,我們希望最終的結果是 i = 2,但有可能並非如此,假設:

  • 執行緒 A 將共享記憶體 i = 0 拷貝到自己的工作記憶體,此時 A 的本地記憶體中 i = 1,但共享記憶體的 i 還是 0
  • 執行緒 B 將共享記憶體 i = 0 拷貝到自己的工作記憶體,此時 B 的本地記憶體中 i = 1,但共享記憶體的 i 還是 0
  • 執行緒 A 完成加 1 操作,此時 A 的本地記憶體中 i = 1,但共享記憶體的 i 還是 0,執行緒 A 將 i = 1 寫回到記憶體
  • 執行緒 B 完成加 1 操作,此時 B 的本地記憶體中 i = 1,共享記憶體的 i 已經是 1,執行緒 B 將 i = 1 寫回到記憶體
  • 最終共享記憶體中 i = 1,並不是我們預期的 i = 2

出現上述問題的原因是 i++ 並不是一個原子性的操作,Java 記憶體模型只保證了基本讀取和賦值是原子性操作。不同執行緒之間的操作互動執行,可能會出現漏洞。所以使用 volatile 必須具備以下兩個條件:

  • 對變數的寫操作不依賴於當前值
  • 該變數沒有包含在具有其他變數的不變式中

上述兩個條件其實就是要保證操作是原子性的。如果希望實現更大範圍操作的原子性,可以通過 synchronized 和 Lock 來實現。synchronized 和 Lock 能保證任一時刻只有一個執行緒執行該程式碼塊,自然就不存在原子性問題。

volatile 禁止指令重排序

所謂指令重排序,是指計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排。指令重排必須保證最終執行結果和程式碼順序執行結果一致。

public void mySort() {
	int x = 11;	// 1
	int y = 12; // 2
	x = x + 5;  // 3
	y = x * x;  // 4
}

正常的執行順序是 1、2、3、4,如果發生指令重排,就有可能會是 2、1、3、4,或者是 1、3、2、4 等等,但不會出現 4、3、2、1 這樣的情況,因為處理器在進行重排時,必須考慮到指令之間的資料依賴性。

在單執行緒下指令重排是沒有問題的,但如果是多執行緒就不一定了,假設主存中有 a,b,x,y 四個變數(保證了可見性),初始值都是 0,有 A、B 兩個執行緒,它們各自順序執行時操作如下:

  • 執行緒 A
    • x = a
    • b = 1
  • 執行緒 B
    • y = b
    • a = 2

無論兩個執行緒之間的操作如何交錯,最終結果都是 x = 0,y = 0(不考慮執行緒 A 走完再到執行緒 B 的情況,因為這樣就和單執行緒沒有差異了)。可如果發生了指令重排,此時它們各自的操作執行順序可能變為:

  • 執行緒 A
    • b = 1
    • x = a
  • 執行緒 B
    • a = 2
    • y = b

這樣造成的結果就是 x = 2,y = 1,和上面的不一致了。因此為了防止這種情況,volatile 規定禁止指令重排,從而保證資料的一致性。


相關文章