Offer快到碗里來,Volatile問題終結者

微信公眾號:大黃奔跑
關注我,可了解更多有趣的面試相關問題。

寫在之前面試問題概覽面試回顧大黃可見性Demo演示小插曲大黃可見性Demo演示小插曲大黃可見性Demo演示小插曲總結番外

寫在之前

Hello,大家好,我是只會寫HelloWorld的程式設計師大黃。

Java中並發編程是各個大廠面試重點,很多知識點晦澀難懂,常常需要結合實際經驗才能回答好,面試沒有回答好,則容易被面試官直接掛掉。

因此,大黃利用周末時間,嘔心瀝血,整理之前和面試官battle的面試題目。

由於並發變成問題實在是太多了,一篇文章不足以囊括所有的並發知識點,打算分為多篇來分析,面試中的並發問題該如何回答。本篇主要圍繞volatile關鍵字展開。


關於並發編程一些源碼和深層次的分析已經不勝枚舉,大黃不打算從各方面展開,只希望能夠借用這篇文章溝通面試中該如何回答,畢竟面試時間短,回答重點、要點才是關鍵。

面試問題概覽

下面我羅列一些大廠面試中,關於並發編程常見的一些面試題目,有的是自己親身經歷,有的是尋找網友的面經分享。

可以先看看這些面試題目,現在心中想想,如果你面對這些題目,該如何回答呢?

  1. volatile關鍵字解釋一下【字節跳動】
  2. volatile有啥作用,如何使用的呢【京東】
  3. synchronized 和volatile 關鍵字的區別【京東】
  4. volatile原理詳細解釋【阿里雲】
  5. volatile關鍵字介紹,記憶體模型說一下【滴滴】
  6. Volatile底層原理,使用場景【抖音】

可以看到volatile關鍵字在各個大廠面試中已經成為了必考的面試題目。回答好了必然稱為加分項,回答不好嘿嘿,你懂的。

面試回顧

一個身著灰色格子襯衫,拿著閃著碩大的🍎logo小哥迎面走來,我心想,logo還自帶發光的,這尼瑪肯定是p7大佬了,但是剛開始咱們還是得淡定不是。

面試官:大黃同學是吧,我看你簡歷上面寫能夠熟練掌握並發編程核心知識,那我們先來看看並發編程的一些核心知識吧。有聽過volatile嗎?說說你對於這個的理解。

記住:此時還是要從為什麼、是什麼、有什麼作用回答,只有這樣才能給面試官留下深刻印象。

大黃:面試官您好,volatile是java虛擬機提供的輕量級同步機制,主要特點有三個:

  1. 保證執行緒之間的可見性
  2. 禁止指令重排
  3. 但是不保證原子性

面試中,肯定不是說完這三點就完了,一般需要展開來說。

大黃:所謂可見性,是多執行緒中獨有的。A執行緒修改值之後,B執行緒能夠知道參數已經修改了,這就是執行緒間的可見性。 A修改共享變數i之後,B馬上可以感知到該變數的修改。

面試官可能會追問,為什麼會出現變數可見性問題了。這個就涉及到Java的記憶體模型了(俗稱JMM),因此你需要簡單說說Java的記憶體模型。
面試官:那為什麼會出現變數可見性問題呢?

大黃:JVM運行程式的實體都是執行緒,每次創建執行緒的時候,JVM都會給執行緒創建屬於自己的工作記憶體,注意工作記憶體是該執行緒獨有的,也就說別的執行緒無法訪問工作記憶體中的資訊。而Java記憶體模型中規定所有的變數都存儲在主記憶體中,主記憶體是多個執行緒共享的區域,執行緒對變數的操作(讀寫)必須在工作記憶體中進行。

面試中記得不要干說理論,結合一下例子,讓面試官感到你真的掌握了。上面的問題你抓住主記憶體、執行緒記憶體分別闡述即可。

大黃:比如,存在兩個執行緒A、B,同時從主執行緒中獲取一個對象(i = 25),某一刻,A、B的工作執行緒中i都是25,A效率比較高,片刻,改完之後,馬上將i更新到了主記憶體,但是此時B是完全沒有辦法i發生了變化,仍然用i做一些操作。問題就發生了,B執行緒沒有辦法馬上感知到變數的變化!!

大黃可見性Demo演示小插曲

import lombok.Data;

/**
 * @author dahuang
 * @time 2020/3/15 17:14
 * @Description JMM原子性模擬
 */

public class Juc002VolatileAtomic {
    public static void main(String[] args) {
        AtomicResource resource = new AtomicResource();

        // 利用for循環創建20個執行緒,每個執行緒自增100次
        for(int i = 0; i < 20; i++){
            new Thread(()->{
                for (int j = 0; j < 100; j++) {
                    resource.addNum();
                }
            },String.valueOf(i)).start();
        }

        // 用該方法判斷上述20執行緒是否計算完畢,
        // 如果小於2,則說明計算執行緒沒有計算完,則主執行緒暫時讓出執行時間
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        // 查看number是否可以保證原子性,如果可以保證則輸出的值則為2000
        System.out.println("Result = "+resource.getNumber());
    }

}

