Java程序員面試必備:Volatile全方位解析

前言

volatile是Java程序員必備的基礎,也是面試官很是喜歡問的一個話題,本文跟你們一塊兒開啓volatile學習之旅,若是有不正確的地方,也麻煩你們指出哈,一塊兒相互學習~html

  • 1.volatile的用法
  • 2.volatile變量的做用
  • 3.現代計算機的內存模型(計算機模型,總線,MESI協議,嗅探技術)
  • 4.Java內存模型(JMM)
  • 5.併發編程的3個特性(原子性、可見性、有序性、happen-before、as-if-serial、指令重排)
  • 6.volatile的底層原理(如何保證可見性,如何保證指令重排,內存屏障)
  • 7.volatile的典型場景(狀態標誌,DCL單例模式)
  • 8.volatile常見面試題&&答案解析
  • 公衆號:撿田螺的小男孩

github 地址java

https://github.com/whx123/Jav...

1.volatile的用法

volatile關鍵字是Java虛擬機提供的的最輕量級的同步機制,它做爲一個修飾符出現,用來修飾變量,可是這裏不包括局部變量哦。咱們來看個demo吧,代碼以下:git

/**
 *  @Author 撿田螺的小男孩
 *  @Date 2020/08/02
 *  @Desc volatile的可見性探索
 */
public class VolatileTest  {

    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();

        Thread t1 = new Thread(task, "線程t1");
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println("開始通知線程中止");
                    task.stop = true; //修改stop變量值。
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }, "線程t2");
        t1.start();  //開啓線程t1
        t2.start();  //開啓線程t2
        Thread.sleep(1000);
    }
}

class Task implements Runnable {
    boolean stop = false;
    int i = 0;

    @Override
    public void run() {
        long s = System.currentTimeMillis();
        while (!stop) {
            i++;
        }
        System.out.println("線程退出" + (System.currentTimeMillis() - s));
    }
}

運行結果:

能夠發現線程t2,雖然把stop設置爲true了,可是線程t1對t2的stop變量視而不可見,所以,它一直在死循環running中。若是給變量stop加上volatile修飾,線程t1是能夠停下來的,運行結果以下:程序員

volatile boolean stop = false;

2. vlatile修飾變量的做用

從以上例子,咱們能夠發現變量stop,加了vlatile修飾以後,線程t1對stop就可見了。其實,vlatile的做用就是:保證變量對全部線程可見性。固然,vlatile還有個做用就是,禁止指令重排,可是它不保證原子性
github

因此當面試官問你volatile的做用或者特性,均可以這麼回答:面試

  • 保證變量對全部線程可見性;
  • 禁止指令重排序
  • 不保證原子性

3. 現代計算機的內存模型(計算機模型,MESI協議,嗅探技術,總線)

爲了更好理解volatile,先回顧一下計算機的內存模型與JMM(Java內存模型)吧~數據庫

計算機模型

計算機執行程序時,指令是由CPU處理器執行的,而打交道的數據是在主內存當中的。編程

因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,總不能每次CPU執行完指令,而後等主內存慢悠悠存取數據吧,
因此現代計算機系統加入一層讀寫速度接近處理器運算速度的高速緩存(Cache),以做爲來做爲內存與處理器之間的緩衝。緩存

在多路處理器系統中,每一個處理器都有本身的高速緩存,而它們共享同一主內存。計算機抽象內存模型以下:多線程

  • 程序執行時,把須要用到的數據,從主內存拷貝一份到高速緩存。
  • CPU處理器計算時,從它的高速緩存中讀取,把計算完的數據寫入高速緩存。
  • 當程序運算結束,把高速緩存的數據刷新會主內存。

隨着科學技術的發展,爲了效率,高速緩存又衍生出一級緩存(L1),二級緩存(L2),甚至三級緩存(L3);

