深入理解 volatile 關鍵字原理

1. 基本概念

volatile 關鍵字,具有兩個特性:1. 內(nèi)存的可見性, 2. 禁止指令重排序優(yōu)化。

內(nèi)存可見性

被 volatile 關鍵字修飾的變量,當線程要對這個變量執(zhí)行的寫操作,都不會寫入本地緩存,而是直接刷入主內(nèi)存中。當線程讀取被 volatile 關鍵字修飾的變量時,也是直接從主內(nèi)存中讀取。

注意:volatile 不能保證原子性。很多時候都會誤用。

下面是問題代碼:

public class VolatileDemo {

    static volatile int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    VolatileDemo.inc();
                }
            }).start();
        }
        System.out.println(VolatileDemo.count);
    }

    static void inc() {
        count++;
    }
}

很多人以為加上了 volatile 關鍵字就能夠?qū)崿F(xiàn)對 int 變量的原子操作,事實并非這樣。上面代碼每次運行的結(jié)果都不相同。看望上面這些基本概念,下面就開始深入理解下 volatile 這個關鍵字吧。

2. JVM 內(nèi)存模型

2.1 可見性

可見性,是指線程之間的可見性,一個線程修改的狀態(tài)對另一個線程是可見的。也就是一個線程修改的結(jié)果。另一個線程馬上就能看到。比如:用 volatile 修飾的變量,就會具有可見性。volatile 修飾的變量不允許線程內(nèi)部緩存和重排序,即直接修改內(nèi)存。所以對其他線程是可見的。但是這里需要注意一個問題,volatile 只能讓被他修飾內(nèi)容具有可見性,但不能保證它具有原子性。

2.2 原子性

原子是世界上的最小單位,具有不可分割性。比如 a=0;(a 非 long 和 double 類型) 這個操作是不可分割的,那么我們說這個操作時原子操作。再比如:a++; 這個操作實際是 a = a + 1;是可分割的,所以他不是一個原子操作。java 的 concurrent 包下提供了一些原子類,我們可以通過閱讀 API 來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

2.3 有序性

Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 是因為其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規(guī)則獲得的,此規(guī)則決定了持有同一個對象鎖的兩個同步塊只能串行執(zhí)行。

3. volatile 原理

當一個變量定義為 volatile 之后,將具備兩種特性:

  1. 保證此變量對所有的線程的可見性,這里的“可見性”,如本文開頭所述,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內(nèi)存,以及每次使用前立即從主內(nèi)存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內(nèi)存來完成。(隨著虛擬機的優(yōu)化,普通變量也可以具有可見性了,下面來看一個代碼)
public class VolatileDemo extends Thread {

    static boolean flag = false;

    @Override
    public void run() {
        while (!flag) {}
    }

    public static void main(String[] args) throws InterruptedException {
        new VolatileDemo().start();
        // 等一段時間,目的是為了能夠讓線程啟動并進入到 run 方法里
        TimeUnit.MILLISECONDS.sleep(300);
        flag = true;
    }
}

上面這段代碼永遠不會結(jié)束,因為對 flag 的修改是在 main 線程的本地工作內(nèi)存中的,flag 的值對其他線程不可見。對 flag 加上 volatile 修飾符在做測試,程序能夠正常結(jié)束退出。

public class VolatileDemo extends Thread {

    static volatile boolean flag = false;

    @Override
    public void run() {
        while (!flag) {}
    }

    public static void main(String[] args) throws InterruptedException {
        new VolatileDemo().start();
        // 等一段時間,目的是為了能夠讓線程啟動并進入到 run 方法里
        TimeUnit.MILLISECONDS.sleep(300);
        flag = true;
    }
}

但是對上面這段代碼在稍作修改,發(fā)現(xiàn)其實也可以不用 volatile 關鍵字,普通變量照樣能夠?qū)崿F(xiàn)內(nèi)存可見性,程序也能夠正常退出。代碼如下:

public class VolatileDemo extends Thread {

    static boolean flag = false;

    @Override
    public void run() {
        while (!flag) { System.out.println(1); }
    }

    public static void main(String[] args) throws InterruptedException {
        new VolatileDemo().start();
        // 等一段時間,目的是為了能夠讓線程啟動并進入到 run 方法里
        TimeUnit.MILLISECONDS.sleep(300);
        flag = true;
    }
}