@Data
class AtomicResource{

    volatile int number = 0;

    public void addNum(){
        number++;
    }
}

下面是運行結果:
結果如下:

Result = 1906

Process finished with exit code 0

面試官:volatile可以保證程式的原子性嗎?
大黃:JMM的目的是解決原子性,但volatile不保證原子性。為什麼無法保證原子性呢?
因為上述的Java的記憶體模型的存在,修改一個i的值並不是一步操作,過程可以分為三步:

  1. 從主記憶體中讀取值,載入到工作記憶體
  2. 工作記憶體中對i進行自增
  3. 自增完成之後再寫回主記憶體。

每個執行緒獲取主記憶體中的值修改,然後再寫回主記憶體,多個執行緒執行的時候,存在很多情況的寫值的覆蓋。

大黃可見性Demo演示小插曲

用下面的例子測試volatile是否保證原子性。

import lombok.Data;

/**
 * @author dahuang
 * @time 2020/3/15 17:14
 * @Description JMM原子性模擬
 */

public class Juc002VolatileAtomic {
    public static void main(String[] args) {
        AtomicResource resource = new AtomicResource();

        // 利用for循環創建20個執行緒,每個執行緒自增100次
        for(int i = 0; i < 20; i++){
            new Thread(()->{
                for (int j = 0; j < 100; j++) {
                    resource.addNum();
                }
            },String.valueOf(i)).start();
        }

        // 用該方法判斷上述20執行緒是否計算完畢,如果小於2,
        // 則說明計算執行緒沒有計算完,則主執行緒暫時讓出執行時間
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        // 查看number是否可以保證原子性,如果可以保證則輸出的值則為2000
        System.out.println("Result = "+resource.getNumber());
    }

}

@Data
class AtomicResource{

    volatile int number = 0;

    public void addNum(){
        number++;
    }
}

結果如下:

Result = 1906
可以看到程式循環了2000次,但是最後值卻只累加到1906,說明程式中有很多覆蓋的。

面試官可能心想,好傢夥,懂得還挺多,我來試試你的深淺。

面試官:那如果程式中想要保證原子性怎麼辦呢?
大黃:Juc(Java並發包簡稱)下面提供了多種方式,比較輕量級的有Atomic類的變數,更重量級有Synchronized關鍵字修飾,前者的效率本身是後者高,不用加鎖就可以保證原子性。

大黃可見性Demo演示小插曲

import lombok.Data;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author dahuang
 * @time 2020/3/15 17:43
 * @Description 利用Atomic來保證原子性
 */

public class Juc003VolatileAtomic {
    public static void main(String[] args) {
        AtomicResource resource = new AtomicResource();

        // 利用for循環創建20個執行緒,每個執行緒自增100次
        for(int i = 0; i < 20; i++){
            new Thread(()->{
                for (int j = 0; j < 100; j++) {
                    resource.addNum();
                }
            },String.valueOf(i)).start();
        }

        // 用該方法判斷上述20執行緒是否計算完畢,如果小於2,
        // 則說明計算執行緒沒有計算完,則主執行緒暫時讓出執行時間
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        // 查看number是否可以保證原子性,如果可以保證則輸出的值則為2000
        System.out.println("Result = "+resource.getNumber());
    }
}

@Data
class AtomicResource{

    AtomicInteger number = new AtomicInteger();

    public void addNum(){
        number.getAndIncrement();
    }
}

輸出結果如下:

Result = 2000

面試官:你剛才說到了volatile禁止指令重排,可以說說裡面的原理嗎?
此刻需要故作沉思,需要表現出在回憶的樣子,(為什麼這麼做,你懂得,畢竟沒有面試官喜歡背題的同學)。
大黃:哦哦,這個之前操作了解過。電腦在底層執行程式的時候,為了提高效率,經常會對指令做重排序,一般重排序分為三種

  1. 編譯器優化的重排序
  2. 指令並行的重排
  3. 記憶體系統的重排

單執行緒下,無論怎麼樣重排序,最後執行的結果都一致的,並且指令重排遵循基本的數據依賴原則,數據需要先聲明再計算;多執行緒下,執行緒交替執行,由於編譯器存在優化重排,兩個執行緒中使用的變數能夠保證一致性是無法確定的,結果無法預測。

volatile本身的原理是利用記憶體屏障來實現,通過插入記憶體屏障禁止在記憶體屏障前後的指令執行重排序的優化。
面試官:那記憶體屏障有啥作用呢,是怎麼實現的呢?

大黃:

  1. 保證特定操作的執行順序
  2. 保證某些變數的記憶體可見性。

面試官:Volatile與記憶體屏障又是如何起著作用的呢?

