先來提出問題和給出答案,之后再刨根問底的揭開面紗:
問: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; } }
這么看的話,代碼應該沒有問題呀,那我們再定義 instance 時,增加了 volatile 關鍵字,作用到底是什么?不加可不可以呢?
1、分配對象內存空間 2、初始化對象 3、設置 instance 指向剛剛分配的內存地址,此時 instance != null (重點)
1、分配對象內存空間 2、設置 instance 指向剛剛分配的內存地址,此時 instance != null (重點) 3、初始化對象
volatile底層如何實現指令重排
volatile 是通過 內存屏障 來防止指令重排序的。再上剛才那個圖:
硬件層面的內存屏障分為 Load Barrier 和 Store 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屏障。