當多個處理器的運算任務都涉及同一塊主內存區域,可能致使緩存數據不一致問題。如何解決這個問題呢?有兩種方案

  • 一、經過在總線加LOCK#鎖的方式。
  • 二、經過緩存一致性協議(Cache Coherence Protocol)

總線

總線(Bus)是計算機各類功能部件之間傳送信息的公共通訊幹線,它是由導線組成的傳輸線束, 按照計算機所傳輸的信息種類,計算機的總線能夠劃分爲數據總線、地址總線和控制總線,分別用來傳輸數據、數據地址和控制信號。

CPU和其餘功能部件是經過總線通訊的,若是在總線加LOCK#鎖,那麼在鎖住總線期間,其餘CPU是沒法訪問內存,這樣一來,效率就比較低了

MESI協議

爲了解決一致性問題,還能夠經過緩存一致性協議。即各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做,這類協議有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比較著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)協議,它的核心思想是:

當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。

CPU中每一個緩存行標記的4種狀態(M、E、S、I),也瞭解一下吧:

緩存狀態 描述
M,被修改(Modified) 該緩存行只被該CPU緩存,與主存的值不一樣,會在它被其餘CPU讀取以前寫入內存,並設置爲Shared
E,獨享的(Exclusive) 該緩存行只被該CPU緩存,與主存的值相同,被其餘CPU讀取時置爲Shared,被其餘CPU寫時置爲Modified
S,共享的(Shared) 該緩存行可能被多個CPU緩存,各個緩存中的數據與主存數據相同
I,無效的(Invalid) 該緩存行數據是無效,須要時需從新從主存載入

MESI協議是如何實現的?如何保證當前處理器的內部緩存、主內存和其餘處理器的緩存數據在總線上保持一致的?多處理器總線嗅探

嗅探技術

在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢查本身的緩存值是否是過時了,若是處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置無效狀態,當處理器對這個數據進行修改操做的時候,會從新從系統內存中把數據庫讀處處理器緩存中。

4. Java內存模型(JMM)

  • Java虛擬機規範試圖定義一種Java內存模型,來屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺上都能達到一致的內存訪問效果。
  • Java內存模型類比於計算機內存模型。
  • 爲了更好的執行性能,java內存模型並無限制執行引擎使用處理器的特定寄存器或緩存來和主內存打交道,也沒有限制編譯器進行調整代碼順序優化。因此Java內存模型會存在緩存一致性問題和指令重排序問題的
  • Java內存模型規定全部的變量都是存在主內存當中(相似於計算機模型中的物理內存),每一個線程都有本身的工做內存(相似於計算機模型的高速緩存)。這裏的變量包括實例變量和靜態變量,可是不包括局部變量,由於局部變量是線程私有的。
  • 線程的工做內存保存了被該線程使用的變量的主內存副本,線程對變量的全部操做都必須在工做內存中進行,而不能直接操做操做主內存。而且每一個線程不能訪問其餘線程的工做內存。

舉個例子吧,假設i的初始值是0,執行如下語句:

i = i+1;

首先,執行線程t1從主內存中讀取到i=0,到工做內存。而後在工做內存中,賦值i+1,工做內存就獲得i=1,最後把結果寫回主內存。所以,若是是單線程的話,該語句執行是沒問題的。可是呢,線程t2的本地工做內存還沒過時,那麼它讀到的數據就是髒數據了。如圖:

Java內存模型是圍繞着如何在併發過程當中如何處理原子性、可見性和有序性這3個特徵來創建的,咱們再來一塊兒回顧一下~

5.併發編程的3個特性(原子性、可見性、有序性)

原子性

原子性,指操做是不可中斷的,要麼執行完成,要麼不執行,基本數據類型的訪問和讀寫都是具備原子性,固然(long和double的非原子性協定除外)。咱們來看幾個小例子:

