面試官最愛的volatile關鍵字

卡巴拉的樹發表於2017-12-12

在Java相關的崗位面試中,很多面試官都喜歡考察面試者對Java併發的瞭解程度,而以volatile關鍵字作為一個小的切入點,往往可以一問到底,把Java記憶體模型(JMM),Java併發程式設計的一些特性都牽扯出來,深入地話還可以考察JVM底層實現以及作業系統的相關知識。

下面我們以一次假想的面試過程,來深入瞭解下volitile關鍵字吧!

面試官: Java併發這塊瞭解的怎麼樣?說說你對volatile關鍵字的理解


就我理解的而言,被volatile修飾的共享變數,就具有了以下兩點特性:

1 . 保證了不同執行緒對該變數操作的記憶體可見性;

2 . 禁止指令重排序

面試官: 能不能詳細說下什麼是記憶體可見性,什麼又是重排序呢?


這個聊起來可就多了,我還是從Java記憶體模型說起吧。

Java虛擬機器規範試圖定義一種Java記憶體模型(JMM),來遮蔽掉各種硬體和作業系統的記憶體訪問差異,讓Java程式在各種平臺上都能達到一致的記憶體訪問效果。簡單來說,由於CPU執行指令的速度是很快的,但是記憶體訪問的速度就慢了很多,相差的不是一個數量級,所以搞處理器的那群大佬們又在CPU里加了好幾層快取記憶體。

在Java記憶體模型裡,對上述的優化又進行了一波抽象。JMM規定所有變數都是存在主存中的,類似於上面提到的普通記憶體,每個執行緒又包含自己的工作記憶體,方便理解就可以看成CPU上的暫存器或者快取記憶體。所以執行緒的操作都是以工作記憶體為主,它們只能訪問自己的工作記憶體,且工作前後都要把值在同步回主記憶體。

這麼說得我自己都有些不清楚了,拿張紙畫一下:

JMM
線上程執行時,首先會從主存中read變數值,再load到工作記憶體中的副本中,然後再傳給處理器執行,執行完畢後再給工作記憶體中的副本賦值,隨後工作記憶體再把值傳回給主存,主存中的值才更新。

使用工作記憶體和主存,雖然加快的速度,但是也帶來了一些問題。比如看下面一個例子:

i = i + 1;
複製程式碼

假設i初值為0,當只有一個執行緒執行它時,結果肯定得到1,當兩個執行緒執行時,會得到結果2嗎?這倒不一定了。可能存在這種情況:

執行緒1: load i from 主存    // i = 0
        i + 1  // i = 1
執行緒2: load i from主存  // 因為執行緒1還沒將i的值寫回主存,所以i還是0
        i +  1 //i = 1
執行緒1:  save i to 主存
執行緒2: save i to 主存
複製程式碼

如果兩個執行緒按照上面的執行流程,那麼i最後的值居然是1了。如果最後的寫回生效的慢,你再讀取i的值,都可能是0,這就是快取不一致問題。

下面就要提到你剛才問到的問題了,JMM主要就是圍繞著如何在併發過程中如何處理原子性、可見性和有序性這3個特徵來建立的,通過解決這三個問題,可以解除快取不一致的問題。而volatile跟可見性和有序性都有關。

面試官:那你具體說說這三個特性呢?


1 . 原子性(Atomicity): Java中,對基本資料型別的讀取和賦值操作是原子性操作,所謂原子性操作就是指這些操作是不可中斷的,要做一定做完,要麼就沒有執行。 比如:

i = 2;
j = i;
i++;
i = i + 1;
複製程式碼

上面4個操作中,i=2是讀取操作,必定是原子性操作,j=i你以為是原子性操作,其實吧,分為兩步,一是讀取i的值,然後再賦值給j,這就是2步操作了,稱不上原子操作,i++i = i + 1其實是等效的,讀取i的值,加1,再寫回主存,那就是3步操作了。所以上面的舉例中,最後的值可能出現多種情況,就是因為滿足不了原子性。

這麼說來,只有簡單的讀取,賦值是原子操作,還只能是用數字賦值,用變數的話還多了一步讀取變數值的操作。有個例外是,虛擬機器規範中允許對64位資料型別(long和double),分為2次32為的操作來處理,但是最新JDK實現還是實現了原子操作的。

JMM只實現了基本的原子性,像上面i++那樣的操作,必須藉助於synchronizedLock來保證整塊程式碼的原子性了。執行緒在釋放鎖之前,必然會把i的值刷回到主存的。

