面試官:談?wù)勀銓?Volatile 的理解吧
前言
在之前的文章 深入分析 Synchronized 原理 介紹了 Synchronized是一種鎖的機制,存在阻塞和性能的問題,而 volatile 是 java 虛擬機提供的最輕量級的同步機制,volatile 主要提供修飾共享變量賦予 “可見性” 和 “有序性”。從簡單的 Demo 引出我們今天的主題 -- volatile。
Demo -- 多線程共享對象 控制執(zhí)行開關(guān)。
public class Demo {
private static boolean switchStatus = false;
public static void main(String[] args) {
new Thread(() -> {
System.out.println("開始工作");
while (!switchStatus) ;
System.out.println("結(jié)束工作");
}).start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
switchStatus = true;
System.out.println("命令停止工作");
}
}
本意是想通過 switchStatus 作為控制工作線程的開關(guān),但是實際執(zhí)行后,會發(fā)現(xiàn)結(jié)果并沒有按照預(yù)期 輸出"結(jié)束工作",而是失聯(lián)了一樣停不下來了,在死循環(huán)中出不來了。
但是如果在上面的 Demo 進(jìn)行稍微的修改即可滿足預(yù)期: private static volatile boolean switchStatus = false; 此時符合預(yù)期關(guān)閉開關(guān)時,工作線程也隨之關(guān)閉了。接下我會針對這2個現(xiàn)象原理進(jìn)行解答,為了讀者更好的理解,得先引入幾個知識點(計算機內(nèi)存模型、JMM-Java 內(nèi)存模型)。
計算機內(nèi)存模型
為了更好地理解后續(xù) JMM 和 volatile,我們先了解下計算機內(nèi)存模型,簡單地介紹下:
程序執(zhí)行時,CPU接收到指令 需要進(jìn)行計算時,讀取所需要的數(shù)據(jù),會先嘗試從 CPU Cache 中獲取,若沒有再從主內(nèi)存中獲取,計算完成后,將結(jié)果寫入 CPU Cache ,若沒有特殊指令的情況下,會根據(jù)操作系統(tǒng)自身定義的時間 一段時間會將 CPU Cache 刷新到主內(nèi)存中(未被volatile 修飾的普通變量);當(dāng)然遇到特殊的指令會將 CPU Cache 刷新到主內(nèi)存中(被volatile 修飾的變量 就是依賴這個特性實現(xiàn)可見性)。
- CPU:處理程序中各種指令,需要和CPU Cache 和 內(nèi)存打交道。
- CPU Cache:由于 CPU 和內(nèi)存的速度差 幾個數(shù)量級,CPU 直接和內(nèi)存打交道很浪費 CPU 性能,因此引入了 CPU Cache 降低 CPU 的性能損耗。
- 緩存一致性協(xié)議/總線鎖機制:引入 CPU Cache降低了 CPU性能損耗的問題,同時引入了緩存不一致的問題,為了解決這個這個問題通過緩存一致性協(xié)議/總線鎖機制 進(jìn)行解決。
總線鎖機制
CPU和其他功能部件是通過總線通信的,如果在總線加LOCK#鎖,那么在鎖住總線期間,其他CPU是無法訪問內(nèi)存,這樣一來,效率就比較低了。因此需要進(jìn)行優(yōu)化,細(xì)化控制鎖的粒度,我們只需要保證,對于被多個CPU緩存的同一份數(shù)據(jù)是一致的就行,所以引入了緩存鎖,他的核心機制就是緩存一致性協(xié)議。
緩存一致性協(xié)議
為了達(dá)成數(shù)據(jù)訪問的一致性,需要各個處理器在訪問內(nèi)存時,遵循一些協(xié)議,在讀寫時根據(jù)協(xié)議來操作,常見的協(xié)議有,MSI,MESI,MOSI等等,最常見的就是MESI協(xié)議;MESI表示緩存行的四種狀態(tài)(modify、 Exclusive、Shared、 Invalid)。
嗅探技術(shù)
如何保證當(dāng)前處理器的內(nèi)部緩存、主內(nèi)存和其他處理器的緩存數(shù)據(jù)在總線上保持一致的?多處理器總線嗅探。
在多處理器下,為了保證各個處理器的緩存是一致的,就會實現(xiàn)緩存緩存一致性協(xié)議,每個處理器通過嗅探在總線上傳播的數(shù)據(jù)來檢查自己的緩存值是不是過期了,如果處理器發(fā)現(xiàn)自己緩存行對應(yīng)的內(nèi)存地址被修改,就會將當(dāng)前處理器的緩存行設(shè)置無效狀態(tài),當(dāng)處理器對這個數(shù)據(jù)進(jìn)行修改操作的時候,會重新從系統(tǒng)內(nèi)存中把數(shù)據(jù)庫讀到處理器緩存中。
Java內(nèi)存模型
- Java虛擬機規(guī)范試圖定義一種Java內(nèi)存模型,來屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,以實現(xiàn)讓Java程序在各種平臺上都能達(dá)到一致的內(nèi)存訪問效果。
- 為了更好地執(zhí)行性能,java內(nèi)存模型并沒有限制執(zhí)行引擎使用處理器的特定寄存器或緩存來和主內(nèi)存打交道,也沒有限制編譯器進(jìn)行調(diào)整代碼順序優(yōu)化。所以Java內(nèi)存模型會存在緩存一致性問題和指令重排序問題的。
- Java內(nèi)存模型規(guī)定所有的變量都是存在主內(nèi)存當(dāng)中(類似于計算機模型中的物理內(nèi)存),每個線程都有自己的工作內(nèi)存(類似于計算機模型的高速緩存)。這里的變量包括實例變量和靜態(tài)變量,但是不包括局部變量,因為局部變量是線程私有的。
- 線程的工作內(nèi)存保存了被該線程使用的變量的主內(nèi)存副本,線程對變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接操作操作主內(nèi)存。并且每個線程不能訪問其他線程的工作內(nèi)存。
舉個例子:
# 初始值
i = 0;
# 線程A 和 線程B同時進(jìn)行操作
i = i + 1;
首先,執(zhí)行線程A從主內(nèi)存中讀取到 i=0,到工作內(nèi)存。然后在工作內(nèi)存中,賦值 i+1,工作內(nèi)存就得到 i=1,最后把結(jié)果寫回主內(nèi)存。如果是單線程的話,該語句執(zhí)行是沒問題的。但是在多線程的情況下,線程B的本地工作內(nèi)存和線程A的工作內(nèi)存讀取的時間相同都是 i=0,但是線程A將 i=1寫入主內(nèi)存中,線程B不知情的情況下,也做了 i+1 的操作,此時就出現(xiàn)可見性帶來問題了:連續(xù)2次的 i=i+1 最終的結(jié)果是1。
volatiole 可見性、有序性
在之前的文章 深入分析 Synchronized 原理 已經(jīng)介紹過 原子性、可見性、有序性定義,這里也就不展開說了。
先說結(jié)論:依賴于 CPU 緩存一致性協(xié)議 和 內(nèi)存屏障 解決了可見性的問題。
正常來說,volatile 基于緩存一致性協(xié)議就應(yīng)該可以實現(xiàn)可見性(在上面已經(jīng)介紹過 緩存一致性協(xié)議和嗅探技術(shù)),但是由于 Java 為了提高性能允許重排序(編譯器重排序 和 處理器重排序),因此需要通過內(nèi)存屏障來防止重排序,來保證每個線程執(zhí)行的每個指令有一定的順序性。
java 內(nèi)存屏障
java的內(nèi)存屏障通常所謂的四種即 LoadLoad、StoreStore、 LoadStore、StoreLoad 實際上也是上述兩種的組合,完成一系列的屏障和數(shù)據(jù)同步功能。
- LoadLoad屏障:對于這樣的語句Load1; LoadLoad; Load2,在Load2及后續(xù)讀取操作要讀取的數(shù)據(jù)被訪問前,保證Load1要讀取的數(shù)據(jù)被讀取完畢。
- StoreStore屏障:對于這樣的語句Store1; StoreStore; Store2,在Store2及后續(xù)寫入操作執(zhí)行前,保證Store1的寫入操作對其它處理器可見。
- LoadStore屏障:對于這樣的語句Load1; LoadStore; Store2,在Store2及后續(xù)寫入操作被刷出前,保證Load1要讀取的數(shù)據(jù)被讀取完畢。
- StoreLoad屏障:對于這樣的語句Store1; StoreLoad; Load2,在Load2及后續(xù)所有讀取操作執(zhí)行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數(shù)處理器的實現(xiàn)中,這個屏障是個萬能屏障,兼具其它三種內(nèi)存屏障的功能。
volatile 語義的內(nèi)存屏障
- 在每個 volatile 寫操作前插入 StoreStore 屏障,在寫操作后插入 StoreLoad 屏障。
- 在每個 volatile 讀操作前插入 LoadLoad 屏障,在讀操作后插入 LoadStore 屏障。
- 由于內(nèi)存屏障的作用,避免了 volatile 變量和其它指令重排序、線程之間實現(xiàn)了通信,使得 volatile 表現(xiàn)出了鎖的特性。
舉一個 volatile 防止指令重排的場景
java 中 DLC單例模式 大家應(yīng)該很熟悉了,只不過大家是否有注意到 uniqueInstance 被 volatile 修飾的作用嗎? 就是為了防止指令重排。
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getInstance() {
if (uniqueInstance != null) {
synchronized (Singleton.class) {
if (uniqueInstance != null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
初始化一個類,會產(chǎn)生多條匯編指令,總結(jié)下來主要執(zhí)行下面三點:
- 給 uniqueInstance 的實例分配內(nèi)存。
- 初始化 Singleton 的構(gòu)造器。
- 將 uniqueInstance 對象指向分配的內(nèi)存空間(按順序到這步 uniqueInstance 初始化完成)。
理想的狀態(tài)下:1 -> 2 -> 3,但是 Java 為了提高性能允許重排序,可能會將初始化一個類的順序進(jìn)行變化,比如:1 -> 3 -> 2,這種情況下就可能會出現(xiàn)NPE,修飾了volatile 防止重排序,避免獲取到 uniqueInstance 未初始化完成,導(dǎo)致NPE
最后簡單總結(jié)下:volatile 在指令之間插入內(nèi)存屏障 + 緩存一致性協(xié)議,保證按照特定順序執(zhí)行和某些變量的可見性。volatile 通過 內(nèi)存屏障通知 CPU 和編譯器阻止指令重排優(yōu)化來維持有序性。