i =666; // 語句1
i = j;   // 語句2
i = i+1;  //語句 3
i++;   // 語句4
  • 語句1操做顯然是原子性的,將數值666賦值給i,即線程執行這個語句時,直接將數值666寫入到工做內存中。
  • 語句2操做看起來也是原子性的,可是它實際上涉及兩個操做,先去讀j的值,再把j的值寫入工做內存,兩個操做分開都是原子操做,可是合起來就不知足原子性了。
  • 語句3讀取i的值,加1,再寫回主存,這個就不是原子性操做了。
  • 語句4 等同於語句3,也是非原子性操做。

可見性

  • 可見性就是指當一個線程修改了共享變量的值時,其餘線程可以當即得知這個修改。
  • Java內存模型是經過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存做爲傳遞媒介的方式來實現可見性的,不管是普通變量仍是volatile變量都是如此。
  • volatile變量,保證新值能當即同步回主內存,以及每次使用前當即從主內存刷新,因此咱們說volatile保證了多線程操做變量的可見性。
  • synchronized和Lock也可以保證可見性,線程在釋放鎖以前,會把共享變量值都刷回主存。final也能夠實現可見性。

有序性

Java虛擬機這樣描述Java程序的有序性的:若是在本線程內觀察,全部的操做都是有序的;若是在一個線程中,觀察另外一個線程,全部的操做都是無序的。

後半句意思就是,在Java內存模型中,容許編譯器和處理器對指令進行重排序,會影響到多線程併發執行的正確性;前半句意思就是as-if-serial的語義,即無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不會被改變。

好比如下程序代碼:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

步驟C依賴於步驟A和B,由於指令重排的存在,程序執行順訊多是A->B->C,也多是B->A->C,可是C不能在A或者B前面執行,這將違反as-if-serial語義。

看段代碼吧,假設程序先執行read方法,再執行add方法,結果必定是輸出sum=2嘛?

bool flag = false;
int b = 0;

public void read() {
   b = 1;              //1
   flag = true;        //2
}

public void add() {
   if (flag) {         //3
       int sum =b+b;   //4
       System.out.println("bb sum is"+sum); 
   } 
}

若是是單線程,結果應該沒問題,若是是多線程,線程t1對步驟1和2進行了指令重排序呢?結果sum就不是2了,而是0,以下圖所示:

這是爲啥呢?指令重排序瞭解一下,指令重排是指在程序執行過程當中,爲了提升性能, 編譯器和CPU可能會對指令進行從新排序。CPU重排序包括指令並行重排序和內存系統重排序,重排序類型和重排序執行過程以下:

實際上,能夠給flag加上volatile關鍵字,來保證有序性。固然,也能夠經過synchronized和Lock來保證有序性。synchronized和Lock保證某一時刻是隻有一個線程執行同步代碼,至關因而讓線程順序執行程序代碼了,天然就保證了有序性。

實際上Java內存模型的有序性並非僅靠volatile、synchronized和Lock來保證有序性的。這是由於Java語言中,有一個先行發生原則(happens-before):

  • 程序次序規則:在一個線程內,按照控制流順序,書寫在前面的操做先行發生於書寫在後面的操做。
  • 管程鎖定規則:一個unLock操做先行發生於後面對同一個鎖額lock操做
  • volatile變量規則:對一個變量的寫操做先行發生於後面對這個變量的讀操做
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每一個一個動做
  • 線程終止規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始
  • 傳遞性:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C

根據happens-before的八大規則,咱們回到剛的例子,一塊兒分析一下。給flag加上volatile關鍵字,look look它是如何保證有序性的,

volatile bool flag = false;
int b = 0;

public void read() {
   b = 1;              //1
   flag = true;        //2
}

public void add() {
   if (flag) {         //3
       int sum =b+b;   //4
       System.out.println("bb sum is"+sum); 
   } 
}
  • 首先呢,flag加上volatile關鍵字,那就禁止了指令重排,也就是1 happens-before 2了
  • 根據volatile變量規則,2 happens-before 3
  • 程序次序規則,得出 3 happens-before 4
  • 最後由傳遞性,得出1 happens-before 4,所以妥妥的輸出sum=2啦~

6.volatile底層原理

以上討論學習,咱們知道volatile的語義就是保證變量對全部線程可見性以及禁止指令重排優化。那麼,它的底層是如何保證可見性和禁止指令重排的呢?

圖解volatile是如何保證可見性的?

在這裏,先看幾個圖吧,哈哈~

假設flag變量的初始值false,如今有兩條線程t1和t2要訪問它,就能夠簡化爲如下圖:

若是線程t1執行如下代碼語句,而且flag沒有volatile修飾的話;t1剛修改完flag的值,還沒來得及刷新到主內存,t2又跑過來讀取了,很容易就數據flag不一致了,以下:

flag=true;

若是flag變量是由volatile修飾的話,就不同了,若是線程t1修改了flag值,volatile能保證修飾的flag變量後,能夠當即同步回主內存。如圖:

細心的朋友會發現,線程t2不仍是flag舊的值嗎,這不還有問題嘛?其實volatile還有一個保證,就是每次使用前當即先從主內存刷新最新的值,線程t1修改完後,線程t2的變量副本會過時了,如圖:

顯然,這裏還不是底層,實際上volatile保證可見性和禁止指令重排都跟內存屏障有關,咱們編譯volatile相關代碼看看~

DCL單例模式(volatile)&編譯對比

DCL單例模式(Double Check Lock,雙重檢查鎖)比較經常使用,它是須要volatile修飾的,因此就拿這段代碼編譯吧

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

編譯這段代碼後,觀察有volatile關鍵字和沒有volatile關鍵字時的instance所生成的彙編代碼發現,有volatile關鍵字修飾時,會多出一個lock addl $0x0,(%esp),即多出一個lock前綴指令

0x01a3de0f: mov    $0x3375cdb0,%esi   ;...beb0cd75 33  
                                        ;   {oop('Singleton')}  
0x01a3de14: mov    %eax,0x150(%esi)   ;...89865001 0000  
0x01a3de1a: shr    $0x9,%esi          ;...c1ee09  
0x01a3de1d: movb   $0x0,0x1104800(%esi)  ;...c6860048 100100  
0x01a3de24: lock addl $0x0,(%esp)     ;...f0830424 00  
                                        ;*putstatic instance  
                                        ; - Singleton::getInstance@24

lock指令至關於一個內存屏障,它保證如下這幾點:

  • 1.重排序時不能把後面的指令重排序到內存屏障以前的位置
  • 2.將本處理器的緩存寫入內存
  • 3.若是是寫入動做,會致使其餘處理器中對應的緩存無效。

顯然,第二、3點不就是volatile保證可見性的體現嘛,第1點就是禁止指令重排列的體現。

內存屏障

內存屏障四大分類:(Load 表明讀取指令,Store表明寫入指令)

內存屏障類型 抽象場景 描述
LoadLoad屏障 Load1; LoadLoad; Load2 在Load2要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
StoreStore屏障 Store1; StoreStore; Store2 在Store2寫入執行前,保證Store1的寫入操做對其它處理器可見
LoadStore屏障 Load1; LoadStore; Store2 在Store2被寫入前,保證Load1要讀取的數據被讀取完畢。
StoreLoad屏障 Store1; StoreLoad; Load2 在Load2讀取操做執行前,保證Store1的寫入對全部處理器可見。

爲了實現volatile的內存語義,Java內存模型採起如下的保守策略

  • 在每一個volatile寫操做的前面插入一個StoreStore屏障。
  • 在每一個volatile寫操做的後面插入一個StoreLoad屏障。
  • 在每一個volatile讀操做的前面插入一個LoadLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadStore屏障。

有些小夥伴,可能對這個仍是有點疑惑,內存屏障這玩意太抽象了。咱們照着代碼看下吧:


內存屏障保證前面的指令先執行,因此這就保證了禁止了指令重排啦,同時內存屏障保證緩存寫入內存和其餘處理器緩存失效,這也就保證了可見性,哈哈~

7.volatile的典型場景

一般來講,使用volatile必須具有如下2個條件:

  • 1)對變量的寫操做不依賴於當前值
  • 2)該變量沒有包含在具備其餘變量的不變式中

實際上,volatile場景通常就是狀態標誌,以及DCL單例模式

7.1 狀態標誌

深刻理解Java虛擬機,書中的例子:

Map configOptions;
char[] configText;
// 此變量必須定義爲 volatile
volatile boolean initialized = false;

// 假設如下代碼在線程 A 中運行
// 模擬讀取配置信息, 當讀取完成後將 initialized 設置爲 true 以告知其餘線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
      
// 假設如下代碼在線程 B 中運行
// 等待 initialized 爲 true, 表明線程 A 已經把配置信息初始化完成
while(!initialized) {
   sleep();
}
// 使用線程 A 中初始化好的配置信息
doSomethingWithConfig();

7.2 DCL單例模式

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

8. volatile相關經典面試題

  • 談談volatile的特性
  • volatile的內存語義
  • 說說併發編程的3大特性
  • 什麼是內存可見性,什麼是指令重排序?
  • volatile是如何解決java併發中可見性的問題
  • volatile如何防止指令重排
  • volatile能夠解決原子性嘛?爲何?
  • volatile底層的實現機制
  • volatile和synchronized的區別?

8.1 談談volatile的特性

8.2 volatile的內存語義

  • 當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存。
  • 當讀一個 volatile 變量時,JMM 會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

8.3 說說併發編程的3大特性

  • 原子性
  • 可見性
  • 有序性

8.4 什麼是內存可見性,什麼是指令重排序?

  • 可見性就是指當一個線程修改了共享變量的值時,其餘線程可以當即得知這個修改。
  • 指令重排是指JVM在編譯Java代碼的時候,或者CPU在執行JVM字節碼的時候,對現有的指令順序進行從新排序。

8.5 volatile是如何解決java併發中可見性的問題

底層是經過內存屏障實現的哦,volatile能保證修飾的變量後,能夠當即同步回主內存,每次使用前當即先從主內存刷新最新的值。

8.6 volatile如何防止指令重排

也是內存屏障哦,跟面試官講下Java內存的保守策略:

  • 在每一個volatile寫操做的前面插入一個StoreStore屏障。
  • 在每一個volatile寫操做的後面插入一個StoreLoad屏障。
  • 在每一個volatile讀操做的前面插入一個LoadLoad屏障。
  • 在每一個volatile讀操做的後面插入一個LoadStore屏障。

再講下volatile的語義哦,重排序時不能把內存屏障後面的指令重排序到內存屏障以前的位置

8.7 volatile能夠解決原子性嘛?爲何?

不能夠,能夠直接舉i++那個例子,原子性須要synchronzied或者lock保證

public class Test {
    public volatile int race = 0;
     
    public void increase() {
        race++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<100;j++)
                        test.increase();
                };
            }.start();
        }
        
        //等待全部累加線程結束
        while(Thread.activeCount()>1)  
            Thread.yield();
        System.out.println(test.race);
    }
}

#### 8.8 volatile底層的實現機制

能夠看本文的第六小節,volatile底層原理哈,主要你要跟面試官講述,volatile如何保證可見性和禁止指令重排,須要講到內存屏障~

#### 8.9 volatile和synchronized的區別?

  • volatile修飾的是變量,synchronized通常修飾代碼塊或者方法
  • volatile保證可見性、禁止指令重排,可是不保證原子性;synchronized能夠保證原子性
  • volatile不會形成線程阻塞,synchronized可能會形成線程的阻塞,因此後面纔有鎖優化那麼多故事~
  • 哈哈,你還有補充嘛~

推薦以前寫的一篇文章:
Synchronized解析——若是你願意一層一層剝開個人心

公衆號

參考與感謝

相關文章
相關標籤/搜索