2 . 可見性(Visibility):

說到可見性,Java就是利用volatile來提供可見性的。 當一個變數被volatile修飾時,那麼對它的修改會立刻重新整理到主存,當其它執行緒需要讀取該變數時,會去記憶體中讀取新值。而普通變數則不能保證這一點。

其實通過synchronized和Lock也能夠保證可見性,執行緒在釋放鎖之前,會把共享變數值都刷回主存,但是synchronized和Lock的開銷都更大。

3 . 有序性(Ordering)

JMM是允許編譯器和處理器對指令重排序的,但是規定了as-if-serial語義,即不管怎麼重排序,程式的執行結果不能改變。比如下面的程式段:

double pi = 3.14;    //A
double r = 1;        //B
double s= pi * r * r;//C
複製程式碼

上面的語句,可以按照A->B->C執行,結果為3.14,但是也可以按照B->A->C的順序執行,因為A、B是兩句獨立的語句,而C則依賴於A、B,所以A、B可以重排序,但是C卻不能排到A、B的前面。JMM保證了重排序不會影響到單執行緒的執行,但是在多執行緒中卻容易出問題。

比如這樣的程式碼:

int a = 0;
bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
    
}
複製程式碼

假如有兩個執行緒執行上述程式碼段,執行緒1先執行write,隨後執行緒2再執行multiply,最後ret的值一定是4嗎?結果不一定:

重排序
如圖所示,write方法裡的1和2做了重排序,執行緒1先對flag賦值為true,隨後執行到執行緒2,ret直接計算出結果,再到執行緒1,這時候a才賦值為2,很明顯遲了一步。

這時候可以為flag加上volatile關鍵字,禁止重排序,可以確保程式的“有序性”,也可以上重量級的synchronized和Lock來保證有序性,它們能保證那一塊區域裡的程式碼都是一次性執行完畢的。

另外,JMM具備一些先天的有序性,即不需要通過任何手段就可以保證的有序性,通常稱為happens-before原則。<<JSR-133:Java Memory Model and Thread Specification>>定義瞭如下happens-before規則:

  1. 程式順序規則: 一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作
  2. 監視器鎖規則:對一個執行緒的解鎖,happens-before於隨後對這個執行緒的加鎖
  3. volatile變數規則: 對一個volatile域的寫,happens-before於後續對這個volatile域的讀
  4. 傳遞性:如果A happens-before B ,且 B happens-before C, 那麼 A happens-before C
  5. start()規則: 如果執行緒A執行操作ThreadB_start()(啟動執行緒B) , 那麼A執行緒的ThreadB_start()happens-before 於B中的任意操作
  6. join()原則: 如果A執行ThreadB.join()並且成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
  7. interrupt()原則: 對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否有中斷髮生
  8. finalize()原則:一個物件的初始化完成先行發生於它的finalize()方法的開始

第1條規則程式順序規則是說在一個執行緒裡,所有的操作都是按順序的,但是在JMM裡其實只要執行結果一樣,是允許重排序的,這邊的happens-before強調的重點也是單執行緒執行結果的正確性,但是無法保證多執行緒也是如此。

第2條規則監視器規則其實也好理解,就是在加鎖之前,確定這個鎖之前已經被釋放了,才能繼續加鎖。

第3條規則,就適用到所討論的volatile,如果一個執行緒先去寫一個變數,另外一個執行緒再去讀,那麼寫入操作一定在讀操作之前。

第4條規則,就是happens-before的傳遞性。

後面幾條就不再一一贅述了。

面試官:volatile關鍵字如何滿足併發程式設計的三大特性的?

那就要重提volatile變數規則: 對一個volatile域的寫,happens-before於後續對這個volatile域的讀。 這條再拎出來說,其實就是如果一個變數宣告成是volatile的,那麼當我讀變數時,總是能讀到它的最新值,這裡最新值是指不管其它哪個執行緒對該變數做了寫操作,都會立刻被更新到主存裡,我也能從主存裡讀到這個剛寫入的值。也就是說volatile關鍵字可以保證可見性以及有序性。

繼續拿上面的一段程式碼舉例:

int a = 0;
bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
   
}
複製程式碼