這是什么原因呢?原來只有在對變量讀取頻率很高的情況下,虛擬機才不會及時回寫主內(nèi)存,而當頻率沒有達到虛擬機認為的高頻率時,普通變量和volatile是同樣的處理邏輯。如在每個循環(huán)中執(zhí)行System.out.println(1)加大了讀取變量的時間間隔,使虛擬機認為讀取頻率并不那么高,所以實現(xiàn)了和volatile的效果。

  1. 禁止指令重排序優(yōu)化。有volatile修飾的變量,賦值后多執(zhí)行了一個 “l(fā)oad addl $0x0, (%esp)” 操作,這個操作相當于一個內(nèi)存屏障(指令重排序時不能把后面的指令重排序到內(nèi)存屏障之前的位置),只有一個 CPU 訪問內(nèi)存時,并不需要內(nèi)存屏障

4. 深入理解指令重排序和內(nèi)存屏障

4.1 as-if-serial 語義

as-if-serial 的語義是:不管怎么重排序,單線程程序的執(zhí)行結(jié)果不能被改變。編譯器、runtime和處理器都必須遵守“as-if-serial”語義。

為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關系的操作做重排序,因為這種重排序會改變執(zhí)行結(jié)果。

但是,如果操作之間不存在數(shù)據(jù)依賴關系,這些操作就可能被編譯器和處理器重排序。

拿個簡單例子來說:

public void execute(){
    int a=0;
    int b=1;
    int c=a+b;
}

這里a=0,b=1兩句可以隨便排序,不影響程序邏輯結(jié)果,但c=a+b這句必須在前兩句的后面執(zhí)行。

as-if-serial 語義把單線程程序保護了起來,遵守 as-if-serial 語義的編譯器、runtime 和處理器共同為編寫單線程程序的程序員創(chuàng)建了一個幻覺:單線程程序是按程序的順序來執(zhí)行的。as-if-serial 語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內(nèi)存可見性問題。

4.2 指令重排序(happens-before)

重排序的規(guī)則

★1. 程序次序規(guī)則(Program Order Rule):在一個線程內(nèi),按照代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作。準確地說應該是控制流順序而不是代碼順序,因為要考慮分支、循環(huán)等結(jié)構(gòu)。

★2. 監(jiān)視器鎖定規(guī)則(Monitor Lock Rule):一個unlock操作先行發(fā)生于后面對同一個對象鎖的lock操作。這里強調(diào)的是同一個鎖,而“后面”指的是時間上的先后順序,如發(fā)生在其他線程中的lock操作。

★3. volatile變量規(guī)則(Volatile Variable Rule):對一個volatile變量的寫操作發(fā)生于后面對這個變量的讀操作,這里的“后面”也指的是時間上的先后順序。

  1. 線程啟動規(guī)則(Thread Start Rule):Thread獨享的start()方法先行于此線程的每一個動作。

  2. 線程終止規(guī)則(Thread Termination Rule):線程中的每個操作都先行發(fā)生于對此線程的終止檢測,我們可以通過Thread.join()方法結(jié)束、Thread.isAlive()的返回值檢測到線程已經(jīng)終止執(zhí)行。

  3. 線程中斷規(guī)則(Thread Interruption Rule):對線程interrupte()方法的調(diào)用優(yōu)先于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過Thread.interrupted()方法檢測線程是否已中斷。

  4. 對象終結(jié)原則(Finalizer Rule):一個對象的初始化完成(構(gòu)造函數(shù)執(zhí)行結(jié)束)先行發(fā)生于它的finalize()方法的開始。

★8. 傳遞性(Transitivity):如果操作A先行發(fā)生于操作B,操作B先行發(fā)生于操作C,那就可以得出操作A先行發(fā)生于操作C的結(jié)論。

如果我們的多線程程序依賴于代碼書寫順序,那么就要考慮是否符合以上規(guī)則,如果不符合就要通過一些機制使其符合,最常用的就是synchronized、Lock以及volatile修飾符。

