讓你徹底理解volatile,面試不再愁

本人免費整理了Java高級資料,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高併發分佈式等教程,一共30G,需要自己領取。
傳送門:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL8Q

1. volatile簡介
在上一篇文章中我們深入理解了java關鍵字

這篇文章帶你徹底理解

我們知道在java中還有一大神器就是關鍵volatile,可以說是和synchronized各領風騷,其中奧妙,我們來共同探討下。


通過上一篇的文章我們瞭解到synchronized是阻塞式同步,在線程競爭激烈的情況下會升級爲重量級鎖。而volatile就可以說是java虛擬機提供的最輕量級的同步機制。

但它同時不容易被正確理解,也至於在併發編程中很多程序員遇到線程安全的問題就會使用synchronized。

Java內存模型告訴我們,各個線程會將共享變量從主內存中拷貝到工作內存,然後執行引擎會基於工作內存中的數據進行操作處理。

線程在工作內存進行操作後何時會寫到主內存中?這個時機對普通變量是沒有規定的,而針對volatile修飾的變量給java虛擬機特殊的約定,線程對volatile變量的修改會立刻被其他線程所感知,即不會出現數據髒讀的現象,從而保證數據的“可見性”。


現在我們有了一個大概的印象就是:被volatile修飾的變量能夠保證每個線程能夠獲取該變量的最新值,從而避免出現數據髒讀的現象。


2. volatile實現原理
volatile是怎樣實現了?比如一個很簡單的Java代碼:
instance = new Instancce() //instance是volatile變量

在生成彙編代碼時會在volatile修飾的共享變量進行寫操作的時候會多出Lock前綴的指令(具體的大家可以使用一些工具去看一下,這裏我就只把結果說出來)。我們想這個Lock指令肯定有神奇的地方,那麼Lock前綴的指令在多核處理器下會發現什麼事情了?

主要有這兩個方面的影響:
1.將當前處理器緩存行的數據寫回系統內存;

2.這個寫回內存的操作會使得其他CPU裏緩存了該內存地址的數據無效

爲了提高處理速度,處理器不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到內存。

如果對聲明瞭volatile的變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。

但是,就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題。所以,在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存裏。因此,經過分析我們可以得出如下結論:

  1. Lock前綴的指令會引起處理器緩存寫回內存;

  2. 一個處理器的緩存回寫到內存會導致其他處理器的緩存失效;

  3. 當處理器發現本地緩存失效後,就會從內存中重讀該變量數據,即可以獲取當前最新值。

這樣針對volatile變量通過這樣的機制就使得每個線程都能獲得該變量的最新值。


3. volatile的happens-before關係
經過上面的分析,我們已經知道了volatile變量可以通過緩存一致性協議保證每個線程都能獲得最新值,即滿足數據的“可見性”。

我們繼續延續上一篇分析問題的方式(我一直認爲思考問題的方式是屬於自己,也纔是最重要的,也在不斷培養這方面的能力),我一直將併發分析的切入點分爲兩個核心,三大性質

兩大核心:JMM內存模型(主內存和工作內存)以及happens-before;三條性質:原子性,可見性,有序性(關於三大性質的總結在以後得文章會和大家共同探討)。

廢話不多說,先來看兩個核心之一:volatile的happens-before關係。