這段程式碼不僅僅受到重排序的困擾,即使1、2沒有重排序。3也不會那麼順利的執行的。假設還是執行緒1先執行write操作,執行緒2再執行multiply操作,由於執行緒1是在工作記憶體裡把flag賦值為1,不一定立刻寫回主存,所以執行緒2執行時,multiply再從主存讀flag值,仍然可能為false,那麼括號裡的語句將不會執行。

如果改成下面這樣:

int a = 0;
volatile bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}
複製程式碼

那麼執行緒1先執行write,執行緒2再執行multiply。根據happens-before原則,這個過程會滿足以下3類規則:

  1. 程式順序規則:1 happens-before 2; 3 happens-before 4; (volatile限制了指令重排序,所以1 在2 之前執行)
  2. volatile規則:2 happens-before 3
  3. 傳遞性規則:1 happens-before 4

從記憶體語義上來看

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體

當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效,執行緒接下來將從主記憶體中讀取共享變數。

面試官:volatile的兩點記憶體語義能保證可見性和有序性,但是能保證原子性嗎?

首先我回答是不能保證原子性,要是說能保證,也只是對單個volatile變數的讀/寫具有原子性,但是對於類似volatile++這樣的複合操作就無能為力了,比如下面的例子:

public class Test {
    public volatile int inc = 0;
 
    public void increase() {
        inc++;
    }
 
    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<1000;j++)
                        test.increase();
                };
            }.start();
        }
 
        while(Thread.activeCount()>1)  //保證前面的執行緒都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
複製程式碼

按道理來說結果是10000,但是執行下很可能是個小於10000的值。有人可能會說volatile不是保證了可見性啊,一個執行緒對inc的修改,另外一個執行緒應該立刻看到啊!可是這裡的操作inc++是個複合操作啊,包括讀取inc的值,對其自增,然後再寫回主存。

假設執行緒A,讀取了inc的值為10,這時候被阻塞了,因為沒有對變數進行修改,觸發不了volatile規則。

執行緒B此時也讀讀inc的值,主存裡inc的值依舊為10,做自增,然後立刻就被寫回主存了,為11。

此時又輪到執行緒A執行,由於工作記憶體裡儲存的是10,所以繼續做自增,再寫回主存,11又被寫了一遍。所以雖然兩個執行緒執行了兩次increase(),結果卻只加了一次。

有人說,volatile不是會使快取行無效的嗎?但是這裡執行緒A讀取到執行緒B也進行操作之前,並沒有修改inc值,所以執行緒B讀取的時候,還是讀的10。

又有人說,執行緒B將11寫回主存,不會把執行緒A的快取行設為無效嗎?但是執行緒A的讀取操作已經做過了啊,只有在做讀取操作時,發現自己快取行無效,才會去讀主存的值,所以這裡執行緒A只能繼續做自增了。

綜上所述,在這種複合操作的情景下,原子性的功能是維持不了了。但是volatile在上面那種設定flag值的例子裡,由於對flag的讀/寫操作都是單步的,所以還是能保證原子性的。

要想保證原子性,只能藉助於synchronized,Lock以及併發包下的atomic的原子操作類了,即對基本資料型別的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。

面試官:說的還可以,那你知道volatile底層的實現機制?

如果把加入volatile關鍵字的程式碼和未加入volatile關鍵字的程式碼都生成彙編程式碼,會發現加入volatile關鍵字的程式碼會多出一個lock字首指令。

lock字首指令實際相當於一個記憶體屏障,記憶體屏障提供了以下功能:

1 . 重排序時不能把後面的指令重排序到記憶體屏障之前的位置 2 . 使得本CPU的Cache寫入記憶體 3 . 寫入動作也會引起別的CPU或者別的核心無效化其Cache,相當於讓新寫入的值對別的執行緒可見。

面試官: 你在哪裡會使用到volatile,舉兩個例子呢?

  1. 狀態量標記,就如上面對flag的標記,我重新提一下:
int a = 0;
volatile bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
}
複製程式碼

這種對變數的讀寫操作,標記為volatile可以保證修改對執行緒立刻可見。比synchronized,Lock有一定的效率提升。

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;
    }
}
複製程式碼

這是一種懶漢的單例模式,使用時才建立物件,而且為了避免初始化操作的指令重排序,給instance加上了volatile。

面試官: 來給我們說說幾種單例模式的寫法吧,還有上面這種用法,你再詳細說說呢?

好吧,這又是一個話題了,volatile的問題終於問完了。。。看看你掌握了沒

相關文章