­

反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

悟空
愛學習的程式猿,自主開發了Java學習平台、PMP刷題小程式。目前主修Java、多執行緒、SpringBoot、SpringCloud、k8s。本公眾號不限於分享技術,也會分享工具的使用、人生感悟、讀書總結。

絮叨

這一篇也算是Java並發編程的開篇,看了很多資料,但是輪到自己去整理去總結的時候,發現還是要多看幾遍資料才能完全理解。還有一個很重要的點就是,畫圖是加深印象和檢驗自己是否理解的一個非常好的方法。

一、Volatile怎麼念?

volatile怎麼念

看到這個單詞一直不知道怎麼發音

英 [ˈvɒlətaɪl]  美 [ˈvɑːlətl]

adj. [化學] 揮發性的;不穩定的;爆炸性的;反覆無常的

那Java中volatile又是幹啥的呢?

二、Java中volatile用來幹啥?

  • Volatile是Java虛擬機提供的輕量級的同步機制(三大特性)
    • 保證可見性
    • 不保證原子性
    • 禁止指令重排

要理解三大特性,就必須知道Java記憶體模型(JMM),那JMM又是什麼呢?

volatile怎麼念

三、JMM又是啥?

這是一份精心總結的Java記憶體模型思維導圖,拿去不謝。

拿走不謝

原理圖1-Java記憶體模型

3.1 為什麼需要Java記憶體模型?

Why:屏蔽各種硬體和作業系統的記憶體訪問差異

JMM是Java記憶體模型,也就是Java Memory Model,簡稱JMM,本身是一種抽象的概念,實際上並不存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括實例欄位,靜態欄位和構成數組對象的元素)的訪問方式。

3.2 到底什麼是Java記憶體模型?

  • 1.定義程式中各種變數的訪問規則
  • 2.把變數值存儲到記憶體的底層細節
  • 3.從記憶體中取出變數值的底層細節

3.3 Java記憶體模型的兩大記憶體是啥?

原理圖2-兩大記憶體

  • 主記憶體
    • Java堆中對象實例數據部分
    • 對應於物理硬體的記憶體
  • 工作記憶體
    • Java棧中的部分區域
    • 優先存儲於暫存器和高速快取

3.4 Java記憶體模型是怎麼做的?

Java記憶體模型的幾個規範:

  • 1.所有變數存儲在主記憶體

  • 2.主記憶體是虛擬機記憶體的一部分

  • 3.每條執行緒有自己的工作記憶體

  • 4.執行緒的工作記憶體保存變數的主記憶體副本

  • 5.執行緒對變數的操作必須在工作記憶體中進行

  • 6.不同執行緒之間無法直接訪問對方工作記憶體中的變數

  • 7.執行緒間變數值的傳遞均需要通過主記憶體來完成

由於JVM運行程式的實體是執行緒,而每個執行緒創建時JVM都會為其創建一個工作記憶體(有些地方稱為棧空間),工作記憶體是每個執行緒的私有數據區域,而Java記憶體模型中規定所有變數都存儲在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫會主記憶體,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中存儲著主記憶體中的變數副本拷貝,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程:

原理圖3-Java記憶體模型

3.5 Java記憶體模型的三大特性

  • 可見性(當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改)
  • 原子性(一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗)
  • 有序性(變數賦值操作的順序與程式程式碼中的執行順序一致)

關於有序性:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指「執行緒內似表現為串列的語義」(Within-Thread As-If-Serial Semantics),後半句是指「指令重排序」現象和「工作記憶體與主記憶體同步延遲」現象。

四、能給個示例說下怎麼用volatile的嗎?

考慮一下這種場景:

有一個對象的欄位number初始化值=0,另外這個對象有一個公共方法setNumberTo100()可以設置number = 100,當主執行緒通過子執行緒來調用setNumberTo100()後,主執行緒是否知道number值變了呢?

答案:如果沒有使用volatile來定義number變數,則主執行緒不知道子執行緒更新了number的值。

(1)定義如上述所說的對象:ShareData

class ShareData {
    int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}

(2)主執行緒中初始化一個子執行緒,名字叫做子執行緒

子執行緒先休眠3s,然後設置number=100。主執行緒不斷檢測的number值是否等於0,如果不等於0,則退出主執行緒。

public class volatileVisibility {
    public static void main(String[] args) {
        // 資源類
        ShareData shareData = new ShareData();

        // 子執行緒 實現了Runnable介面的,lambda表達式
        new Thread(() -> {

            System.out.println(Thread.currentThread().getName() + "\t come in");

            // 執行緒睡眠3秒,假設在進行運算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            myData.setNumberTo100();

            // 輸出修改後的值
            System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);

        }, "子執行緒").start();

