volatile 徹底搞懂


先來提出問題和給出答案,之后再刨根問底的揭開面紗:

  問:volatile 的可見性和禁止指令重排序是怎么實現的?

  答:可見性:是通過緩存一致性協議來達到的

    禁止指令重排序:JMM 模型里有 8 個指令來完成數據的讀寫,通過其中 load 和 store 指令相互組合成的 4 個內存屏障實現禁止指令重排序。

可見性

  我們知道線程中運行的代碼最終都是交給CPU執行的,而代碼執行時所需使用到的數據來自於內存(或者稱之為主存)。但是CPU是不會直接操作內存的,每個CPU都會有自己的緩存,操作緩存的速度比操作主存更快。

  因此當某個線程需要修改一個數據時,事實上步驟是如下的:

  1、將主存中的數據加載到緩存中

  2、CPU對緩存中的數據進行修改

  3、將修改后的值刷新到內存中

  多個線程操作同一個變量的情況,則可以用下圖表示(這個圖下面會出現幾次)

  

  第一步:線程1、線程2、線程3 操作的是主存中的同一個變量,並且分別交由 CPU1、CPU2、CPU3 處理。

  第二步:3個CPU分別將主存中變量加載到緩存中

  第三步:各自將修改后的值刷新到主存中

  問題就出現在第二步,因為每個CPU操作的是各自的緩存,所以不同的CPU之間是無法感知其他CPU對這個變量的修改的,最終就可能導致結果與我們的預期不符。

  而使用了volatile關鍵字之后,情況就有所不同,volatile關鍵字有兩層語義:

  1、立即將緩存中數據寫回到內存中

  2、其他處理器通過嗅探總線上傳播過來的數據監測自己緩存的值是不是過期了,如果過期了,就會對應的將緩存中的數據置為無效。而當處理器對這個數據進行修改時,會重新從內存中把數據讀取到緩存中進行處理。

  在這種情況下,不同的CPU之間就可以感知其他CPU對變量的修改,並重新從內存中加載更新后的值,因此可以解決可見性問題。

補充:

  緩存一致性協議有多種,但是日常處理的大多數計算機設備使用的都屬於“窺探(snooping)”協議。

  “窺探” 背后的基本思想是,所有內存傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條總線。

  緩存本身是獨立的,但是內存是共享資源,所有的內存訪問都要經過仲裁(arbitrate):同一個指令周期中,只有一個緩存可以讀寫內存。窺探協議的思想是,緩存不僅僅在做內存傳輸的時候才和總線打交道,而是不停地在窺探總線上發生的數據交換,跟蹤其他緩存在做什么。所以當一個緩存代表它所屬的處理器去讀寫內存時,其他處理器都會得到通知,它們以此來使自己的緩存保持同步。只要某個處理器一寫內存,其他處理器馬上就知道這塊內存在它們自己的緩存中對應的段已經失效。

實際使用場景

  在實際開發中,我們在程序中通常會有一些標記標記變量。程序運行時根據根據這個標記變量值的不同決定是否執行某段業務邏輯處理代碼。例如:

public class VolatileDemo {
    volatile static Boolean flag = true;
    public static void main(String[] args) {
       //該線程每隔1毫秒,修改一次flag的值
        new Thread(){
            public void run() {
                try {
                    this.sleep(1);
                    flag=!flag;
                } catch (InterruptedException e) {e.printStackTrace();}
            }
        }.start();
 
        //主線程通過死循環不斷根據flag進行判斷是否要執行某段代碼
        while(true){
            if(flag){
                System.out.println("do some thing...");
            }else {
                System.out.println("...");
            }
        }
    }
}

  在這段代碼中,由於我們在 flag 標識字段上使用了volatile關鍵字,因此自定義線程每次修改時狀態變量的值時,主線程都可以實時的感知到。

  特別的,對於多個線程都依賴於同一個狀態變量的值來判斷是否要執行某段代碼時,使用volatile關鍵字更為有用,其可以保證多個線程在任一時刻的行為都是一致的。

