譯文《Java併發程式設計之volatile》

潘潘和他的朋友們發表於2022-03-22
作者: 雅各布·詹科夫
原文: http://tutorials.jenkov.com/j...
翻譯: 潘深練個人網站 如您有更好的翻譯版本,歡迎 ❤️ 提交 issue 或投稿哦~
更新: 2022-02-24

Java的volatile關鍵字用於將Java變數標記為“儲存在主記憶體中”。更準確地說,每次對volatile變數的讀取都將從計算機主記憶體中讀取,而不是從CPU快取中讀取,並且每次對volatile變數的寫入都將寫入主記憶體,而不僅僅寫在CPU快取。

事實上,自從 Java5 開始,volatile 關鍵字就不僅僅被用來保證 volatile 變數讀寫主記憶體。我將在以下內容解釋這一點。

Java volatile 教程視訊

如果你喜歡視訊,我在這裡有這個 Java volatile 教程的視訊版本:
Java volatile 教程視訊

變數可見性問題

Java的volatile關鍵字在多執行緒處理中保證了共享變數的“可見性”。這聽起來可能有點抽象,所以讓我詳細說明。

在多執行緒應用程式中,如果多個執行緒對同一個無宣告volatile關鍵詞的變數進行操作,出於效能原因,每個執行緒可以在處理變數時將變數從主記憶體複製到CPU快取中。如果你的計算機擁有多CPU,則每個執行緒可能在不同的CPU上執行。這就意味著,每個執行緒都可以將變數複製在不同CPU的CPU快取上。這在此處進行了說明:

對於無宣告volatile關鍵詞的變數而言,無法保證Java虛擬機器(JVM)何時將資料從主記憶體讀取到CPU快取,或者將資料從CPU快取寫入主記憶體。這就可能會導致幾個問題,我將在以下部分內容解釋這些問題。

想象一個場景,多個執行緒訪問一個共享物件,該物件包含一個宣告如下的計數器(counter)變數:

public class SharedObject {

    public int counter = 0;

}

假設只有執行緒1會增加計數器(counter)變數的值,但是執行緒1和執行緒2會不時的讀取這個計數器變數。

如果計數器(counter)變數沒有宣告volatile關鍵詞,則無法保證計數器變數的值何時從CPU快取寫回主記憶體。這就意味著,每個CPU快取上的計數器變數值和主記憶體中的變數值可能不一致。這種情況如下所示:

一個執行緒的寫操作還沒有寫回主記憶體(每個執行緒都有本地快取,即CPU快取,一般寫入成功會從cpu快取重新整理至主記憶體),其他執行緒看不到變數的最新值,這就是“可見性”問題,即一個執行緒的更新對其他執行緒是不可見的。

Java volatile 可見性保證

Java的volatile關鍵字就是為了解決變數的可見性問題。通過對計數器(counter)變數宣告volatile關鍵字,所有執行緒對該變數的寫入都會被立即同步到主記憶體中,並且,所有執行緒對該變數的讀取都會直接從主記憶體讀取。

以下是計數器(counter)變數宣告瞭關鍵字volatile的用法:

public class SharedObject {

    public volatile int counter = 0;

}

因此,宣告瞭volatile關鍵字的變數,保證了其他執行緒對該變數的寫入可見性。

在以上給出的場景中,一個執行緒(T1)修改了計數器變數,而另一個執行緒(T2)讀取計數器變數(但是沒有進行修改),這種場景下如果給計數器(counter)變數宣告volatile關鍵字,就能夠保證計數器(counter)變數的寫入對執行緒(T2)是可見的。

但是如果執行緒(T1)和執行緒(T2)都對計數器(counter)變數進行了修改,那麼給計數器(counter)變數宣告volatile關鍵字是無法保證可見性的,稍後討論。

volatile 全域性可見性保證

實際上,Java的volatile關鍵字可見性保證超過了volatile變數本身的可見性,可見性保證如下:

  • 如果執行緒A寫入一個volatile變數,而執行緒B隨後讀取了同一個volatile變數,那麼所有變數的可見性,線上程A寫入volatile變數之前對執行緒A可見,線上程B讀取volatile變數之後對執行緒B同樣可見。
  • 如果執行緒A讀取一個volatile變數,那麼讀取volatile變數時,對執行緒A可見的所有變數也會從主記憶體中重新讀取。

讓我用一個程式碼示例來說明:

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;
    }
}

udpate()方法寫入三個變數,其中只有變數days宣告為volatile

volatile關鍵字宣告的變數,被寫入時會直接從本地執行緒快取重新整理到主記憶體。

volatile的全域性可見性保證,指的是當一個值被寫入days時,所有對當前寫入執行緒可見的變數也都會被寫入到主記憶體。意思就是當一個值被寫入days變數時,year變數和months變數也會被寫入到主記憶體。

在讀yearsmonthsdays的值時,你可以這樣做:

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;
    }
}

注意,totalDays()方法會首先讀取days變數的值到total變數中,當程式讀取days變數時,也會從主記憶體讀取month變數和years變數的值。因此你可以通過以上的讀取順序,來保證讀取到三個變數days,monthsyears最新的值。

指令重排序的挑戰

為了提高效能,一般允許 JVM 和 CPU 在保證程式語義不變的情況下對程式中的指令進行重新排序。例如:

int a = 1;
int b = 2;

a++;
b++;

這些指令可以重新排序為以下順序,而不會丟失程式的語義含義:

int a = 1;
a++;

int b = 2;
b++;

然而,當其中一個變數是volatile關鍵字宣告的變數時,指令重排就會遇到一些挑戰。讓我們看看之前教程中的MyClass類示例:

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變數的最新值也會被寫入到主記憶體當中。但是,如果Java虛擬機器對指令進行重排,例如這樣:

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

當修改days變數時,仍然會將months變數和years變數的值寫入主記憶體,但是這個節點是發生在新值寫入months變數和years變數之前。因此months變數和years變數的最新值不可能正確地對其他執行緒可見。這種重排指令會導致語義發生改變。

針對這個問題Java提供了一個解決方案,我們往下看。

Java volatile Happens-Before 規則

為了解決指令重新排序的挑戰,除了可見性保證之外,Java的volatile關鍵字還提供了Happens-Before規則。Happens-Before規則保證:

  • 如果其他變數的讀寫操作原先就發生在volatile變數的寫操作之前,那麼其他變數的讀寫指令不能被重排序到volatile變數的寫指令之後;

    • volatile變數寫入之前,發生的其他變數的讀寫,Happens-Before 於volatile變數的寫入。
注意:例如在volatile變數寫入之後的其他變數讀寫,仍然可能被重排到volatile變數寫入之前。只不過不能反著來,允許後面的讀寫重排到前面,但不允許前面的讀寫重排到後面。
  • 如果其他變數的讀寫操作原先就發生在volatile變數讀操作之後,那麼其他變數的讀寫指令不能被重排序到volatile變數的讀指令之前;
注意:例如在volatile變數讀之前的其他變數讀取,可能被重排到volatile變數的讀之後。只不過不能反著來,允許前面的讀取重排到後面,但不允許後面的讀取重排到前面。

上述的Happens-Before規則,確保了volatile關鍵字的可見性保證會被強制要求。

僅宣告 volatile 不足以保證執行緒安全

即使volatile關鍵字保證直接從主記憶體讀取volatile變數,並且所有對volatile變數的寫入都直接寫入主記憶體,在某些情況下僅僅宣告變數volatile是不足以保證執行緒安全的。

在前面解釋的情況中,只有執行緒1寫入共享計數器變數,宣告計數器變數volatile足以確保執行緒2始終看到最新的寫入值。

事實上,如果寫入變數的新值不需要依賴之前的值,那多個執行緒可以同時對一個volatile共享變數進行寫入操作,並且在主記憶體中仍然儲存正確的值。換而言之,如果一個執行緒僅對一個volatile共享變數進行寫入操作,那並不需要先讀取出這個變數的值,再通過計算得到下一個值。

一旦執行緒需要首先讀取出volatile變數的值,再基於該值為volatile共享變數生成新值,那volatile變數就不再足以保證正確的可見性。在讀取volatile變數和寫入新值之間的短暫時間會產生資源競爭,存在多個執行緒同時來讀取volatile變數並得到相同的值,且都為變數賦予新值,然後將值都寫回主記憶體中,從而會覆蓋掉彼此的值。

多個執行緒遞增同個計數器(counter)變數的情況,導致volatile變數不夠保證執行緒安全性。 以下部分更詳細地解釋了這種情況:

想象一下,如果執行緒1將值為0的共享計數器(counter)變數讀入其CPU快取記憶體,則將其遞增為1並且還未將更改的值寫回主記憶體。 同時間執行緒2也可以從主記憶體中讀取到相同的計數器變數,其中變數的值仍為0,存進其自己的CPU快取記憶體。 然後,執行緒2也可以將計數器(counter)遞增到1,也還未將其寫回主記憶體。 這種情況如下圖所示:

執行緒1和執行緒2現在幾乎不同步。共享計數器(counter)變數的實際值應該是2,但每個執行緒在其CPU快取中的變數值為1,在主記憶體中該值仍然為0。真是一團糟!即使執行緒最終將其共享計數器變數的值寫回主記憶體,該值也將是錯誤的。

volatile 何時是執行緒安全的

正如我前面提到的,如果兩個執行緒都在讀取和寫入共享變數,那麼使用volatile關鍵字是不足以保證執行緒安全的。一般這種情況下,您需要使用synchronized來保證變數的讀取和寫入是原子性的。讀取或寫入volatile變數不會阻塞其他執行緒讀取或寫入。為此,您必須在關鍵部分周圍使用synchronized關鍵字。

作為synchronized塊的替代方案,您可以選擇使用java.util.concurrent併發包中的原子資料型別。 例如,AtomicLongAtomicReference或其它之一。

如果只有一個執行緒讀取和寫入volatile變數的值,而其他執行緒只讀取變數,那麼讀取執行緒將保證看到寫入volatile變數的最新值。 如果不使變數變為volatile,則無法保證。

volatile關鍵字保證適用於32位和64位。

volatile 的效能注意事項

讀寫volatile變數都會直接從主記憶體讀寫,比從CPU快取讀寫要花更多的開銷,但訪問volatile變數可以阻止指令重排,這是一項正常的效能增強技術。因此,除非確實需要強制實施變數的可見性,否則其他情況減少使用volatile變數。

(本篇完)

原文: http://tutorials.jenkov.com/j...
翻譯: 潘深練個人網站 如您有更好的翻譯版本,歡迎 ❤️ 提交 issue 或投稿哦~

相關文章