java volatile 關鍵字

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

變量的可見性問題

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 沒有把更新後的值寫回到主存中。這被稱做 "可見性" 問題,即其餘線程對某線程更新操做不可見。app

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

volatile 徹底可見性

volatile 關鍵字的可見性生效範圍會超出 volatile 變量自己,這種徹底可見性表現爲如下兩個方面:spa

  • 若是 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 都會回寫到主存。code

上面的 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。

相關文章
相關標籤/搜索