天天看點

一道經典Java面試題:volatile的底層實作原理

前言

大家好,我是撿田螺的小男孩。今天我們來探讨一道經典Java面試題:volatile的底層實作原理。

如何向面試官表現你的基礎不錯呢?可以從這幾個方面,全方位回答這個問題:

  • volatile 是什麼?有什麼作用?
  • 現代計算機模型,MESI協定
  • 講述JMM 模型
  • volatile 不能保證原子性
  • volatile 是如何保證可見性的?
  • volatile 是如何保證指令重排的?

1. volatile 是什麼?有什麼作用

volatile關鍵字是Java虛拟機提供的的最輕量級的同步機制。它作為一個修飾符出現,可以用來修飾變量。它有什麼作用呢?它修飾的變量,可以保證變量對所有線程可見性,禁止指令重排,但是不能保證原子性。

一道經典Java面試題:volatile的底層實作原理

volatile特性

2. 現代計算機的記憶體模型,MESI協定,嗅探技術

計算機執行程式時,指令是由CPU處理器執行的,而打交道的資料是在主記憶體當中的。

由于計算機的儲存設備與處理器的運算速度有幾個數量級的差距,總不能每次CPU執行完指令,然後等主記憶體慢悠悠存取資料吧, 是以現代計算機系統加入一層讀寫速度接近處理器運算速度的高速緩存(Cache),以作為來作為記憶體與處理器之間的緩沖。

在多路處理器系統中,每個處理器都有自己的高速緩存,而它們共享同一主記憶體.現代計算機模型如下:

一道經典Java面試題:volatile的底層實作原理
  • 程式執行時,把需要用到的資料,從主記憶體拷貝一份到高速緩存。
  • CPU處理器計算時,從它的高速緩存中讀取,把計算完的資料寫入高速緩存。
  • 當程式運算結束,把高速緩存的資料重新整理會主記憶體。

MESI協定是什麼呢?MESI協定就是為了解決緩存一緻性問題的緩存一緻性協定

當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協定是如何實作的?如何保證目前處理器的内部緩存、主記憶體和其他處理器的緩存資料在總線上保持一緻的?多處理器總線嗅探

什麼又是嗅探技術?

在多處理器下,為了保證各個處理器的緩存是一緻的,就會實作緩存緩存一緻性協定,每個處理器通過嗅探在總線上傳播的資料來檢查自己的緩存值是不是過期了,如果處理器發現自己緩存行對應的記憶體位址被修改,就會将目前處理器的緩存行設定無效狀态,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料庫讀到處理器緩存中。

3. 畫出JMM 模型

JMM模型有點類似現代計算機的記憶體模型,而volatile 跟JMM息息相關,我們先回憶一下JMM模型哈

一道經典Java面試題:volatile的底層實作原理

Java記憶體模型

  • Java虛拟機規範試圖定義一種Java記憶體模型,來屏蔽掉各種硬體和作業系統的記憶體通路差異,以實作讓Java程式在各種平台上都能達到一緻的記憶體通路效果。
  • 為了更好的執行性能,java記憶體模型并沒有限制執行引擎使用處理器的特定寄存器或緩存來和主記憶體打交道,也沒有限制編譯器進行調整代碼順序優化。是以Java記憶體模型會存在緩存一緻性問題和指令重排序問題的。
  • Java記憶體模型規定所有的變量都是存在主記憶體當中,每個線程都有自己的工作記憶體。這裡的變量包括執行個體變量和靜态變量,但是不包括局部變量,因為局部變量是線程私有的。
  • 線程的工作記憶體儲存了被該線程使用的變量的主記憶體副本,線程對變量的所有操作都必須在工作記憶體中進行,而不能直接操作操作主記憶體。并且每個線程不能通路其他線程的工作記憶體。

3. volatile能保證原子性嘛?

不可以,可以直接舉i++那個例子哈。要保證原子性,可以使用synchronzied或者lock。

/**
 * 程式員田螺
 */
class VolatileTest {

    public volatile int race = 0;

    public void increase() {
        race++;
    }

    public static void main(String[] args) {
        final Solution test = new Solution();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 10000; j++)
                        test.increase();
                }

            }.start();
        }

        //等待所有累加線程結束
        while (Thread.activeCount() > 2)
            Thread.yield();
        System.out.println(test.race);
    }
}      

運作結果:

96994      

4. volatile 是如何保證可見性的?

volatile變量,保證新值能立即同步回主記憶體,以及每次使用前立即從主記憶體重新整理,是以我們說volatile保證了多線程操作變量的可見性。

5. volatile 是如何保證指令重排的?

指令重排是指在程式執行過程中,為了提高性能, 編譯器和CPU可能會對指令進行重新排序。volatile是如何禁止指令重排的?在Java語言中,有一個先行發生原則(happens-before)

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

實際上volatile保證可見性和禁止指令重排都跟記憶體屏障有關。我們來看一段volatile使用的demo代碼

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關鍵字時所生成的彙編代碼,發現有volatile關鍵字修飾時,會多出一個lock addl $0x0,(%esp),即多出一個lock字首指令,lock指令相當于一個「記憶體屏障」

lock指令相當于一個記憶體屏障,它保證以下這幾點:

  • 1.重排序時不能把後面的指令重排序到記憶體屏障之前的位置
  • 2.将本處理器的緩存寫入記憶體
  • 3.如果是寫入動作,會導緻其他處理器中對應的緩存無效。

第2點和第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屏障。

有些小夥伴,可能對這個還是有點疑惑,記憶體屏障這玩意太抽象了。我們照着代碼看下吧:

一道經典Java面試題:volatile的底層實作原理