深入剖析volatile關(guān)鍵字

1.并發(fā)編程中的三個(gè)概念

在并發(fā)編程中,我們通常會(huì)遇到以下三個(gè)問題:原子性問題,可見性問題,有序性問題。我們先看具體看一下這三個(gè)概念:

原子性:即一個(gè)操作或者多個(gè)操作 要么全部執(zhí)行并且執(zhí)行的過程不會(huì)被任何因素打斷,要么就都不執(zhí)行。

可見性:是指當(dāng)多個(gè)線程訪問同一個(gè)變量時(shí),一個(gè)線程修改了這個(gè)變量的值,其他線程能夠立即看得到修改的值。

//線程1執(zhí)行的代碼
int i = 0;
i = 10;
//線程2執(zhí)行的代碼
j = I;

假若執(zhí)行線程1的是CPU1,執(zhí)行線程2的是CPU2。由上面的分析可知,當(dāng)線程1執(zhí)行 i =10這句時(shí),會(huì)先把i的初始值加載到CPU1的高速緩存中,然后賦值為10,那么在CPU1的高速緩存當(dāng)中i的值變?yōu)?0了,卻沒有立即寫入到主存當(dāng)中。此時(shí)線程2執(zhí)行 j = i,它會(huì)先去主存讀取i的值并加載到CPU2的緩存當(dāng)中,注意此時(shí)內(nèi)存當(dāng)中i的值還是0,那么就會(huì)使得j的值為0,而不是10.

這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。

有序性:即程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2
//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代碼中,由于語句1和語句2沒有數(shù)據(jù)依賴性,因此可能會(huì)被重排序。假如發(fā)生了重排序,在線程1執(zhí)行過程中先執(zhí)行語句2,而此是線程2會(huì)以為初始化工作已經(jīng)完成,那么就會(huì)跳出while循環(huán),去執(zhí)行doSomethingwithconfig(context)方法,而此時(shí)context并沒有被初始化,就會(huì)導(dǎo)致程序出錯(cuò)。
從上面可以看出,指令重排序不會(huì)影響單個(gè)線程的執(zhí)行,但是會(huì)影響到線程并發(fā)執(zhí)行的正確性。也就是說,要想并發(fā)程序正確地執(zhí)行,必須要保證原子性、可見性以及有序性。只要有一個(gè)沒有被保證,就有可能會(huì)導(dǎo)致程序運(yùn)行不正確。

2.volatile關(guān)鍵字的兩層語義

一旦一個(gè)共享變量(類的成員變量、類的靜態(tài)成員變量)被volatile修飾之后,那么就具備了兩層語義:

  • 保證了不同線程對這個(gè)變量進(jìn)行操作時(shí)的可見性,即一個(gè)線程修改了某個(gè)變量的值,這新值對其他線程來說是立即可見的。
  • 禁止進(jìn)行指令重排序。

假如線程1先執(zhí)行,線程2后執(zhí)行:

//線程1
boolean stop = false;
while(!stop){
    doSomething();
}
//線程2
stop = true;

使用volatile修飾之后:

  • 1、使用volatile關(guān)鍵字會(huì)強(qiáng)制將修改的值立即寫入主存;

  • 2、使用volatile關(guān)鍵字的話,當(dāng)線程2進(jìn)行修改時(shí),會(huì)導(dǎo)致線程1的工作內(nèi)存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應(yīng)的緩存行無效);

  • 3、由于線程1的工作內(nèi)存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時(shí)會(huì)去主存讀取。

那么在線程2修改stop值時(shí)(當(dāng)然這里包括2個(gè)操作,修改線程2工作內(nèi)存中的值,然后將修改后的值寫入內(nèi)存),會(huì)使得線程1的工作內(nèi)存中緩存變量stop的緩存行無效,然后線程1讀取時(shí),發(fā)現(xiàn)自己的緩存行無效,它會(huì)等待緩存行對應(yīng)的主存地址被更新之后,然后去對應(yīng)的主存讀取最新的值。線程1讀取到的就是最新的正確的值。

3.volatile保證內(nèi)存可見性

對于可見性,Java提供了volatile關(guān)鍵字來保證可見性。

當(dāng)一個(gè)共享變量被volatile修飾時(shí),它會(huì)保證修改的值會(huì)立即被更新到主存,當(dāng)有其他線程需要讀取時(shí),它會(huì)去內(nèi)存中讀取新值。