禁止指令重排序

  我們先來看一道面試題:

  在單例 DCL 方式下,有沒有必要加 volatile 關鍵詞,為什么?

  讀到這里不會有不知道 DCL 是什么東東?那就有點孤陋寡聞了,DCL=雙重檢索,看以下單例代碼就應該可以明白了:

public class Singleton {
    // 有沒有必要增加 volatile 關鍵字
    private volatile static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) { // 第一次判斷
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次判斷
                    instance = new Singleton();
                }
            }
        }
        reurn instance;
    }
}
可以看到, 當多個線程同時去調用 getInstance 方法時, 如果 instance 還沒初始化, 即 instance == null, 線程進入 第一個if方法, 然后會有一個線程率先拿到鎖, 其他線程開始等待, 此時instance == null。拿到鎖的線程進入到同步方法並進入第二個if方法里, 先通過new的方式初始化一個實例, 之后再釋放鎖, 此時 instance != null。與此同時剛剛等待的線程開始競爭鎖, 拿到鎖的線程進入到同步方法里, 發現此時instance已經初始化過了, 所以就不進入第二個if方法, 直接返回已經初始化過的 instance。后續如果再有線程調用 getInstance 方法, 在第一個if判斷那里就為false, 直接返回剛剛已經初始化過的instance了, 這樣做的好處是: 保證了線程安全, 避免了多個線程同時調用 getInstance 方法時, 每次都在競爭鎖, 增加系統的開銷。

  這么看的話,代碼應該沒有問題呀,那我們再定義 instance 時,增加了 volatile 關鍵字,作用到底是什么?不加可不可以呢?

這里就要先從計算機指令講起, CPU 和 編譯器為了提升程序的執行效率, 通常會按照一定的規則對指令進行優化, 如果兩條指令互不依賴, 有可能它們執行的順序並不是源代碼編寫的順序,這就是我們所說的 指令重排。
正常情況 instance = new Instance() 可以分成三步:
  1、分配對象內存空間
  2、初始化對象
  3、設置 instance 指向剛剛分配的內存地址,此時 instance != null (重點)
因為 2 3 步不存在數據上的依賴關系, 即在單線程的情況下, 無論 2 和 3 誰先執行, 都不影響最終的結果, 所以在程序編譯時, 有可能它的順序就變成了:
1、分配對象內存空間
  2、設置 instance 指向剛剛分配的內存地址,此時 instance != null (重點)
  3、初始化對象
但是, CPU 和 編譯器在指令重排時, 並不會關心是否影響多線程的執行結果。在不加 volatile關鍵字時, 如果有多個線程訪問 getInstance 方法, 此時正好發生了指令重排, 那么可能出現如下情況:
當第一個線程拿到鎖並且 進入到第二個if方法后, 先分配對象內存空間, 然后再 instance 指向剛剛分配的內存地址, instance 已經不等於null, 但此時 instance 還沒有初始化完成。如果這個時候又有一個線程來調用 getInstance 方法, 在 第一個if的判斷結果就為false, 於是直接返回還沒有初始化完成的 instance, 那么就很有可能產生異常。
所以,加了 volatile 之后,會強制 cpu 和 編譯器按照順序執行代碼,所以就不需要擔心指令重排導致上面的問題了。

volatile底層如何實現指令重排

  volatile 是通過 內存屏障 來防止指令重排序的。再上剛才那個圖:

 

  硬件層面的內存屏障分為 Load BarrierStore Barrier讀屏障寫屏障

  對於 Load Barrier 來說,在指令前插入 Load Barrier,可以讓高速緩存中的數據失效,強制從主內存加載數據。

  對於 Store Barrier 來說,在指令后插入 Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。

  java 的內存屏障通常所謂的四種即:

  1. LoadLoad

  2. StoreStore

  3. LoadStore

  4. StoreLoad

  實際上也是上述兩種的組合,完成一系列的 屏障 和 數據同步 功能。

  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防止指令重排序具體步驟

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

  2、在每個 volatile寫操作 后面插入一個 StoreLoad屏障

  3、在每個 volatile讀操作 后面插入一個 LoadLoad屏障

  4、在每個 volatile讀操作 后面插入一個 LoadStore屏障

    


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM