volatile面試的連環追擊,你還頂得住嗎?

本文腦圖


volatilejava中熱門關鍵字,也是面試中的高頻問點,今天就來深入的從各種volatile面試題中剖析它的底層原理實現,並通過簡單的代碼去證明。

在深入volatile之前,我們先從原理入手,然後層層深入,逐步剖析它的底層原理,使用過volatile關鍵字的程序員都知道,在多線程併發場景中volitile能夠保障共享變量的可見性

那麼問題來了,什麼是可見性呢?volatile是怎麼保障共享變量的可見性的呢?

在說可見性之前,我們先來了解在多線程的條件下,線程與線程之間是怎麼通信的,我們先來看看一張圖:

在Java線程中每次的讀取寫入不會直接操作主內存,因爲cpu的速度遠快於主內存的速度,若是直接操作主內存,大大限制了cpu的性能,對性能有很大的影響,所以每條線程都有各自的工作內存

這裏的工作內存類似於緩存,並非實際存在的,因爲緩存的讀取和寫入的速度遠大於主內存,這樣就大大提高了cpu數據交互的性能

所有的共享變量都是直接存儲於主內存中,工作內存保存線程在使用主內存共享變量的副本,當操作完工作內存的變量,會寫入主內存,完成對共享變量的讀取和寫入。

在單線程時代,不存在數據一致性的的問題,線程都是排隊的順序執行,前面的線程執行完纔會到後面的線程執行。
在這裏插入圖片描述
隨着計算機的發展,到了多核多線程的時代,緩存的出現雖然提升了cpu的執行效率,但是卻出現了緩存一致性的問題,爲了解決數據的一致性問題,提出兩種解決方案:

  1. 總線上加Lock#鎖:該方法簡單粗暴,在總線上加鎖,其它cpu的線程只能排隊等候,效率低下。
  2. 緩存一致性協議:該方案是JMM中提出的解決方案,通過對變量地址加鎖,減小鎖的粒度,執行變得更加高效。

爲了提高程序的執行效率,設計者們提出了底層對編譯器和執行器(處理器)的優化方案,分別是編譯器處理器重排序

那麼什麼是編譯器重排序和處理器啊重排序呢?

編譯器重排序就是在不改變單線程的語義的前提下,可以重新排列語句的執行順序。

處理器排序是在機器指令的層面,假如不存在數據依賴,處理器可以改變機器指令的執行順序,爲了提高程序的執行效率,在多線程中假如兩行的代碼存在數據依賴,將會被禁止重排序。

不管是編譯器重排序處理器的重排序,前提條件都不能改變單線程語義的前提下進行重排序,說白了就是最後的執行結果要準確無誤

學過大學的計算機基礎課都知道,我們的程序用高級語言寫完後是不能被各大平臺的機器所執行的,需要執行編譯,然後將編譯後的字節碼文件處理成機器指令,才能被計算機執行。

從java源代碼到最終的機器執行指令,分別會經過下面三種重排序:
在這裏插入圖片描述
前面說到了數據依賴的特性,什麼是數據依賴呢?

數據依賴就是假設一句代碼中對一個變量a++自增,然後後一句代碼b=a將a的值賦值給b,便表示這兩句代碼存在數據依賴,兩句代碼執行順序不能互換。

前面提到編譯器和處理器的重排序,在編譯器和處理器進行重排序的時候,就會遵守數據的依賴性,編譯器和處理器就會禁止存在數據依賴的兩個操作進行重排序,保證了數據的準確性。

JDK5開始,爲了保證程序的有序性,便提出了happen-before原則,假如兩個操作符合該原則,那麼這兩個操作可以隨意的進行重排序,並不會影響結果的正確性。

具體happen-before原則有6條,具體原則如下所示:

  1. 同一個線程中前面的操作先於後續的操作(但是這個並不是絕對的,假如在單線程的環境下,重排序後不會影響結果的準確性,是可以進行重排序,不按代碼的順序執行)。
  2. Synchronized 規則中解鎖操作先於後續的加鎖操作。
  3. volatile 規則中寫操作先於後續的讀取操作,保證數據的可見性。
  4. 一個線程的start()方法先於任何該線程的所有後續操作。
  5. 線程的所有操作先於其他該線程在該線程上調用join返回成功的操作。
  6. 如果操作a先於操作b,操作b先於操作c,那麼操作a先於操作c,傳遞性原理。

