深入理解 volatile 關鍵字原理

1. 基本概念

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

內存可見性

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

注意: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 關鍵字就能夠實現(xiàn)對 int 變量的原子操作,事實并非這樣。上面代碼每次運行的結果都不相同。看望上面這些基本概念,下面就開始深入理解下 volatile 這個關鍵字吧。

2. JVM 內存模型

2.1 可見性

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

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 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存來完成。(隨著虛擬機的優(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;
    }
}

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

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 關鍵字,普通變量照樣能夠實現(xiàn)內存可見性,程序也能夠正常退出。代碼如下:

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

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

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

4. 深入理解指令重排序和內存屏障

4.1 as-if-serial 語義

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

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

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

拿個簡單例子來說:

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

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

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

4.2 指令重排序(happens-before)

重排序的規(guī)則

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

★2. 監(jiān)視器鎖定規(guī)則(Monitor Lock Rule):一個unlock操作先行發(fā)生于后面對同一個對象鎖的lock操作。這里強調的是同一個鎖,而“后面”指的是時間上的先后順序,如發(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()方法結束、Thread.isAlive()的返回值檢測到線程已經終止執(zhí)行。

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

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

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

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

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

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

public class SimpleHappenBefore {
    /** 這是一個驗證結果的變量 */
    private static int a=0;
    /** 這是一個標志位 */
    private static boolean flag=false;
    public static void main(String[] args) throws InterruptedException {
        //由于多線程情況下未必會試出重排序的結論,所以多試一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
            //這里等待線程結束后,重置共享變量,以使驗證結果的工作變得簡單些.
            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ù)據依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數(shù)據依賴關系,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什么效果?請看下面的程序執(zhí)行時序圖:


image

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

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

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

image

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

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

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

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

image
image

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

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

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

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

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

    v.afield = 1; x.finalField = v; ... ; 構建方法邊界sharedRef = x;

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

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

    例如對于如下語句

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

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

4.3 內存屏障

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

內存屏障可以被分為以下幾種類型

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

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

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

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

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

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

image
image

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

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