1. 程式人生 > >Java中的volatile關鍵字詳解

Java中的volatile關鍵字詳解

volatile這個關鍵字可能很多朋友都聽說過,或許也都用過。在Java 5之前,它是一個備受爭議的關鍵字,因為在程式中使用它往往會導致出人意料的結果。在Java 5之後,volatile關鍵字才得以重獲生機。

Java語言提供了一種稍弱的同步機制,即volatile變數,用來確保將變數的更新操作通知到其他執行緒。當把變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile型別的變數時總會返回最新寫入的值。

在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。

變數的可見性問題

Java volatile 關鍵字保證了執行緒對變數改動的可見性。
舉個例子,在多執行緒 (不使用 volatile) 環境中,每個執行緒會從主存中複製變數到 CPU 快取 (以提高效能)。如果你有多個 CPU,不同執行緒也許會執行在不同的 CPU 上,並把主存中的變數複製到各自的 CPU 快取中,像下圖畫的那樣

若果不使用 volatile 關鍵字,你無法保證 JVM 什麼時候從主存中讀變數到 CPU cache,或把變數從 CPU cache 寫回主存。這會導致很多併發問題,我會在下面的小節中解釋。
想像一下這種情形,兩個或多個執行緒同時訪問一個共享物件,物件中包含一個用於計數的變數:

public class SharedObject {
    public int counter = 0;
}

如果 Thread-1 會增加 counter 的值,而 Thread-1 和 Thread-2 會不時地讀取 counter 變數。在這種情形中,如果變數 counter 沒有被宣告成 volatile,就無法保證 counter 的值何時會 (被 Thread-1) 從 CPU cache 寫回到主存。結果導致 counter 在 CPU 快取的值和主存中的不一致:

Thread-2 無法讀取到變數最新的值,因為 Thread-1 沒有把更新後的值寫回到主存中。這被稱作 "可見性" 問題,即其他執行緒對某執行緒更新操作不可見。

volatile 保證了變數的可見性

volatile 關鍵字解決了變數的可見性問題。通過把變數 counter 宣告為 volatile,任何對 counter 的寫操作都會立即重新整理到主存。同樣的,所有對 counter 的讀操作都會直接從主存中讀取。

public class SharedObject {
    public volatile int counter = 0;
}

還是上面的情形,宣告 volatile 後,若 Thread-1 修改了 counter 則會立即重新整理到主存中,Thread-2 從主存讀取的 counter 是 Thread-1 更新後的值,保證了 Thread-2 對變數的可見性。

volatile 完全可見性

volatile 關鍵字的可見性生效範圍會超出 volatile 變數本身,這種完全可見性表現為以下兩個方面:

  • 如果 Thread-A 對 volatile 變數進行寫操作,Thread-B 隨後該 volatile 變數進行讀操作,那麼 (在 Thread-A 寫 volatile 變數之前的) 所有對 Thread-A 可見的變數,也會 (在 Thread-B 讀 volatile 變數之後) 對 Thread-B 可見。
  • 當 Thread-A 讀一個 volatile 變數時,所有其他對 Thread-A 可見的變數也會重新從主存中讀一遍。

很抽象?讓我們舉例說明:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

上面的 update() 方法給三個變數賦值 (寫操作),其中只有 days 是 volatile 變數。完全可見性在這的含義是,當對 days 進行寫操作時,執行緒可見的其他變數 (在寫 days 之前的變數) 都會一同回寫到主存,也就是說變數 months 和 years 都會回寫到主存。

上面的 totalDays() 方法一開始就把 volatile 變數 days 讀取到區域性變數 total 中,當讀取 days 時,變數 months 和 years (在讀 days 之後的變數) 同樣會從主存中讀取。所以通過上面的程式碼,你能確保讀到最新的 days, months 和 years。

指令重排的困擾

為了提高效能,JVM 和 CPU 會被允許對程式進行指令重排,只要重排的指令語義保持一致。舉個例子:

int a = 1;
int b = 2;

a++;
b++;

上述指令可能被重排成如下形式,語義跟先前保持一致:

int a = 1;
a++;

int b = 2;
b++;

然而,當你使用了 volatile 變數時,指令重排有時候會產生一些困擾。讓我們再看下面的例子:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