        while(myData.number == 0) {
            // main執行緒就一直在這裡等待循環,直到number的值不等於零
        }

        // 按道理這個值是不可能列印出來的,因為主執行緒運行的時候,number的值為0,所以一直在循環
        // 如果能輸出這句話,說明子執行緒在睡眠3秒後,更新的number的值,重新寫入到主記憶體,並被main執行緒感知到了
        System.out.println(Thread.currentThread().getName() + "\t 主執行緒感知到了 number 不等於 0");

        /**
         * 最後輸出結果:
         * 子執行緒     come in
         * 子執行緒     update number value:100
         * 最後執行緒沒有停止,並行沒有輸出"主執行緒知道了 number 不等於0"這句話,說明沒有用volatile修飾的變數,變數的更新是不可見的
         */
    }
}

沒有使用volatile

(3)我們用volatile修飾變數number

class ShareData {
    //volatile 修飾的關鍵字,是為了增加多個執行緒之間的可見性,只要有一個執行緒修改了記憶體中的值,其它執行緒也能馬上感知
    volatile int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}

輸出結果:

子執行緒	 come in
子執行緒	 update number value:100
main	 主執行緒知道了 number 不等於 0

Process finished with exit code 0

mark

小結:說明用volatile修飾的變數,當某執行緒更新變數後,其他執行緒也能感知到。

五、那為什麼其他執行緒能感知到變數更新?

mark

其實這裡就是用到了「窺探(snooping)」協議。在說「窺探(snooping)」協議之前,首先談談快取一致性的問題。

5.1 快取一致性

當多個CPU持有的快取都來自同一個主記憶體的拷貝,當有其他CPU偷偷改了這個主記憶體數據後,其他CPU並不知道,那拷貝的記憶體將會和主記憶體不一致,這就是快取不一致。那我們如何來保證快取一致呢?這裡就需要作業系統來共同制定一個同步規則來保證,而這個規則就有MESI協議。

如下圖所示,CPU2 偷偷將num修改為2,記憶體中num也被修改為2,但是CPU1和CPU3並不知道num值變了。

原理圖4-快取一致性1

5.2 MESI

當CPU寫數據時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,系統會發出訊號通知其它CPU將該記憶體變數的快取行設置為無效。如下圖所示,CPU1和CPU3 中num=1已經失效了。

原理圖5-快取一致性2

當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。

如下圖所示,CPU1和CPU3發現快取的num值失效了,就重新從記憶體讀取,num值更新為2。

原理圖6-快取一致性3

5.3 匯流排嗅探

那其他CPU是怎麼知道要將快取更新為失效的呢?這裡是用到了匯流排嗅探技術。

每個CPU不斷嗅探匯流排上傳播的數據來檢查自己快取值是否過期了,如果處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設置為無效狀態,當處理器對這個數據進行修改操作的時候,會重新從記憶體中把數據讀取到處理器快取中。

原理圖7-快取一致性4

5.4 匯流排風暴

匯流排嗅探技術有哪些缺點?

由於MESI快取一致性協議,需要不斷對主線進行記憶體嗅探,大量的交互會導致匯流排頻寬達到峰值。因此不要濫用volatile,可以用鎖來替代,看場景啦~

六、能演示下volatile為什麼不保證原子性嗎?

原子性:一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗。

這個定義和volatile啥關係呀,完全不能理解呀?Show me the code!

考慮一下這種場景:

當20個執行緒同時給number自增1,執行1000次以後,number的值為多少呢?

在單執行緒的場景,答案是20000,如果是多執行緒的場景下呢?答案是可能是20000,但很多情況下都是小於20000。

示例程式碼:

package com.jackson0714.passjava.threads;

/**
 演示volatile 不保證原子性
 * @create: 2020-08-13 09:53
 */

public class VolatileAtomicity {
    public static volatile int number = 0;

    public static void increase() {
        number++;
    }

    public static void main(String[] args) {

        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            }, String.valueOf(i)).start();
        }

        // 當所有累加執行緒都結束
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(number);
    }
}

執行結果:第一次19144,第二次20000,第三次19378。

volatile第一次執行結果

volatile第二次執行結果

volatile第三次執行結果

我們來分析一下increase()方法,通過反編譯工具javap得到如下彙編程式碼:

  public static void increase();
    Code:
       0: getstatic     #2                  // Field number:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field number:I
       8: return

