關(guān)于關(guān)鍵字Volatile的理解

Java Volatile Keyword

在這篇文章中,我們將關(guān)注Java語(yǔ)言中的基本但經(jīng)常被誤解的概念 - volatile關(guān)鍵字。

1.概述

在Java中,每個(gè)線程都有一個(gè)獨(dú)立的內(nèi)存空間,稱(chēng)為工作內(nèi)存; 這保存了用于執(zhí)行操作的不同變量的值。在執(zhí)行操作之后,線程將變量的更新值復(fù)制到主存儲(chǔ)器,并且從那里其他線程可以讀取最新值。

Java volatile關(guān)鍵字作用將Java變量標(biāo)記為“存儲(chǔ)在主存儲(chǔ)器中”。更確切地說(shuō),這意味著,每次讀取一個(gè)volatile變量都將從計(jì)算機(jī)的主內(nèi)存中讀取,而不是從CPU緩存中讀取,并且每次寫(xiě)入volatile變量都將寫(xiě)入主內(nèi)存,而不僅僅是CPU緩存。

簡(jiǎn)單地說(shuō),volatile關(guān)鍵字標(biāo)記一個(gè)變量,在多個(gè)線程訪問(wèn)它的情況下,總是轉(zhuǎn)到主內(nèi)存,讀取和寫(xiě)入。

實(shí)際上,自Java 5以來(lái),volatile關(guān)鍵字保證的不僅僅是易失性變量被寫(xiě)入主內(nèi)存并從主內(nèi)存中讀取。我將在以下部分解釋。

可變可見(jiàn)性問(wèn)題

Java volatile關(guān)鍵字保證可以跨線程查看變量的變化。這可能聽(tīng)起來(lái)有點(diǎn)抽象,所以讓我詳細(xì)說(shuō)明。

在線程操作非易失性變量的多線程應(yīng)用程序中,出于性能原因,每個(gè)線程可以在處理它們時(shí)將變量從主內(nèi)存復(fù)制到CPU緩存中。如果您的計(jì)算機(jī)包含多個(gè)CPU,則每個(gè)線程可以在不同的CPU上運(yùn)行。這意味著,每個(gè)線程可以將變量復(fù)制到不同CPU的CPU緩存中。這在這里說(shuō)明:

線程可以保存CPU緩存中主存儲(chǔ)器的變量副本。

對(duì)于non-volatile變量,無(wú)法保證Java虛擬機(jī)(JVM)何時(shí)將數(shù)據(jù)從主內(nèi)存讀入CPU緩存,或?qū)?shù)據(jù)從CPU緩存寫(xiě)入主內(nèi)存。這可能會(huì)導(dǎo)致幾個(gè)問(wèn)題,我將在以下部分中解釋。

想象一下兩個(gè)或多個(gè)線程可以訪問(wèn)共享對(duì)象的情況,該共享對(duì)象包含一個(gè)聲明如下的計(jì)數(shù)器變量:

public class SharedObject {
    public int counter = 0;
}

想象一下,只有線程1遞增counter變量,但線程1和線程2都可能counter不時(shí)讀取變量。

如果counter變量未聲明為volatile,則無(wú)法保證何時(shí)將counter變量的值從CPU緩存寫(xiě)回主內(nèi)存。這意味著counter變量在CPU緩存中的變量值可能與主存儲(chǔ)器中的變量值不同。這種情況如下所示:

線程1和主內(nèi)存使用的CPU緩存包含計(jì)數(shù)器變量的不同值。

這里的問(wèn)題是,其他線程沒(méi)有看到counter變量的最新值,原因是它還沒(méi)有被另一個(gè)線程寫(xiě)回主內(nèi)存,稱(chēng)為“可見(jiàn)性”問(wèn)題。其他線程看不到一個(gè)線程的更新。

2.易失性和線程同步

對(duì)于所有多線程應(yīng)用程序,我們需要確保一致的行為規(guī)則:

  • 相互排斥 - 一次只有一個(gè)線程執(zhí)行一個(gè)關(guān)鍵部分
  • 可見(jiàn)性 - 一個(gè)線程對(duì)共享數(shù)據(jù)所做的更改對(duì)其他線程可見(jiàn),以維護(hù)數(shù)據(jù)一致性

同步方法和塊提供上述兩種屬性,但代價(jià)是應(yīng)用程序的性能。

Volatile是一個(gè)非常有用的原語(yǔ),因?yàn)樗梢詭椭?strong>確保數(shù)據(jù)變化的可見(jiàn)性方面,當(dāng)然,不提供互斥。因此,它在我們可以使用多個(gè)線程并行執(zhí)行代碼塊但我們需要確??梢?jiàn)性屬性的地方很有用。

Java易失性可見(jiàn)性保證

Java volatile關(guān)鍵字旨在解決可變可見(jiàn)性問(wèn)題。通過(guò)聲明counter變量為volatile,對(duì)counter變量的所有寫(xiě)操作都將立即寫(xiě)回內(nèi)存。此外,counter變量的所有讀取都將直接從主存儲(chǔ)器中讀取。

