Java之 volatile 關(guān)鍵字解析

本文對(duì)java中volatile關(guān)鍵字的作用及使用進(jìn)行了詳細(xì)介紹。

本文首發(fā):http://yuweiguocn.github.io/

《春曉》
春眠不覺曉,處處聞啼鳥。
夜來風(fēng)雨聲,花落知多少?
—唐,孟浩然

作用

volatile作用主要有兩個(gè),一是保證多線程環(huán)境下共享變量的可見性,二是禁止指令重排序。

緩存一致性

首先從計(jì)算機(jī)的內(nèi)存模型和Java內(nèi)存模型來分析下多線程環(huán)境下普通共享變量的可見性問題。

計(jì)算機(jī)內(nèi)存模型

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

我們運(yùn)行的程序會(huì)被編譯成指令放到CPU中運(yùn)行,程序在運(yùn)行過程中的臨時(shí)數(shù)據(jù)放在主存(物理內(nèi)存)中,由于CPU的運(yùn)算速度很快,但從主存數(shù)據(jù)讀取和寫入速度很慢,所以為了提高CPU運(yùn)算效率,在CPU中開辟了一塊存儲(chǔ)稱之為高速緩存。程序在運(yùn)行過程中,會(huì)將需要的數(shù)據(jù)復(fù)制一份到高速緩存,這樣CPU在執(zhí)行指令時(shí)會(huì)從高速緩存中讀取和寫入數(shù)據(jù),運(yùn)算過程結(jié)束會(huì)將數(shù)據(jù)從高速緩存中寫回到主存。

高速緩存在單線程中是沒問題的,但在多線程中可能會(huì)出現(xiàn)問題。在多核CPU 中,每條線程可能運(yùn)行于不同的 CPU 中,因此 每個(gè)線程運(yùn)行時(shí)有自己的高速緩存(對(duì)單核CPU來說,其實(shí)也會(huì)出現(xiàn)這種問題,只不過是以線程調(diào)度的形式來分別執(zhí)行的)。舉個(gè)例子,下面的代碼由兩個(gè)線程運(yùn)行,i 的初始值為0:

i = i + 1;

兩個(gè)線程分別讀取 i 的值到各自所在CPU的高速緩存中,線程1執(zhí)行加1操作后 i 的值為1,然后將 i 的值寫入到主存中,然后線程2執(zhí)行加1操作,由于線程2的高速緩存中 i 的值為0,所以執(zhí)行加1操作后 i 的值為1,線程2將 i 的值寫入到主存中,最終 i 的值是1而不是2,這就是著名的緩存一致性問題 。為了解決緩存不一致的問題,在硬件層面通常有兩種解決方案:一是緩存一致性協(xié)議,二是通過在總線加Lock#鎖的方式。這里我們不再深入介紹。

圖 計(jì)算機(jī)內(nèi)存模型

Java內(nèi)存模型

Java內(nèi)存模型規(guī)定所有的變量都是存在主存當(dāng)中(類似于前面說的物理內(nèi)存),每個(gè)線程都有自己的工作內(nèi)存(類似于前面的高速緩存)。線程對(duì)變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接對(duì)主存進(jìn)行操作。并且每個(gè)線程不能訪問其他線程的工作內(nèi)存。所以在Java內(nèi)存模型中同樣存在一致性問題。

圖 Java內(nèi)存模型抽象結(jié)構(gòu)示意圖

使用 volatile 保證可見性

對(duì)于可見性,Java提供了 volatile 關(guān)鍵字來保證可見性。當(dāng)一個(gè)共享變量被 volatile 修飾時(shí),它會(huì)保證修改的值會(huì)立即被更新到主存,當(dāng)有其他線程需要讀取時(shí),它會(huì)去內(nèi)存中讀取新值。而普通的共享變量不能保證可見性,因?yàn)槠胀ü蚕碜兞勘恍薷闹螅裁磿r(shí)候被寫入主存是不確定的,當(dāng)其他線程去讀取時(shí),此時(shí)內(nèi)存中可能還是原來的舊值,因此無法保證可見性。

來看一段代碼,假如線程1先執(zhí)行,線程2后執(zhí)行:

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