update() 方法在寫變數 days 時,對變數 years 和 months 的寫操作同樣會重新整理到主存中。但如果 JVM 執行了指令重排會發生什麼情況?就像下面這樣:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

當變數 days 發生改變時,months 和 years 仍然會回寫到主存中。但這一次,days 的更新發生在寫 months 和 years 之前,導致 months 和 years 的新值可能對其他執行緒不可見,使程式語義發生改變。對此 JVM 有現成的解決方法,我們會在下一小節討論這個問題。

volatile 的 Happen-before 機制

為了解決指令重排帶來的困擾,Java volatile 關鍵字在可見性的基礎上提供了 happens-before 這種擔保機制。happens-before 保證瞭如下方面:

  • 如果其他變數的讀寫操作原本發生在 volatile 變數寫操作之前,他們不能被指令重排到 volatile 變數的寫操作之後。注意,發生在 volatile 變數寫操作之後的讀寫操作仍然可以被指令重排到 volatile 變數寫操作之前。happen-after 重排到 (volatile 寫操作) 之前是允許的,但 happen-before 重排到之後是不允許的。
  • 如果其他變數的讀寫操作原本發生在 volatile 變數讀操作之後,他們不能被指令重排到 volatile 變數的讀操作之前。注意,發生在 volatile 變數讀操作之前的讀操作仍然可以被指令重排到 volatile 變數讀操作之後。happen-before 重排到 (volatile 讀操作) 之後是允許的,但 happen-after 重排到之前是不允許的。

happens-before 機制確保了 volatile 的完全可見性

volatile 並不總是行得通

雖然關鍵字 volatile 保證了對 volatile 變數的讀寫操作會直接訪問主存,但在某些情況下把變數宣告為 volatile 還不足夠。
回顧之前舉過的例子 —— Thread-1 對共享變數 counter 進行寫操作,宣告 counter 為 volatile 並不足以保證 Thread-2 總是能讀到最新的值。

實際上,可能會有多個執行緒對同一個 volatile 變數進行寫操作,也會把正確的新值寫回到主存,只要這個新值不依賴舊值。但只要這個新值依賴舊值 (也就是說執行緒先會讀取 volatile 變數,基於讀取的值計算出一個新值,並把新值寫回到 volatile 變數),volatile 關鍵字不再能夠保證正確的可見性 (其他文章會把這稱為原子性)。

在多執行緒同時共享變數 counter 的情形下,volatile 關鍵字已不足以保證程式的併發性。設想一下:Thread-1 從主存中讀取了變數 counter = 0 到 CPU 快取中,進行加 1 操作但還沒把更新後的值寫回到主存。Thread-2 同一時間從主存中讀取 counter (值仍為 0) 到他所在的 CPU 快取中,同樣進行加 1 操作,也沒來得及回寫到主存。情形如下圖所示:


Thread-1 和 Thread-2 現在處於不同步的狀態。從語義上來說,counter 的值理應是 2,但變數 counter 在兩個執行緒所在 CPU 快取中的值卻是 1,在主存中的值還是 0。即使執行緒都把 counter 回寫到主存中,counter 更新成1,語義上依然是錯的。(這種情況應該使用 synchronized 關鍵字保證執行緒同步)

什麼時候使用 volatile

像之前的例子所說:如果有兩個或多個執行緒同時對一個變數進行讀寫,使用 volatile 關鍵字是不夠用的,因為對 volatile 變數的讀寫並不會阻塞其他執行緒對該變數的讀寫。你需要使用 synchronized 關鍵字保證讀寫操作的原子性,或者使用 java.util.concurrent 包下的原子型別代替 synchronized 程式碼塊,例如:AtomicLong, AtomicReference 等。

如果只有一個執行緒對變數進行讀寫操作,其他執行緒僅有操作,這時使用 volatile 關鍵字就能保證每個執行緒都能讀到變數的最新值,即保證了可見性。

volatile 的效能

volatile 變數的讀寫操作會導致對主存的直接讀寫,對主存的直接訪問比訪問 CPU 快取開銷更大。使用 volatile 變數一定程度上影響了指令重排,也會一定程度上影響效能。所以當迫切需要保證變數可見性的時候,你才會考慮使用 volatile。

我有一個微信公眾號,經常會分享一些Java技術相關的乾貨;如果你喜歡我的分享,可以用微信搜尋“Java團長”或者“javatuanzhang”關注。

參考: