小白必須掌握的Java volatile關鍵字

Java volatile關鍵字用於將Java變量標記爲“存儲在主內存中”。更準確地說,這意味着每次對volatile變量的讀取都將從計算機的主內存中讀取,而不是從CPU緩存中讀取,並且對volatile變量的每次寫入都將被寫入主內存中,而不僅是CPU緩存。

 

事實上,從Java 5開始,volatile關鍵字保證了volatile變量不僅僅是寫入主內存或者是從主內存讀取,我將從下面幾個方面進行解釋。

 

01、變量可見性問題

 

Java volatile關鍵字可確保跨線程更改變量的可見性。這聽起來有點抽象,下面來詳細說明。

 

在多線程應用中,線程對非易失性變量進行操作,出於性能方面的考慮,每個線程在進行操作時都可以將主內存中的變量複製到CPU緩存中。如果計算機包含多個CPU,則每個線程可能在不同的CPU上運行。這意味着,每個線程可以將變量複製到不同CPU的CPU緩存中。我們來看個例子:

對於非易失性變量,無法保證Java虛擬機(JVM)何時將數據從主存讀取到CPU緩存,何時將數據從CPU緩存寫入主存。這可能會導致一些問題,這些都將在下面一起解釋。

 

設想一種情況,有兩個或多個線程可以訪問一個共享對象,該共享對象包含一個聲明爲以下內容的計數器變量:

public class SharedObject {    public int counter = 0;}

想象一下,只有線程1遞增計數器變量,但線程1和線程2都可能時不時地讀取計數器變量。

 

如果計數器變量未聲明爲volatile,則無法保證計數器變量的值何時從CPU緩存寫入主內存。這意味着,CPU緩存中的計數器變量值可能與主內存中的不同。這種情況如下所示:

線程看不到變量的最新值,因爲它還沒有被另一個線程寫回主內存的問題,稱爲“可見性”問題。一個線程的更新對其他線程不可見。

 

02、Java volatile可見性保證

 

Java volatile關鍵字旨在解決變量可見性問題。通過聲明計數器變量爲volatile,所有對計數器變量的寫操作將立即寫回到主內存中。同樣,計數器變量的所有讀取將直接從主內存中讀取。

 

我們對上面的計數器變量加上volatile關鍵字聲明,像這樣:​​​​​​​

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

因此,將變量聲明爲volatile可以保證對該變量的其他寫入線程的可見性。

 

在上面給出的場景中,一個線程(T1)修改計數器,另一個線程(T2)讀取計數器(但從不修改),聲明計數器變量volatile足以保證T2對計數器變量的寫入可見性。

 

但是,如果T1和T2都在遞增計數器變量,那麼聲明計數器變量volatile是不夠的,這個以後再說。

 

完全易失的可見性保證(Full volatile Visibility Guarantee)

 

實際上,Java volatile的可見性保證超出了volatile變量本身。可見性保證如下:

  • 如果線程A寫入易失性變量,並且線程B隨後讀取相同的易失性變量,則在寫入易失性變量之前線程A可見的所有變量在線程B讀取易失性變量後也將可見。

  • 如果線程A讀取了易失性變量,則在讀取易失性變量時線程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是可變的。

 

完全易失的可見性保證意味着,當將值寫入days時,線程可見的所有變量也將寫入主內存。這意味着,當將值寫入days時,years和months的值也將寫入主內存中。

 

在讀取years,months和days的值時,可以這樣:

​​​​​​​

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的值時,months和years的值也會被讀入主內存,因此,可以保證按照上述讀取順序查看days,months和years的最新值。

 

03、指令重排挑戰性

 

出於性能原因,允許Java VM和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 VM重新對指令進行排列,比如:​​​​​​​

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

當days變量被修改時,months和years的值仍會寫入主內存,但這次是在新值寫入months和years之前發生的。因此,新值對其他線程來說是不可見的。重新排序的指令的語義已更改。

Java有解決此問題的方法,我們將在後面看到。

04、Java易失性發生之前保證

 