這段代碼是很典型的一段代碼,很多人在中斷線程時(shí)可能都會(huì)采用這種標(biāo)記辦法。但是事實(shí)上,這段代碼會(huì)完全運(yùn)行正確么?即一定會(huì)將線程中斷么?不一定,也許在大多數(shù)時(shí)候,這個(gè)代碼能夠把線程中斷,但是也有可能會(huì)導(dǎo)致無法中斷線程(雖然這個(gè)可能性很小,但是只要一旦發(fā)生這種情況就會(huì)造成死循環(huán)了)。
下面解釋一下這段代碼為何有可能導(dǎo)致無法中斷線程。在前面已經(jīng)解釋過,每個(gè)線程在運(yùn)行過程中都有自己的工作內(nèi)存,那么 線程1 在運(yùn)行的時(shí)候,會(huì)將 stop 變量的值拷貝一份放在自己的工作內(nèi)存當(dāng)中。
那么當(dāng) 線程2 更改了 stop 變量的值之后,但是還沒來得及寫入主存當(dāng)中, 線程2 轉(zhuǎn)去做其他事情了,那么 線程1 由于不知道 線程2 對(duì) stop 變量的更改,因此還會(huì)一直循環(huán)下去。但是用 volatile 修飾之后就變得不一樣了:

  • 使用 volatile 關(guān)鍵字會(huì)強(qiáng)制將修改的值立即寫入主存;
  • 使用 volatile 關(guān)鍵字的話,當(dāng) 線程2 進(jìn)行修改時(shí),會(huì)導(dǎo)致 線程1 的工作內(nèi)存中緩存變量 stop 的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對(duì)應(yīng)的緩存行無效);
  • 由于 線程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ì)等待緩存行對(duì)應(yīng)的主存地址被更新之后,然后去對(duì)應(yīng)的主存讀取最新的值。那么線程1讀取到的就是最新的正確的值。

指令重排序

指令重排序,一般來說,處理器為了提高程序運(yùn)行效率,可能會(huì)對(duì)輸入代碼進(jìn)行優(yōu)化,它不保證程序中各個(gè)語句的執(zhí)行先后順序同代碼中的順序一致,但是它會(huì)保證程序最終執(zhí)行結(jié)果和代碼順序執(zhí)行的結(jié)果是一致的。
volatile 關(guān)鍵字禁止指令重排序有兩層意思:

  • 當(dāng)程序執(zhí)行到 volatile 變量的讀操作或者寫操作時(shí),在其前面的操作的更改肯定全部已經(jīng)進(jìn)行,且結(jié)果已經(jīng)對(duì)后面的操作可見,在其后面的操作肯定還沒有進(jìn)行;
  • 在進(jìn)行指令優(yōu)化時(shí),不能將在對(duì) volatile 變量訪問的語句放在其后面執(zhí)行,也不能把 volatile 變量后面的語句放到其前面執(zhí)行。

對(duì)于使用雙重檢查鎖定實(shí)現(xiàn)單例的方式,單例的引用會(huì)聲明為volatile,這里的volatile有什么作用?

public class Singleton {
    private Singleton(){}

    private volatile static Singleton instance;

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

instance = new Singleton();通過這一行代碼創(chuàng)建一個(gè)對(duì)象,可以分解為如下的三行偽代碼:

memory = allocate();   //1:分配對(duì)象的內(nèi)存空間
ctorInstance(memory);  //2:初始化對(duì)象
instance = memory;     //3:設(shè)置instance指向剛分配的內(nèi)存地址

上面三行偽代碼中的2和3之間,可能會(huì)被重排序(在一些JIT編譯器上,這種重排序是真實(shí)發(fā)生的)2和3之間重排序之后的執(zhí)行時(shí)序如下:

memory = allocate();   //1:分配對(duì)象的內(nèi)存空間
instance = memory;     //3:設(shè)置instance指向剛分配的內(nèi)存地址
                       //注意,此時(shí)對(duì)象還沒有被初始化!
ctorInstance(memory);  //2:初始化對(duì)象

如果發(fā)生重排序,另一個(gè)并發(fā)執(zhí)行的線程B就有可能在第一次為空判斷instance時(shí)不為null。線程B接下來將訪問instance所引用的對(duì)象,但此時(shí)這個(gè)對(duì)象可能還沒有被A線程初始化!

當(dāng)聲明對(duì)象的引用為volatile后,上面的三行偽代碼中的2和3之間的重排序,在多線程環(huán)境中將會(huì)被禁止。

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

前面講述了源于volatile關(guān)鍵字的一些使用,下面我們來探討一volatile到底如何保證可見性和禁止指令重排序的。下面這段話摘自《深入理解Java虛擬機(jī)》:

觀察加入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)制將對(duì)緩存的修改操作立即寫入主存;
  • 如果是寫操作,它會(huì)導(dǎo)致其他CPU中對(duì)應(yīng)的緩存行無效。

參考

?著作權(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),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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

  • 吵過架還沒分手的情侶注定會(huì)長久 分過手又在一起的情侶注定會(huì)遺憾 韓朔再一次見到程雨含,是好多年以后了,這次...
    bed947f5fd07閱讀 660評(píng)論 2 9
  • 最近小程序被炒的火熱。很多人應(yīng)該已經(jīng)嘗試了微信小程序,我也抱著學(xué)習(xí)的態(tài)度,準(zhǔn)備研究一下。研究之后感覺還可以,如果有...
    范小飯_閱讀 3,261評(píng)論 0 13
  • (421) 晚上從理發(fā)店出來,突然電閃雷鳴,霎時(shí)間大雨傾盆,哈利路亞,天降甘霖,看著路人抱頭鼠竄,我淡定地?fù)纹鹩陚?..
    韓尚小閱讀 171評(píng)論 0 1

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