值得注意的是:兩個操作之間具有happens-before關系,并不意味著前一個操作必須要在后一個操作之前執(zhí)行! happens-before 僅僅要求前一個操作(執(zhí)行的結(jié)果)對后一個操作可見,且前一個操作按順序排在第二個操作之前。

舉一個例子來理解重排序,看下面的代碼:

public class SimpleHappenBefore {
    /** 這是一個驗證結(jié)果的變量 */
    private static int a=0;
    /** 這是一個標志位 */
    private static boolean flag=false;
    public static void main(String[] args) throws InterruptedException {
        //由于多線程情況下未必會試出重排序的結(jié)論,所以多試一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
            //這里等待線程結(jié)束后,重置共享變量,以使驗證結(jié)果的工作變得簡單些.
            threadA.join();
            threadB.join();
            a=0;
            flag=false;
        }
    }
    static class ThreadA extends Thread{
        public void run(){
            a=1;            //1
            flag=true;      //2
        }
    }
    static class ThreadB extends Thread{
        public void run(){
            if(flag){       //3
                a=a*1;      //4
            }
            if(a==0){
                System.out.println("ha,a==0");
            }
        }
    }
}

flag 變量是個標記,用來標識變量 a 是否已被寫入。這里假設有兩個線程 A 和 B,A 首先執(zhí)行 writer() 方法,隨后 B 線程接著執(zhí)行 reader() 方法。線程 B 在執(zhí)行操作 4 時,能否看到線程 A 在操作 1 對共享變量 a 的寫入?

答案是:不一定能看到。

由于操作1和操作2沒有數(shù)據(jù)依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數(shù)據(jù)依賴關系,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產(chǎn)生什么效果?請看下面的程序執(zhí)行時序圖:


image

如上圖所示,操作1和操作2做了重排序。程序執(zhí)行時,線程A首先寫標記變量flag,隨后線程B讀這個變量。由于條件判斷為真,線程B將讀取變量a。此時,變量a還根本沒有被線程A寫入,在這里多線程程序的語義被重排序破壞了!

※注:本文統(tǒng)一用紅色的虛箭線表示錯誤的讀操作,用綠色的虛箭線表示正確的讀操作。

下面再讓我們看看,當操作3和操作4重排序時會產(chǎn)生什么效果(借助這個重排序,可以順便說明控制依賴性)。下面是操作3和操作4重排序后,程序的執(zhí)行時序圖:

image

在程序中,操作3和操作4存在控制依賴關系。當代碼中存在控制依賴性時,會影響指令序列執(zhí)行的并行度。為此,編譯器和處理器會采用猜測(Speculation)執(zhí)行來克服控制相關性對并行度的影響。以處理器的猜測執(zhí)行為例,執(zhí)行線程B的處理器可以提前讀取并計算a*a,然后把計算結(jié)果臨時保存到一個名為重排序緩沖(reorder buffer ROB)的硬件緩存中。當接下來操作3的條件判斷為真時,就把該計算結(jié)果寫入變量i中。

從圖中我們可以看出,猜測執(zhí)行實質(zhì)上對操作3和4做了重排序。重排序在這里破壞了多線程程序的語義!

除此之外,Java內(nèi)存模型對volatile和final的語義做了擴展。對volatile語義的擴展保證了volatile變量在一些情況下不會重排序,volatile的64位變量double和long的讀取和賦值操作都是原子的。對final語義的擴展保證一個對象的構(gòu)建方法結(jié)束前,所有final成員變量都必須完成初始化(的前提是沒有this引用溢出)。

Java內(nèi)存模型關于重排序的規(guī)定,總結(jié)后如下表所示。

image
image

表中“第二項操作”的含義是指,第一項操作之后的所有指定操作。如,普通讀不能與其之后的所有volatile寫重排序。另外,JMM也規(guī)定了上述volatile和同步塊的規(guī)則盡適用于存在多線程訪問的情景。例如,若編譯器(這里的編譯器也包括JIT,下同)證明了一個volatile變量只能被單線程訪問,那么就可能會把它做為普通變量來處理。

留白的單元格代表允許在不違反Java基本語義的情況下重排序。例如,編譯器不會對對同一內(nèi)存地址的讀和寫操作重排序,但是允許對不同地址的讀和寫操作重排序。