爲了解決指令重新排序的難題,除了可見性保證之外,Java volatile關鍵字還提供易失性發生之前(“happens-before”)保證。發生之前保證保證了:

  • 如果讀/寫最初發生在對volatile變量的寫入之前,則不能將對其他變量的讀/寫重新排序爲在對volatile變量的寫入之後發生。在寫入volatile變量之前的讀/寫保證在寫入volatile變量之前“發生”。注意,例如,在對volatile的寫操作之後的其他變量的讀/寫操作仍有可能在對volatile的寫操作之前重新排序。但不是相反。允許從後到前,但不允許從前到後。

  • 如果讀取/寫入最初發生在讀取volatile變量之後,則不能將對其他變量的讀取和寫入重新排序爲在讀取volatile變量之前發生。注意,在volatile變量的讀取之前發生的其他變量的讀取可能會被重新排序爲在volatile變量的讀取之後發生。但不是相反。允許從前到後,但不允許從後到前。

 

上述“易失性發生之前(“happens-before”)確保強制執行volatile關鍵字的可見性保證。

 

 

 

05、聲明volatile還不一定夠

 

即使volatile關鍵字保證所有volatile變量的讀取都直接從主存中讀取,並且所有對volatile變量的寫入都直接寫入主存,但在某些情況下,聲明變量volatile還不夠。

 

在前面解釋的只有線程1寫入共享計數器變量的情況下,聲明計數器變量爲volatile,足以確保線程2始終看到最新的寫入值。

 

實際上,如果寫入變量的新值不依賴於先前的值,則多個線程甚至可能正在寫入一個共享的volatile變量,並且仍將正確的值存儲在主內存中。換句話說,如果線程首先將值寫入共享的volatile變量,則不需要先讀取其值即可找出下一個值。

 

一旦線程需要首先讀取volatile變量的值,並基於該值爲共享的volatile變量生成新值,則volatile變量將不再足以保證正確的可見性。讀取volatile變量與寫入新值之間的時間間隔很短,從而造成競爭狀態,多個線程可能會讀取volatile變量的同一個值,爲該變量生成一個新值,並且在將該值寫入主內存時 - 覆蓋彼此的值。

 

多個線程遞增同一個計數器的情況正是這樣一種情況,即聲明volatile變量還不夠。下面將更詳細地解釋此案例。

 

想象一下,如果線程1將一個值爲0的共享計數器變量讀入其CPU高速緩存中,將其遞增爲1,而不是將更改後的值寫回到主內存中。然後,線程2可以從主內存中(該變量的值仍爲0)讀取相同的計數器變量到其自己的CPU高速緩存中。然後線程2還可將計數器增加到1,並且也不會將其寫回主內存。下圖說明了這種情況:

線程1和線程2現在實際上不同步。共享計數器變量的實際值應該是2,但每個線程的CPU緩存中都有該變量的值1,但在主內存中該值仍然爲0。這是糟糕的,即使線程最終將共享計數器變量的值寫回主內存,該值也會出錯。

 

 

 

06、什麼時候聲明volatile變量就足夠

 

如前所述,如果兩個線程都在讀寫一個共享變量,那麼使用volatile關鍵字是不夠的。在這種情況下,您需要使用synchronized來保證變量的讀寫是原子的。讀取或寫入volatile變量不會阻塞線程的讀取或寫入。爲此,必須在關鍵部分使用synchronized關鍵字。

 

作爲同步塊的替代方法,您還可以使用java.util.concurrent包中提供的許多原子數據類型之一。例如,AtomicLong或AtomicReference或其他之一。

 

如果只有一個線程讀取和寫入volatile變量的值,而其他線程只讀取該變量,那麼讀取線程將保證看到寫入volatile變量的最新值。如果不將變量聲明volatile,就無法保證這一點。

 

volatile關鍵字保證可以在32位和64位變量上使用。

 

 

07、volatile的性能考慮

 

讀寫volatile變量會使該變量被讀寫到主存。與訪問CPU緩存相比,讀寫主內存的開銷更大。訪問volatile變量還可以防止指令重新排序,這是一種常見的性能增強技術。因此,只有在確實需要增強變量的可見性時,才應該使用volatile變量。

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