我們來看重點第三條,也就是我們今天所瞭解的重點volatile關鍵字,爲了實現volatile內存語義,規定有volatile修飾的共享變量在機器指令層面會出出現Lock前綴的指令。

我們來看看一個例子經典的例子,具體的代碼如下:

public class TestVolatile extends Thread {
    private static boolean flag = false;

    public void run() {
        while (!flag) ;
        System.out.println("run方法退出了")
    }

    public static void main(String[] args) throws Exception {
        new TestVolatile().start();
        Thread.sleep(5000);
        flag = true;
    }
}

看上面的代碼執行run方法能執行推出嗎?是不能的,因爲對於這兩個線程來說,首先new TestVolatile().start()線程拿到flag共享變量的值爲false,並存儲在於自己的工作內存中。

第一個線程到while循環中,就直接進入死循環,即使主線程讀取flag的值,然後改變該值爲true。

但是對於第一個線程來說並不知道,flag的值已經被修改,在第一個線程的工作內存中flag仍然爲false。具體的執行原理圖如下:

這樣對於共享變量flag,主線程修改後,對於線程1來說是不可見的,然後我們加上volatile變量修飾該變量,修改代碼如下:

 private static volatile boolean flag = false;

輸出的結果中,就會輸出run方法推出了,具體的原理假如一個共享變量被Volatile修飾,該指令在多核處理器下會引發兩件事情。

  1. 將當前處理器緩存行數據寫回主內存中。
  2. 這個寫入的操作會讓其它處理器中已經緩存了該變量的內存地址失效,當其它處理器需求再次使用該變量時,必須從主內存中重新讀取該值。

讓我們具體從idea的輸出的彙編指令中可以看出,我們看到紅色線框裏面的那行指令:putstatic flag ,將靜態變量flag入棧,注意觀察add指令前面有一個lock前綴指令。

注意:讓idea輸出程序的彙編指令,在啓動程序的時候,可以加上
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly作爲啓動參數,就可以查看彙編指令。

簡單的說被volatile修飾的共享變量,在lock指令後是一個原子操作,該原子操作不會被其它線程的調度機制打斷,該原子操作一旦執行就會運行到結束,中間不會切換到任意一個線程。

當使用lock前綴的機器指令,它會向cpu發送一個LOCK#信號,這樣能保證在多核多線程的情況下互斥的使用該共享變量的內存地址。直到執行完畢,該鎖定纔會消失。

volatile的底層就是通過內存屏障來實現的,lock前綴指令就相當於一個內存屏障

那麼什麼又是內存屏障呢?

內存屏障是一組CPU指令,爲了提高程序的運行效率,編譯器和處理器運行對指令進行重排序,JMM爲了保證程序運行結果的準確性,規定存在數據依賴的機器指令禁止重排序

通過插入特定類型的內存屏障(例如lock前綴指令)來禁止特定類型的編譯器重排序和處理器重排序,插入一條內存屏障會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。

所以爲了保證每個cpu的數據一致性,每一個cpu會通過嗅探總線上傳播的數據來檢查自己數據的有效性,當發現自己緩存的數據的內存地址被修改,就會讓自己緩存該數據的緩存行失效,重新獲取數據,保證了數據的可見性。

那麼既然volatile可以保證可見性,它可以保證數據的原子性嗎?

什麼是原子性呢?原子性就是即不可再分了,不能分爲多步操作。在Java中只有對基本類型變量的賦值和讀取才是原子操作。

i = 1,但是像j = i或者i++都不是原子操作,因爲他們都進行了多次原子操作,比如先讀取i的值,再將i的值賦值給j,兩個原子操作加起來就不是原子操作了。

所以假如一個volatileinteger自增(i++),其實要分成3步:

  1. 讀取主內存中volatile變量值到工作內存;
  2. 在工作內存中增加變量的值;
  3. 把工作內存的值寫主內存。

假如有兩個線程都要執行a變量的自增操作,當線程1執行a++;語句時,先是讀入a的值爲0,此時a線程的執行時間被讓出。

線程2獲得執行,線程2會重新從主內存中,讀入a的值還是0,然後線程2執行+1操作,最後把a=1刷新到主內存中;

線程2執行完後,線程1由開始執行,但之前已經讀取的a的值0,因爲前面的讀取原子操作已經結束了,所以它還是在0的基礎上執行+1操作,也就是還是等於1,並刷新到主內存中。所以最終的結果是a變量的值爲1

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