多執行緒知識梳理(8) – volatile 關鍵字

澤毛發表於2019-03-04

一、基本概念

1.1 記憶體模型

在程式的執行過程中,涉及到兩個方面:指令的執行和資料的讀寫。其中指令的執行通過處理器來完成,而資料的讀寫則要依賴於系統記憶體,但是處理器的執行速度要遠大於記憶體資料的讀寫,因此在處理器中加入了快取記憶體。在程式的執行過程中,會 先將資料拷貝到處理器的快取記憶體中,待運算結束後再回寫到系統記憶體當中

在單執行緒的情況下不會有什麼問題,但是如果在多執行緒情況下就可能會出現異常的情況,以下面這段程式碼為例,i是放在堆記憶體的共享變數:

i = i + 1; //i 的初始值為0。
複製程式碼

假如執行緒A和執行緒B都執行這段程式碼,那麼就可能出現下面兩種情況:

  • 第一種情況:執行緒A先執行+1操作,然後將i的值寫回到系統記憶體中;執行緒B從系統記憶體中拷貝i的值1到快取記憶體中,執行完+1操作再回寫到系統記憶體中,最終的結果是i=2
  • 第二種情況:執行緒A和執行緒B首先都將i的值0拷貝到各自處理器的快取記憶體當中,執行緒A首先執行+1操作,之後i的值為1,然後寫回到系統記憶體中;但是對於執行緒B而言,它並不知道這一過程,在執行該執行緒的處理器的快取記憶體中i的值仍然為0,因此在它執行+1操作後,再將i的值寫回到系統記憶體中,最終的結果是i=1

這種不確定性就稱為 快取不一致

1.2 併發程式設計中的三個概念

在併發程式設計中,有三個關鍵的概念:可見性、原子性和有序性,只有保證了這三點才能使得程式在多執行緒情況下獲得預期的執行結果。

1.2.1 可見性

可見性:是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是一個執行緒修改的結果,另一個執行緒馬上就能看到。在1.1所舉的例子就存在可見性的問題。

Javavolatilesynchronizedfinal實現可見性。

1.2.2 原子性

原子性:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

再比如a++,這個操作實際是a=a+1,是可分割的,所以它不是一個原子操作。非原子操作都會存線上程安全問題,需要我們使用同步技術來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。

Javasynchronized和在lockunlock中操作或者原子操作類來保證原子性。

1.2.3 有序性

有序性:即程式執行的順序按照程式碼的先後順序執行。以下面的程式碼為例:

int i = 0;              
boolean flag = false;
i = 1; //語句1           
flag = true; //語句2
複製程式碼

在上面的程式碼中定義了一個整形和Boolean型變數,並通過語句1和語句2對這兩個變數賦值,但是JVM在執行這段程式碼的時候並不保證語句1在語句2之前執行,也就是說可能會發生 指令重排序

指令重排序指的是在 保證程式最終執行結果和程式碼順序執行的結果一致的前提 下,改變語句執行的順序來優化輸入程式碼,提高程式執行效率。

但是這一前提條件在多執行緒的情況下就有可能出現問題,以下面的程式碼為例:

//執行緒1:
context = loadContext(); //語句1
inited = true; //語句2
 
//執行緒2:
while (!inited) {
    sleep()
}
doSomethingWithConfig(context);
複製程式碼

對於執行緒1來說,語句1和語句2沒有依賴關係,因此有可能會發生指令重排序的情況。但是對於執行緒2來說,語句2在語句1之前執行,那麼就會導致進入doSomethingWithConfig函式的時候context沒有初始化。

Java語言提供了volatilesynchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile是因為其 本身包含禁止指令重排序 的語義,synchronized是由 一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作 這條規則獲得的,此規則決定了持有同一個物件鎖的兩個同步塊只能序列執行。

二、volatile 詳解

2.1 定義

volatile的定義如下:Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保 通過排它鎖單獨地獲得這個變數。如果一個欄位被宣告成volatileJava執行緒記憶體模型確保 所有執行緒看到這個變數的值是一致的

一旦一個共享變數被volatile修飾之後,那麼就具備了兩層語義:

  • 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。
  • 禁止進行指令重排序。

下面,我們用兩個小結解釋一下這兩層語義。

2.2 保證可見性

當我們在X86處理器下通過工具獲取JIT編譯器生成的彙編指令,來檢視對volatile進行寫操作時,會發生下面的事情:

//Java 程式碼
instance = new Singleton(); //instance 是 volatile 變數

//轉變成彙編程式碼
0x01a3de1d: move $0 x 0, 0 x 1104800 (%esi); 
0x01a3de24: lock add1 $ 0 x 0, (%esp);
複製程式碼

volatile變數修飾的共享變數 進行寫操作的時候 會多出兩行彙編程式碼,Lock字首的指令在多核處理器下引發了兩件事情:

  • 將當前處理器 內部快取 的資料寫回到 系統記憶體
  • 這個寫回記憶體的操作會使在其他處理器裡 快取了該記憶體地址的資料無效,當這些處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。

2.3 禁止指令重排序

volatile關鍵字禁止指令重排序有兩層意思:

  • 當程式執行到volatile變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
  • 在進行指令優化時,不能將在對volatile變數訪問的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。

