反制面試官 | 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並發編程實戰》