number++其實執行了3條指令

getstatic:拿number的原始值
iadd:進行加1操作
putfield:把加1後的值寫回

執行了getstatic指令number的值取到操作棧頂時,volatile關鍵字保證了number的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他執行緒可能已經把number的值改變了,而操作棧頂的值就變成了過期的數據,所以putstatic指令執行後就可能把較小的number值同步回主記憶體之中。

總結如下:

在執行number++這行程式碼時,即使使用volatile修飾number變數,在執行期間,還是有可能被其他執行緒修改,沒有保證原子性。

七、怎麼保證輸出結果是20000呢?

7.1 synchronized同步程式碼塊

我們可以通過使用synchronized同步程式碼塊來保證原子性。從而使結果等於20000

public synchronized static void increase() {
   number++;
}

synchronized同步程式碼塊執行結果

但是使用synchronized太重了,會造成阻塞,只有一個執行緒能進入到這個方法。我們可以使用Java並發包(JUC)中的AtomicInterger工具包。

7.2 AtomicInterger原子性操作

我們來看看AtomicInterger原子自增的方法getAndIncrement()

AtomicInterger

public static AtomicInteger atomicInteger = new AtomicInteger();

public static void main(String[] args) {

    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                atomicInteger.getAndIncrement();
            }
        }, String.valueOf(i)).start();
    }

    // 當所有累加執行緒都結束
    while(Thread.activeCount() > 2) {
        Thread.yield();
    }

    System.out.println(atomicInteger);
}

多次運行的結果都是20000。

getAndIncrement的執行結果

八、禁止指令重排又是啥?

說到指令重排就得知道為什麼要重排,有哪幾種重排。

如下圖所示,指令執行順序是按照1>2>3>4的順序,經過重排後,執行順序更新為指令3->4->2->1。

原理圖8-指令重排

會不會感覺到重排把指令順序都打亂了,這樣好嗎?

可以回想下小學時候的數學題:2+3-5=?,如果把運算順序改為3-5+2=?,結果也是一樣的。所以指令重排是要保證單執行緒下程式結果不變的情況下做重排。

8.1 為什麼要重排

電腦在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排序。

8.2 有哪幾種重排

  • 1.編譯器優化重排:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 2.指令級的並行重排:現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 3.記憶體系統的重排:由於處理器使用快取和讀/寫緩衝區,這使得載入和存儲操作看上去可能是在亂序執行。

原理圖9-三種重排

注意:

  • 單執行緒環境裡面確保最終執行結果和程式碼順序的結果一致

  • 處理器在進行重排序時,必須要考慮指令之間的數據依賴性

  • 多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。

8.3 舉個例子來說說多執行緒中的指令重排?

設想一下這種場景:定義了變數num=0和變數flag=false,執行緒1調用初始化函數init()執行後,執行緒調用add()方法,當另外執行緒判斷flag=true後,執行num+100操作,那麼我們預期的結果是num會等於101,但因為有指令重排的可能,num=1和flag=true執行順序可能會顛倒,以至於num可能等於100

public class VolatileResort {
    static int num = 0;
    static boolean flag = false;
    public static void init() {
        num= 1;
        flag = true;
    }
    public static void add() {
        if (flag) {
            num = num + 5;
            System.out.println("num:" + num);
        }
    }
    public static void main(String[] args) {
        init();
        new Thread(() -> {
            add();
        },"子執行緒").start();
    }
}

先看執行緒1中指令重排:

num= 1;flag = true; 的執行順序變為 flag=true;num = 1;,如下圖所示的時序圖

原理圖10-執行緒1指令重排

如果執行緒2 num=num+5 在執行緒1設置num=1之前執行,那麼執行緒2的num變數值為5。如下圖所示的時序圖。

原理圖11-執行緒2在num=1之前執行

8.4 volatile怎麼實現禁止指令重排?

我們使用volatile定義flag變數:

static volatile boolean flag = false;

如何實現禁止指令重排:

原理:在volatile生成的指令序列前後插入記憶體屏障(Memory Barries)來禁止處理器重排序。

有如下四種記憶體屏障:

四種記憶體屏障

volatile寫的場景如何插入記憶體屏障:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障(寫-寫 屏障)。

  • 在每個volatile寫操作的後面插入一個StoreLoad屏障(寫-讀 屏障)。

原理圖12-volatile寫的場景如何插入記憶體屏障

StoreStore屏障可以保證在volatile寫(flag賦值操作flag=true)之前,其前面的所有普通寫(num的賦值操作num=1) 操作已經對任意處理器可見了,保障所有普通寫在volatile寫之前刷新到主記憶體。