除此之外,為了保證final的新增語義。JSR-133對于final變量的重排序也做了限制。

  • 構(gòu)建方法內(nèi)部的final成員變量的存儲,并且,假如final成員變量本身是一個引用的話,這個final成員變量可以引用到的一切存儲操作,都不能與構(gòu)建方法外的將當期構(gòu)建對象賦值于多線程共享變量的存儲操作重排序。

    例如對于如下語句
    x.finalField = v; ... ;構(gòu)建方法邊界sharedRef = x;

    v.afield = 1; x.finalField = v; ... ; 構(gòu)建方法邊界sharedRef = x;

    這兩條語句中,構(gòu)建方法邊界前后的指令都不能重排序。

  • 初始讀取共享對象與初始讀取該共享對象的final成員變量之間不能重排序。

    例如對于如下語句

    x = sharedRef; ... ; i = x.finalField;
    前后兩句語句之間不會發(fā)生重排序。

    由于這兩句語句有數(shù)據(jù)依賴關系,編譯器本身就不會對它們重排序,但確實有一些處理器會對這種情況重排序,因此特別制定了這一規(guī)則。

4.3 內(nèi)存屏障

內(nèi)存屏障(Memory Barrier,或有時叫做內(nèi)存柵欄,Memory Fence)是一種CPU指令,用于控制特定條件下的重排序和內(nèi)存可見性問題。Java編譯器也會根據(jù)內(nèi)存屏障的規(guī)則禁止重排序。

內(nèi)存屏障可以被分為以下幾種類型

  1. LoadLoad屏障:對于這樣的語句Load1; LoadLoad; Load2,在Load2及后續(xù)讀取操作要讀取的數(shù)據(jù)被訪問前,保證Load1要讀取的數(shù)據(jù)被讀取完畢。

  2. StoreStore屏障:對于這樣的語句Store1; StoreStore; Store2,在Store2及后續(xù)寫入操作執(zhí)行前,保證Store1的寫入操作對其它處理器可見。

  3. LoadStore屏障:對于這樣的語句Load1; LoadStore; Store2,在Store2及后續(xù)寫入操作被刷出前,保證Load1要讀取的數(shù)據(jù)被讀取完畢。

  4. StoreLoad屏障:對于這樣的語句Store1; StoreLoad; Load2,在Load2及后續(xù)所有讀取操作執(zhí)行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。 在大多數(shù)處理器的實現(xiàn)中,這個屏障是個萬能屏障,兼具其它三種內(nèi)存屏障的功能。

有的處理器的重排序規(guī)則較嚴,無需內(nèi)存屏障也能很好的工作,Java編譯器會在這種情況下不放置內(nèi)存屏障。

為了實現(xiàn)上一章中討論的JSR-133的規(guī)定,Java編譯器會這樣使用內(nèi)存屏障。

image
image

為了保證final字段的特殊語義,也會在下面的語句加入內(nèi)存屏障。

x.finalField = v; StoreStore; sharedRef = x;

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,117評論 6 537
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,860評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,128評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,291評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,025評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,421評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,477評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,642評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,177評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,970評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,157評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,717評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,410評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,821評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,053評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,896評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,157評論 2 375

推薦閱讀更多精彩內(nèi)容

  • 從三月份找實習到現(xiàn)在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發(fā)崗...
    時芥藍閱讀 42,329評論 11 349
  • 前言 在 CSDN 的密碼事件爆發(fā)之前,我都是用的一個密碼,所以出事之后換了方案一。 方案一 第一批密碼的規(guī)則是類...
    co233閱讀 1,161評論 0 0
  • 時間並沒有消逝 只是移動到另一個地方 誰都沒有離去 只是到另一個空間遊戲 空氣中的質(zhì)量 早就告訴我們這個袐密 我們...
    蔡振源閱讀 203評論 0 1
  • 在將來某天,可能你會在超市遇見以前的某個人,你的目光當然會下意識地看過去。在你的記憶中,那個人可能在你記憶...
    Yech_Matthew閱讀 138評論 0 0
  • 原文地址前些日子一直在做一些微信公眾號的H5開發(fā),在開發(fā)過程中遇到了不少坑。記錄一下: 視頻Android版的視頻...
    趙的拇指閱讀 599評論 0 0