以下面的例子為例:

//flag 為 volatile 變數

x = 2; //語句1
y = 0; //語句2
flag = true;  //語句3
x = 4; //語句4
y = -1; //語句5
複製程式碼

由於flagvolatile變數,因此,可以保證語句1/2在語句3之前執行,語句4/5在其之後執行,但是並不保證語句1/2之間或者語句4/5之間的順序。

對於1.2.3舉的有關Context問題,我們就可以通過將inited變數宣告為volatile,這樣就會保證loadContext()inited賦值語句之間的順序不被改變,避免出現inited=true但是Context沒有初始化的情況出現。

2.4 效能問題

volatile相對於synchronized的優勢主要原因是兩點:簡易和效能。如果從讀寫兩方便來考慮:

  • volatile讀操作開銷非常低,幾乎和非volatile讀操作一樣
  • volatile寫操作的開銷要比非volatile寫操作多很多,因為要保證可見性需要實現 記憶體界定,即便如此,volatile的總開銷仍然要比鎖獲取低。volatile操作不會像鎖一樣 造成阻塞

以上兩個條件表明,可以被寫入volatile變數的這些有效值 獨立於任何程式的狀態,包括變數的當前狀態。大多數的程式設計情形都會與這兩個條件的其中之一衝突,使得volatile不能像synchronized那樣普遍適用於實現執行緒安全。

因此,在能夠安全使用volatile的情況下,volatile可以提供一些優於鎖的可伸縮特性。如果讀操作的次數要遠遠超過寫操作,與鎖相比,volatile變數通常能夠減少同步的效能開銷。

2.5 應用場景

要使volatile變數提供理想的執行緒安全,必須同時滿足以下兩個條件:

  • 對變數的 寫操作不依賴於當前值。例如x++這樣的增量操作,它實際上是一個由讀取、修改、寫入操作序列組成的組合操作,必須以原子方式執行,而volatile不能提供必須的原子特性。
  • 該變數 沒有包含在其它變數的不變式中

避免濫用volatile最重要的準則就是:只有在 狀態真正獨立於程式內其它內容時 才能使用volatile,下面,我們總結一些volatile的應用場景。

2.5.1 狀態標誌

volatile來修飾一個Boolean狀態標誌,用於指示發生了某一次的重要事件,例如完成初始化或者請求停機。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
複製程式碼

2.5.2 一次性安全釋出

在解釋 一次性安全釋出 的含義之前,讓我們先來看一下 單例寫法 當中著名的 雙重檢查鎖定問題

    //使用 volatile 修飾。
    private volatile static Singleton sInstance;

    public static Singleton getInstance() {
        if (sInstance == null) { //(0)
            synchronized (Singleton.class) { //(1)         
                if (sInstance == null) {  //(2)           
                    sInstance = new Singleton(); //(3)    
                }
            }
        }
        return sInstance;
    }
複製程式碼

假如 沒有使用volatile來修飾sInstance變數,那麼有可能會發生下面的場景:

  • 第一步:Thread1進入getInstance()方法,由於sInstance為空,Thread1進入synchronized程式碼塊。
  • 第二步:Thread1前進到(3)處,在建構函式執行之前使sInstance物件成為非空,並設定sInstance指向的記憶體空間。
  • 第三步:Thread2執行,它在入口(0)處檢查例項是否為空,由於sInstance物件不為空,Thread2sInstance引用返回,此時sInstance物件並沒有初始化完成
  • 第四步:Thread1通過執行Singleton物件的建構函式並將引用返回給它,來完成對該物件的初始化。

通過volatile就可以禁止第二步和第四步的重排序,也就是使得 初始化物件在設定 sInstance 指向的記憶體空間之前完成

2.5.3 volatile bean 模式

volatile bean模式適用於將JavaBeans作為“榮譽結構”使用的框架。在volatile bean模式中,JavaBean被用作一組具有getter和/或setter方法的獨立屬性的容器。

volatile bean模式的基本原理是:很多框架為易變資料的持有者提供了容器,但是放入這些容器中的物件必須是執行緒安全的。

volatile bean模式中,JavaBean的所有資料成員都是volatile型別的,並且 gettersetter方法必須非常普通,除了獲取或設定相應的屬性外,不能包含任何邏輯。此外,對於物件引用的資料成員,引用的物件必須是有效不可變的。

public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
    public void setAge(int age) { 
        this.age = age;
    }
}
複製程式碼

2.5.4 開銷較低的讀/寫鎖策略

如果讀操作遠遠超過寫操作,您可以結合使用內部鎖和volatile變數來減少公共程式碼路徑的開銷。下面的程式碼中使用synchronized確保增量操作是原子的,並使用volatile保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的效能,因為讀路徑的開銷僅僅涉及volatile讀操作,這通常要優於一個無競爭的鎖獲取的開銷。

public class CheesyCounter {
    private volatile int value;
    public int getValue() { return value; }
    public synchronized int increment() {
        return value++;
    }
}
複製程式碼

三、參考文獻

(1) Java 併發程式設計:volatile 關鍵字解析
(2) Java 中 volatile 關鍵字詳解
(3) 正確使用 volatile 變數
(4) volatile 的使用

相關文章