在六條happens-before規則中有一條是:**volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。**下面我們結合具體的代碼,我們利用這條規則推導下:

 public class VolatileExample {
    private int a = 0;
    private volatile boolean flag = false;
    public void writer(){
        a = 1;          //1        flag = true;   //2    }
    public void reader(){
        if(flag){      //3            int i = a; //4        }
    }}


上面的實例代碼對應的happens-before關係如下圖所示:

v2-b61c1fcfc3af09d4bea506da99992364_hd.jpg


加鎖線程A先執行writer方法,然後線程B執行reader方法圖中每一個箭頭兩個節點就代碼一個happens-before關係,黑色的代表根據程序順序規則推導出來,紅色的是根據volatile變量的寫happens-before 於任意後續對volatile變量的讀,而藍色的就是根據傳遞性規則推導出來的。

這裏的2 happen-before 3,同樣根據happens-before規則定義:如果A happens-before B,則A的執行結果對B可見,並且A的執行順序先於B的執行順序,我們可以知道操作2執行結果對操作3來說是可見的,也就是說當線程A將volatile變量 flag更改爲true後線程B就能夠迅速感知。


4. volatile的內存語義
還是按照兩個核心的分析方式,分析完happens-before關係後我們現在就來進一步分析volatile的內存語義(按照這種方式去學習,會不會讓大家對知識能夠把握的更深,而不至於不知所措,如果大家認同我的這種方式,不妨給個贊,小弟在此謝過,對我是個鼓勵)。

還是以上面的代碼爲例,假設線程A先執行writer方法,線程B隨後執行reader方法,初始時線程的本地內存中flag和a都是初始狀態,下圖是線程A執行volatile寫後的狀態圖。

v2-7718b0046de2c36d8d873677d144f6de_hd.jpg


當volatile變量寫後,線程中本地內存中共享變量就會置爲失效的狀態,因此線程B再需要讀取從主內存中去讀取該變量的最新值。下圖就展示了線程B讀取同一個volatile變量的內存變化示意圖。

v2-daac292be093e735c665414fb6deb2f9_hd.jpg


從橫向來看,線程A和線程B之間進行了一次通信,線程A在寫volatile變量時,實際上就像是給B發送了一個消息告訴線程B你現在的值都是舊的了,然後線程B讀這個volatile變量時就像是接收了線程A剛剛發送的消息。既然是舊的了,那線程B該怎麼辦了?自然而然就只能去主內存去取啦。


好的,我們現在兩個核心:happens-before以及內存語義現在已經都瞭解清楚了。是不是還不過癮,突然發現原來自己會這麼愛學習(微笑臉),那我們下面就再來一點乾貨----volatile內存語義的實現。


4.1 volatile的內存語義實現
我們都知道,爲了性能優化,JMM在不改變正確語義的前提下,會允許編譯器和處理器對指令序列進行重排序,那如果想阻止重排序要怎麼辦了?答案是可以添加內存屏障。

內存屏障
JMM內存屏障分爲四類見下圖,

v2-9c017db7acf891fcb5ae9b6ea9da3926_hd.jpg


java編譯器會在生成指令系列時在適當的位置會插入內存屏障指令來禁止特定類型的處理器重排序。

爲了實現volatile的內存語義,JMM會限制特定類型的編譯器和處理器重排序,JMM會針對編譯器制定volatile重排序規則表:

v2-c33488293bd156f70e8239964172514b_hd.jpg


"NO"表示禁止重排序。爲了實現volatile內存語義時,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序

對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎是不可能的,爲此,JMM採取了保守策略:

  1. 在每個volatile寫操作的前面插入一個StoreStore屏障;

  2. 在每個volatile寫操作的後面插入一個StoreLoad屏障;

  3. 在每個volatile讀操作的後面插入一個LoadLoad屏障;

  4. 在每個volatile讀操作的後面插入一個LoadStore屏障。

需要注意的是:volatile寫是在前面和後面分別插入內存屏障,而volatile讀操作是在後面插入兩個內存屏障
StoreStore屏障:禁止上面的普通寫和下面的volatile寫重排序;
StoreLoad屏障:防止上面的volatile寫與下面可能有的volatile讀/寫重排序
LoadLoad屏障:禁止下面所有的普通讀操作和上面的volatile讀重排序
LoadStore屏障:禁止下面所有的普通寫操作和上面的volatile讀重排序
下面以兩個示意圖進行理解,圖片摘自相當好的一本書《java併發編程的藝術》。

v2-52da5e63293ef5a4f7794c7331896531_hd.jpg


v2-b9916a63ba0e7039d8834b172f5edb8e_hd.jpg


5. 一個示例
我們現在已經理解volatile的精華了,文章開頭的那個問題我想現在我們都能給出答案了。更正後的代碼爲:

public class VolatileDemo {
    private static volatile boolean isOver = false;

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) ;
            }
        });
        thread.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isOver = true;
    }}

注意不同點,現在已經將isOver設置成了volatile變量,這樣在main線程中將isOver改爲了true後,thread的工作內存該變量值就會失效,從而需要再次從主內存中讀取該值,現在能夠讀出isOver最新值爲true從而能夠結束在thread裏的死循環,從而能夠順利停止掉thread線程。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章