JAVA volatile 關鍵字

volatile 關鍵字能把 Java 變量標記成"被存儲到主存中"。這表示每一次讀取 volatile 變量都會訪問計算機主存,而不是 CPU 緩存。每一次對 volatile 變量的寫操作不僅會寫到 CPU 緩存,還會刷新到主存中。
實際上從 Java 5 開始,volatile 變量不僅會在讀寫操作時訪問主存,他還被賦予了更多含義。

變量的可見性問題

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 對變量的可見性。

然而,若 Thread-1 和 Thread-2 都同時更新變量 counter,volatile 關鍵字就不夠用了。

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。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章