而普通的共享變量不能保證可見性,因?yàn)槠胀ü蚕碜兞勘恍薷闹?,什么時(shí)候被寫入主存是不確定的,當(dāng)其他線程去讀取時(shí),此時(shí)內(nèi)存中可能還是原來的舊值,因此無法保證可見性。

另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時(shí)刻只有一個(gè)線程獲取鎖然后執(zhí)行同步代碼,并且在釋放鎖之前會(huì)將對變量的修改刷新到主存當(dāng)中。因此可以保證可見性。

image.png

4.volatile禁止指令重排

volatile關(guān)鍵字提供內(nèi)存屏障的方式來防止指令被重排,編譯器在生成字節(jié)碼文件時(shí),會(huì)在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。

在Java內(nèi)存模型中說過,為了性能優(yōu)化,編譯器和處理器會(huì)進(jìn)行指令重排序;也就是說java程序天然的有序性可以總結(jié)為:如果在本線程內(nèi)觀察,所有的操作都是有序的;如果在一個(gè)線程觀察另一個(gè)線程,所有的操作都是無序的。在單例模式的實(shí)現(xiàn)上有一種雙重檢驗(yàn)鎖定的方式(Double-checked Locking)。代碼如下:

public class Singleton {
    private Singleton() { }
    private volatile static Singleton instance;
    public Singleton getInstance(){
        if(instance==null){
            synchronized (Singleton.class){
                if(instance==null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

這里為什么要加volatile了?我們先來分析一下不加volatile的情況,有問題的語句是這條:instance = new Singleton();
這條語句實(shí)際上包含了三個(gè)操作:

  • 1.分配對象的內(nèi)存空間;
  • 2.初始化對象;
  • 3.設(shè)置instance指向剛分配的內(nèi)存地址。但由于存在重排序的問題,可能有以下的執(zhí)行順序:
image

如果2和3進(jìn)行了重排序的話,線程B進(jìn)行判斷if(instance==null)時(shí)就會(huì)為true,而實(shí)際上這個(gè)instance并沒有初始化成功,顯而易見對線程B來說之后的操作就會(huì)是錯(cuò)的。而用volatile修飾的話就可以禁止2和3操作重排序,從而避免這種情況。volatile包含禁止指令重排序的語義,其具有有序性。

通過生成匯編代碼,可以清晰的看到加入volatile和未加入volatile的差別。volatile變量修飾的共享變量,在進(jìn)行寫操作的時(shí)候會(huì)多出一個(gè)lock前綴的匯編指令,這個(gè)指令會(huì)觸發(fā)總線鎖或者緩存鎖,通過緩存一致性協(xié)議來解決可見性問題。(可從Java內(nèi)存模型簡介了解緩存一致性協(xié)議)

0x01a3de1d:movb $0x0,0x1104800(%esi)  ; ...c6860048 100100
0x01a3de24:lock addl $0x0,(%esp)  ; ...f0830424 00

再比如下邊的例子

image.png

5.volatile如何保證有序性

  • 在分析保證有序性前,有必要了解一下內(nèi)存屏障。內(nèi)存屏障(Memory Barriers,Intel稱之Memory Fence)指令是指,重排序時(shí)不能把后面的指令重排序到內(nèi)存屏障之前的位置。CPU把內(nèi)存屏障分成三類:寫屏障(store barrier)、讀屏障(load barrier)和全屏障(Full Barrier)。

  • 寫屏障store barrier相當(dāng)于storestore barrier, 強(qiáng)制所有在storestore內(nèi)存屏障之前的所有執(zhí)行,都要在該內(nèi)存屏障之前執(zhí)行,并發(fā)送緩存失效的信號。所有在storestore barrier指令之后的store指令,都必須在storestore barrier屏障之前的指令執(zhí)行完后再被執(zhí)行。

讀屏障load barrier相當(dāng)于loadload barrier,強(qiáng)制所有在load barrier讀屏障之后的load指令,都在loadbarrier屏障之后執(zhí)行。

全屏障full barrier相當(dāng)于storeload,是一個(gè)全能型的屏障,因?yàn)樗瑫r(shí)具備前面兩種屏障的效果。強(qiáng)制了所有在storeload barrier之前的store/load指令,都在該屏障之前被執(zhí)行,所有在該屏障之后的的store/load指令,都在該屏障之后被執(zhí)行。

在JMM中把內(nèi)存屏障指令分為4類:

  • LoadLoad Barriers,load1 ; LoadLoad; load2 ,確保load1數(shù)據(jù)的裝載優(yōu)先于load2及所有后續(xù)裝載指令的裝載。
  • StoreStore Barriers,store1; storestore;store2 ,確保store1數(shù)據(jù)對其他處理器可見優(yōu)先于store2及所有后續(xù)存儲(chǔ)指令的存儲(chǔ)。
  • LoadStore Barries, load1;loadstore;store2,確保load1數(shù)據(jù)裝載優(yōu)先于store2以及后續(xù)的存儲(chǔ)指令刷新到內(nèi)存。
  • StoreLoad Barries, store1; storeload;load2, 確保store1數(shù)據(jù)對其他處理器變得可見, 優(yōu)先于load2及所有后續(xù)裝載指令的裝載;這條內(nèi)存屏障指令是一個(gè)全能型的屏障同時(shí)具有其他3條屏障的效果。

編譯器在生成字節(jié)碼時(shí),會(huì)在volatile指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個(gè)最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略。

  • 在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障。
  • 在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障。
  • 在每個(gè)volatile讀操作的前面插入一個(gè)LoadLoad屏障。
  • 在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障。

6.volatile無法保證原子性

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保證前面的線程都執(zhí)行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

自增操作不是原子性操作,而且volatile也無法保證對變量的任何操作都是原子性的。

把上面的代碼改成以下任何一種都可以達(dá)到效果:

(1)采用synchronized

public class Test {
    public  int inc = 0;
    
    public synchronized void increase() {
        inc++;
    }
    
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        
        while(Thread.activeCount()>1)  //保證前面的線程都執(zhí)行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

(2)采用Lock:

public class Test {
    public  int inc = 0;
    Lock lock = new ReentrantLock();
    
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }
    
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        
        while(Thread.activeCount()>1)  //保證前面的線程都執(zhí)行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

7.volatile的原理和實(shí)現(xiàn)機(jī)制

前面講述了源于volatile關(guān)鍵字的一些使用,下面我們來探討一下volatile到底如何保證可見性和禁止指令重排序的。

“觀察加入volatile關(guān)鍵字和沒有加入volatile關(guān)鍵字時(shí)所生成的匯編代碼發(fā)現(xiàn),加入volatile關(guān)鍵字時(shí),會(huì)多出一個(gè)lock前綴指令”lock前綴指令實(shí)際上相當(dāng)于一個(gè)內(nèi)存屏障(也成內(nèi)存柵欄),內(nèi)存屏障會(huì)提供3個(gè)功能:

  • 它確保指令重排序時(shí)不會(huì)把其后面的指令排到內(nèi)存屏障之前的位置,也不會(huì)把前面的指令排到內(nèi)存屏障的后面;即在執(zhí)行到內(nèi)存屏障這句指令時(shí),在它前面的操作已經(jīng)全部完成;
  • 它會(huì)強(qiáng)制將對緩存的修改操作立即寫入主存;
  • 如果是寫操作,它會(huì)導(dǎo)致其他CPU中對應(yīng)的緩存行無效。

8.volatile適用場景

  • synchronized關(guān)鍵字是防止多個(gè)線程同時(shí)執(zhí)行一段代碼,那么就會(huì)很影響程序執(zhí)行效率,而volatile關(guān)鍵字在某些情況下性能要優(yōu)于synchronized,是一種比synchronized 關(guān)鍵字更輕量級的同步機(jī)制。但是要注意volatile關(guān)鍵字是無法替代synchronized關(guān)鍵字的,因?yàn)関olatile關(guān)鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個(gè)條件:

    • 對變量的寫操作不依賴于當(dāng)前值
    • 該變量沒有包含在具有其他變量的不變式中
  • 實(shí)際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨(dú)立于任何程序的狀態(tài),包括變量的當(dāng)前狀態(tài)。上面的2個(gè)條件需要保證操作是原子性操作,才能保證使用volatile關(guān)鍵字的程序在并發(fā)時(shí)能夠正確執(zhí)行。

9.思維導(dǎo)圖如下:

詳情介紹:

更新主題詳情

420天以來,Java架構(gòu)更新了 888個(gè)主題,已經(jīng)有156+位同學(xué)加入。微信掃碼關(guān)注java架構(gòu),獲取Java面試題和架構(gòu)師相關(guān)題目和視頻。上述相關(guān)面試題答案,盡在Java架構(gòu)中。

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

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

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