以下是 變量volatile聲明的counter樣子:

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

聲明volatile變量可以保證對(duì)該變量的其他寫(xiě)入線程的可見(jiàn)性。

在上面給出的場(chǎng)景中,一個(gè)線程(T1)修改計(jì)數(shù)器,另一個(gè)線程(T2)讀取計(jì)數(shù)器(但從不修改它),聲明該counter變量volatile后足以保證counter變量的寫(xiě)入對(duì)T2是可見(jiàn)的。

但是,如果T1和T2都在增加counter變量,那么聲明 counter變量volatile就不夠了。稍后會(huì)詳細(xì)介紹。

完全不穩(wěn)定的可見(jiàn)性保證

實(shí)際上,Java volatile的可見(jiàn)性保證超出了volatile 變量本身。能見(jiàn)度保證如下情形:

如果線程A寫(xiě)入volatile變量并且線程B隨后讀取相同的volatile變量,線程A在寫(xiě)入之前的所有volatile變量,當(dāng)線程B讀取volatile變量后也將對(duì)線程B可見(jiàn)。

如果線程A讀取volatile變量,則讀取變量時(shí)線程A可見(jiàn)的所有變量volatile也將從主存儲(chǔ)器重新讀取。
讓我用代碼示例說(shuō)明:

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()方法寫(xiě)入三個(gè)變量,其中只有days 是volatile。

完全volatile可見(jiàn)性保證意味著,當(dāng)寫(xiě)入days值時(shí),線程可見(jiàn)的所有變量也會(huì)寫(xiě)入主存儲(chǔ)器。這意味著,當(dāng)days的值被寫(xiě)入主內(nèi)存,years和months的值也被寫(xiě)入主存儲(chǔ)器。

當(dāng)讀取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()通過(guò)讀取的值的方法開(kāi)始days到 total變量。當(dāng)讀取的值days,值months 和years也被讀入到主存儲(chǔ)器中。因此可以保證看到的最新值days,months并years與上述讀取序列。

指令重新排序挑戰(zhàn)

只要指令的語(yǔ)義含義保持不變,Java 虛擬機(jī)和CPU就可以出于性能原因重新排序程序中的指令。例如,請(qǐng)查看以下說(shuō)明:

int a = 1;
int b = 2;

a++;
b++;

這些指令可以按以下順序重新排序,而不會(huì)丟失程序的語(yǔ)義含義:`

int a = 1;
a++;

int b = 2;
b++;

然而,當(dāng)其中一個(gè)變量是volatile變量時(shí),指令重新排序時(shí)將面臨挑戰(zhàn)。讓我們看看MyClass這個(gè)Java volatile教程前面的例子中的類(lèi):

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

一旦調(diào)用update()方法寫(xiě)入一個(gè)值到days, 新寫(xiě)入到y(tǒng)ears 和months的值同樣被寫(xiě)入到主內(nèi)存。
但是,如果Java VM重新排序指令,如下所示:

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

當(dāng)我們?nèi)バ薷膁ays變量時(shí)months的值和years值仍寫(xiě)入到主內(nèi)存,但這次發(fā)生的是days修改是發(fā)生在寫(xiě)months years之前。新的值(months,years)不能正確被其他線程可見(jiàn).重新排序的指令的語(yǔ)義含義已經(jīng)改變。
Java有一個(gè)解決這個(gè)問(wèn)題的方法,我們將在下一節(jié)中看到。

3.發(fā)生在保證之前

從Java 5開(kāi)始,volatile關(guān)鍵字還提供了額外的功能,可確保包括非易失性變量在內(nèi)的所有變量的值與Volatile寫(xiě)操作一起寫(xiě)入主存儲(chǔ)器。

這稱(chēng)為Happens-Before,因?yàn)樗鼮樗凶兞刻峁┝藢?duì)另一個(gè)讀取線程的可見(jiàn)性。此外,JVM不會(huì)重新排序volatile變量的讀寫(xiě)指令。

Java volatile Happens-Before Guarantee

為了解決指令重新排序挑戰(zhàn),volatile除了可見(jiàn)性保證之外,Java 關(guān)鍵字還提供“先發(fā)生”保證。事先發(fā)生的保證保證:
1.如果讀取/寫(xiě)入最初發(fā)生在對(duì)volatile變量寫(xiě)入之前,則無(wú)法重新排序?qū)ζ渌兞康淖x和寫(xiě)操作。

對(duì)volatile變量的寫(xiě)入之前的讀/寫(xiě)將會(huì)保證在寫(xiě)入”volatile"之前“先發(fā)生”。
請(qǐng)注意,在寫(xiě)入“volatile"變量之前,可能會(huì)對(duì)其他變量的讀/寫(xiě)進(jìn)行重新排序,以使其在寫(xiě)入“volatile"后發(fā)生。只是不是另一種方式。從以后到以前是允許的, 但從以前到以后是不允許的。

2.如果讀取/寫(xiě)入最初發(fā)生在對(duì)volatile變量讀取之后,則無(wú)法重新排序?qū)ζ渌兞康淖x和寫(xiě)操作。

請(qǐng)注意, 在讀取“volatile"變量之前, 可能會(huì)對(duì)其他變量的讀取進(jìn)行重新排序, 以使其在讀取“volatile"后發(fā)生。只是不是另一種方式。從之前到以后是允許的, 但從以后到以前是不允許的。

上述情況-在保證確保volatile關(guān)鍵字的可見(jiàn)性保證被強(qiáng)制執(zhí)行之前。

volatile is Not Always Enough

即使volatile關(guān)鍵字保證volatile變量直接從主存儲(chǔ)器讀取變量的所有讀取,并且所有對(duì)volatile變量的寫(xiě)入都直接寫(xiě)入主存儲(chǔ)器,仍然存在聲明變量不足的情況volatile。

在前面解釋的情況中,只有線程1寫(xiě)入共享counter變量,聲明該counter變量volatile足以確保線程2始終看到最新的寫(xiě)入值。

實(shí)際上,volatile如果寫(xiě)入變量的新值不依賴(lài)于其先前的值,則多個(gè)線程甚至可以寫(xiě)入共享變量,并且仍然具有存儲(chǔ)在主存儲(chǔ)器中的正確值。換句話說(shuō),如果將值寫(xiě)入共享volatile變量的線程首先不需要讀取其值來(lái)計(jì)算其下一個(gè)值。

一旦線程需要首先讀取volatile變量的值,并且基于該值為共享volatile變量生成新值,volatile變量就不再足以保證正確的可見(jiàn)性。讀取volatile 變量和寫(xiě)入新值之間的短時(shí)間間隔會(huì)產(chǎn)生競(jìng)爭(zhēng)條件 ,其中多個(gè)線程可能讀取volatile變量的相同值,為變量生成新值,并在將值寫(xiě)回時(shí)主存 - 覆蓋彼此的值。

多個(gè)線程遞增相同計(jì)數(shù)器的情況恰好是 volatile變量不夠的情況。以下部分更詳細(xì)地解釋了這種情況。

想象一下,如果線程1將counter值為0 的共享變量讀入其CPU高速緩存,則將其增加到1并且不將更改的值寫(xiě)回主存儲(chǔ)器。然后,線程2可以counter從主存儲(chǔ)器讀取相同的變量,其中變量的值仍為0,進(jìn)入其自己的CPU高速緩存。然后,線程2也可以將計(jì)數(shù)器遞增到1,也不將其寫(xiě)回主存儲(chǔ)器。這種情況如下圖所示:

兩個(gè)線程已將共享計(jì)數(shù)器變量讀入其本地CPU高速緩存并遞增。

線程1和線程2現(xiàn)在幾乎不同步。共享counter變量的實(shí)際值應(yīng)為2,但每個(gè)線程的CPU緩存中的變量值為1,而主存中的值仍為0.這是一個(gè)混亂!即使線程最終將共享counter變量的值寫(xiě)回主存儲(chǔ)器,該值也將是錯(cuò)誤的。

什么時(shí)候揮發(fā)夠了?

正如我前面提到的,如果兩個(gè)線程都在讀取和寫(xiě)入共享變量,那么使用 volatile關(guān)鍵字是不夠的。 在這種情況下,您需要使用synchronized來(lái)保證變量的讀取和寫(xiě)入是原子的。讀取或?qū)懭雟olatile變量不會(huì)阻止線程讀取或?qū)懭?。為此,您必?code>synchronized 在關(guān)鍵部分代碼

作為synchronized塊的替代方法,您還可以使用java.util.concurrent包中提供的原子數(shù)據(jù)類(lèi)型。例如,AtomicLong或者 AtomicReference來(lái)避免競(jìng)爭(zhēng)條件。

  • 標(biāo)記為synchronized的邏輯變?yōu)橥綁K,在任何給定時(shí)間只允許一個(gè)線程執(zhí)行。

如果只有一個(gè)線程讀取和寫(xiě)入volatile變量的值,而其他線程只讀取變量,那么讀取線程將保證看到寫(xiě)入volatile變量的最新值。如果不使變量變?yōu)関olatile,則無(wú)法保證。

volatile關(guān)鍵字保證適用于32位和64位變量。

揮發(fā)性的性能考慮因素

讀取和寫(xiě)入volatile變量會(huì)導(dǎo)致變量被讀取或?qū)懭胫鞔鎯?chǔ)器。讀取和寫(xiě)入主內(nèi)存比訪問(wèn)CPU緩存更昂貴。訪問(wèn)volatile變量也會(huì)阻止指令重新排序,這是一種正常的性能增強(qiáng)技術(shù)。因此,在真正需要強(qiáng)制實(shí)施變量可見(jiàn)性時(shí),應(yīng)該只使用volatile變量。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容