對於Volatile變數進行寫操作時,會在寫操作後面加上一個store屏障指令,將工作記憶體中的共享變數值即可刷新到主記憶體;
對於Volatile變數進行讀操作時,會在讀操作前面加入一個load屏障指令,讀取之前馬上讀取主記憶體中的數據。

面試官心想:可以的,這個小夥子有點深度。我看看他是否用過。那你工作中在哪用到volatile了呢?

大黃:單例模式如果必須要在多執行緒下保證單例,volatile關鍵字必不可少。

面試官:可以簡單寫一下普通的單例模式嗎?

我們先來看看普通的單例模式:

public class Juc004SingletonMultiThread {
    /**
     * 私有化構造方法、只會構造一次
     */

    private Juc004SingletonMultiThread(){
        System.out.println("構造方法");
    }

    private static Juc004SingletonMultiThread instance = null;

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

    public static void main(String[] args) {

        // new 30個執行緒,觀察構造方法會創建幾次
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                Juc004SingletonMultiThread.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

大黃:注意哦,這已經是極度強校驗的單例模式了。但是這種雙重檢查的近執行緒安全的單例模式也有可能出現問題,因為底層存在指令重排,檢查的順序可能發生了變化,可能會發生讀取到的instance !=null,但是instance的引用對象可能沒有完成初始化。,導致另一個執行緒讀取到了還沒有初始化的結果。

面試官:為什麼會發生以上的情況呢?

大黃:這個可能需要從對象的初始化過程說起了。話說,盤古開天闢地…… 不好意思,跑題了,我們繼續。

   // step 1
public  static Juc004SingletonMultiThread getInstance(){                 
   // step 2
  if(instance == null){                                          
   // step 3
    synchronized (Juc004SingletonMultiThread.class){             
    // step 4
      if(instance == null){                                  
     // step 5
        instance = new Juc004SingletonMultiThread();    
      }
    }
  }
  return instance;
}

第五步初始化過程會分為三步完成:

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

再使用該初始化完成的對象,似乎一起看起來是那麼美好,但是電腦底層編譯器想著讓你加速,則可能會自作聰明的將第三步和第二步調整順序(重排序),優化成了

  1. memory = allocate() 分配對象記憶體空間
  2. instance = memory 設置instance指向剛分配的記憶體地址,此時對象還沒有哦
  3. instance(memory) 初始化對象

這種優化在單執行緒下還不要緊,因為第一次訪問該對象一定是在這三步完成之後,但是多執行緒之間存在如此多的的競爭,如果有另一個執行緒在重排序之後的3後面訪問了該對象則有問題了,因為該對象根本就完全初始化的。

面試官:好傢夥,這個小夥子,必須要。可以簡單畫畫訪問到圖嗎?

大黃拿起筆就繪製了如下圖了:

多執行緒訪問記憶體模型
多執行緒訪問記憶體模型

並且滔滔不絕到,但是上述問題在單執行緒下不存在該問題,只有涉及到多執行緒下才會發生。
為了解決該問題可以從兩個角度解決問題,

  1. 不允許2和3進行重排序
  2. 允許2和3重排序,但是不允許其他執行緒看到這個重排序。
    因此可以加上Volatile關鍵字防止指令重排。

面試官:那你寫一下用volatile實現的單例模式吧

public class Juc004SingletonMultiThread {
    /**
     * 私有化構造方法、只會構造一次
     */

    private Juc004SingletonMultiThread(){
        System.out.println("構造方法");
    }

    private  static volatile Juc004SingletonMultiThread instance = null;

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

    public static void main(String[] args) {

        // new 30個執行緒,觀察構造方法會創建幾次
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                Juc004SingletonMultiThread.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

面試到這裡,我想面試官對於你的能力已經不容置疑了。
面試官暗喜,嘿嘿,碰到寶了,好小子,有點東西啊,這種人才必須得拿下。

面試官:好了,今天的面試就到這裡,請問你下一場面試什麼時候有時間呢,我來安排一下。

哈哈哈,恭喜你,到了這裡面試已經成功拿下了,收起你的笑容。

大黃:我這幾天都有時間的,看你們的安排。

總結

本身主要圍繞開頭的幾個真正的面試題展開,簡單來說,volatile是什麼?為什麼要有volatilevolatile底層原理?平時編程中哪裡用到了volatile

最後大黃分享多年面試心得。面試中,面對一個問題,大概按照總分的邏輯回答即可。先直接拋出結論,然後舉例論證自己的結論。一定要第一時間抓住面試官的心裡,否則容易給人抓不著重點或者不著邊際的印象。

番外

另外,關注大黃奔跑公眾號,第一時間收穫獨家整理的面試實戰記錄及面試知識點總結。

我是大黃,一個只會寫HelloWorld的程式設計師,咱們下期見。

關注大黃,充當offer收割機
關注大黃,充當offer收割機