Volatile全方位解析

2020-08-11 16:18:13

Volatile全方位解析

volatile是Java程式設計師必備的基礎,也是面試官非常喜歡問的一個話題,本文跟大家一起開啓vlatile學習之旅,如果有不正確的地方,也麻煩大家指出哈,一起相互學習~

1.volatile的用法
2.vlatile變數的作用
3.現代計算機的記憶體模型(計算機模型,匯流排,MESI協定,嗅探技術)
4.Java記憶體模型(JMM)
5.併發程式設計的3個特性(原子性、可見性、有序性、happen-before、as-if-serial、指令重排)
6.volatile的底層原理(如何保證可見性,如何保證指令重排,記憶體屏障)
7.volatile的典型場景(狀態標誌,DCL單例模式)
8.volatile常見面試題&&答案解析
「github 地址」

❝https://github.com/whx123/JavaHome❞

1.volatile的用法

volatile關鍵字是Java虛擬機器提供的的「最輕量級的同步機制 機製」,它作爲一個修飾符出現,用來「修飾變數」,但是這裏不包括區域性變數哦。我們來看個demo吧,程式碼如下:

/**

  • @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;

  1. vlatile修飾變數的作用

從以上例子,我們可以發現變數stop,加了vlatile修飾之後,執行緒t1對stop就可見了。其實,vlatile的作用就是:「保證變數對所有執行緒可見性」。當然,vlatile還有個作用就是,「禁止指令重排」,但是它「不保證原子性」。

所以當面試官問你「volatile的作用或者特性」,都可以這麼回答:

保證變數對所有執行緒可見性;

禁止指令重排序

不保證原子性

  1. 現代計算機的記憶體模型(計算機模型,MESI協定,嗅探技術,匯流排)

爲了更好理解volatile,先回顧一下計算機的記憶體模型與JMM(Java記憶體模型)吧~

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

由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,總不能每次CPU執行完指令,然後等主記憶體慢悠悠存取數據吧, 所以現代計算機系統加入一層讀寫速度接近處理器運算速度的快取記憶體(Cache),以作爲來作爲記憶體與處理器之間的緩衝。

在多路處理器系統中,每個處理器都有自己的快取記憶體,而它們共用同一主記憶體。「計算機抽象記憶體模型」如下:

程式執行時,把需要用到的數據,從主記憶體拷貝一份到快取記憶體。
CPU處理器計算時,從它的快取記憶體中讀取,把計算完的數據寫入快取記憶體。
當程式運算結束,把快取記憶體的數據重新整理會主記憶體。
隨着科學技術的發展,爲了效率,快取記憶體又衍生出一級快取(L1),二級快取(L2),甚至三級快取(L3);

當多個處理器的運算任務都涉及同一塊主記憶體區域,可能導致「快取數據不一致」問題。如何解決這個問題呢?有兩種方案

❝1、通過在匯流排加LOCK#鎖的方式。

2、通過快取一致性協定(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協定是如何實現的?如何保證當前處理器的內部快取、主記憶體和其他處理器的快取數據在總線上保持一致的?「多處理器匯流排嗅探」

嗅探技術

❝在多處理器下,爲了保證各個處理器的快取是一致的,就會實現快取快取一致性協定,每個處理器通過嗅探在總線上傳播的數據來檢查自己的快取值是不是過期了,如果處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統記憶體中把數據庫讀到處理器快取中。❞

  1. 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.如果是寫入動作,會導致其他處理器中對應的快取無效。❞
顯然,第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屏障。
有些小夥伴,可能對這個還是有點疑惑,記憶體屏障這玩意太抽象了。我們照着程式碼看下吧:

記憶體屏障保證前面的指令先執行,所以這就保證了禁止了指令重排啦,同時記憶體屏障保證快取寫入記憶體和其他處理器快取失效,這也就保證了可見性,哈哈~

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可能會造成執行緒的阻塞,所以後面纔有鎖優化那麼多故事~