volatile讀場景如何插入記憶體屏障:

  • 在每個volatile讀操作的後面插入一個LoadLoad屏障(讀-讀 屏障)。

  • 在每個volatile讀操作的後面插入一個LoadStore屏障(讀-寫 屏障)。

原理圖13-volatile讀場景如何插入記憶體屏障

LoadStore屏障可以保證其後面的所有普通寫(num的賦值操作num=num+5) 操作必須在volatile讀(if(flag))之後執行。

十、volatile常見應用

這裡舉一個應用,雙重檢測鎖定的單例模式

package com.jackson0714.passjava.threads;
/**
 演示volatile 單例模式應用(雙邊檢測)
 * @author: 悟空聊架構
 * @create: 2020-08-17
 */

class VolatileSingleton {
    private static VolatileSingleton instance = null;
    private VolatileSingleton() {
        System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
    }
    public static VolatileSingleton getInstance() {
        // 第一重檢測
        if(instance == null) {
            // 鎖定程式碼塊
            synchronized (VolatileSingleton.class) {
                // 第二重檢測
                if(instance == null) {
                    // 實例化對象
                    instance = new VolatileSingleton();
                }
            }
        }
        return instance;
    }
}

程式碼看起來沒有問題,但是 instance = new VolatileSingleton();其實可以看作三條偽程式碼:

memory = allocate(); // 1、分配對象記憶體空間
instance(memory); // 2、初始化對象
instance = memory; // 3、設置instance指向剛剛分配的記憶體地址,此時instance != null

步驟2 和 步驟3之間不存在 數據依賴關係,而且無論重排前 還是重排後,程式的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。

memory = allocate(); // 1、分配對象記憶體空間
instance = memory; // 3、設置instance指向剛剛分配的記憶體地址,此時instance != null,但是對象還沒有初始化完成
instance(memory); // 2、初始化對象

如果另外一個執行緒執行:if(instance == null) 時,則返回剛剛分配的記憶體地址,但是對象還沒有初始化完成,拿到的instance是個假的。如下圖所示:

原理圖14-雙重檢鎖存在的並發問題

解決方案:定義instance為volatile變數

private static volatile VolatileSingleton instance = null;

十一、volatile都不保證原子性,為啥我們還要用它?

奇怪的是,volatile都不保證原子性,為啥我們還要用它?

volatile是輕量級的同步機制,對性能的影響比synchronized小。

典型的用法:檢查某個狀態標記以判斷是否退出循環。

比如執行緒試圖通過類似於數綿羊的傳統方法進入休眠狀態,為了使這個示例能正確執行,asleep必須為volatile變數。否則,當asleep被另一個執行緒修改時,執行判斷的執行緒卻發現不了。

那為什麼我們不直接用synchorized,lock鎖?它們既可以保證可見性,又可以保證原子性為何不用呢?

因為synchorized和lock是排他鎖(悲觀鎖),如果有多個執行緒需要訪問這個變數,將會發生競爭,只有一個執行緒可以訪問這個變數,其他執行緒被阻塞了,會影響程式的性能。

注意:當且僅當滿足以下所有條件時,才應該用volatile變數

  • 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
  • 該變數不會與其他的狀態一起納入不變性條件中。
  • 在訪問變數時不需要加鎖。

十二、volatile和synchronzied的區別

  • volatile只能修飾實例變數和類變數,synchronized可以修飾方法和程式碼塊。
  • volatile不保證原子性,而synchronized保證原子性
  • volatile 不會造成阻塞,而synchronized可能會造成阻塞
  • volatile 輕量級鎖,synchronized重量級鎖
  • volatile 和synchronized都保證了可見性和有序性

十三、小結

  • volatile 保證了可見性:當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改。
  • volatile 保證了單執行緒下指令不重排:通過插入記憶體屏障保證指令執行順序。
  • volatitle不保證原子性,如a++這種自增操作是有並發風險的,比如扣減庫存、發放優惠券的場景。
  • volatile 類型的64位的long型和double型變數,對該變數的讀/寫具有原子性。
  • volatile 可以用在雙重檢鎖的單例模式種,比synchronized性能更好。
  • volatile 可以用在檢查某個狀態標記以判斷是否退出循環。

期待後篇么?CAS走起!

我是悟空,越挫越勇的悟空,奧利給!

悟空

參考資料:

《深入理解Java虛擬機》

《Java並發編程的藝術》

《Java並發編程實戰》