前言
volatile是Java程序員必備的基礎,也是面試官非常喜歡問的一個話題,本文跟大家一起開啟vlatile學習之旅,如果有不正確的地方,也麻煩大家指出哈,一起相互學習~
-
1.volatile的用法 -
2.vlatile變量的作用 -
3.現代計算機的內存模型(計算機模型,總線,MESI協議,嗅探技術) -
4.Java內存模型(JMM) -
5.並發編程的3個特性(原子性、可見性、有序性、happen-before、as-if-serial、指令重排) -
6.volatile的底層原理(如何保證可見性,如何保證指令重排,內存屏障) -
7.volatile的典型場景(狀態標志,DCL單例模式) -
8.volatile常見面試題&&答案解析 -
公眾號:撿田螺的小男孩
「github 地址」
❝https://github.com/whx123/JavaHome
❞
1.volatile的用法
volatile關鍵字是Java虛擬機提供的的「最輕量級的同步機制」,它作為一個修飾符出現,用來「修飾變量」,但是這里不包括局部變量哦。我們來看個demo吧,代碼如下:
/**
* @Author 撿田螺的小男孩
* @Date 2020/08/02
* @Desc volatile的可見性探索
*/
public class VolatileTest {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread t1 = new Thread(task, "線程t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println("開始通知線程停止");
task.stop = true; //修改stop變量值。
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "線程t2");
t1.start(); //開啟線程t1
t2.start(); //開啟線程t2
Thread.sleep(1000);
}
}
class Task implements Runnable {
boolean stop = false;
int i = 0;
@Override
public void run() {
long s = System.currentTimeMillis();
while (!stop) {
i++;
}
System.out.println("線程退出" + (System.currentTimeMillis() - s));
}
}
「運行結果:」 可以發現線程t2,雖然把stop設置為true了,但是線程t1對t2的「stop變量視而不可見」,因此,它一直在死循環running中。如果給變量stop加上volatile修飾,線程t1是可以停下來的,運行結果如下:
volatile boolean stop = false;

2. vlatile修飾變量的作用
從以上例子,我們可以發現變量stop,加了vlatile修飾之后,線程t1對stop就可見了。其實,vlatile的作用就是:「保證變量對所有線程可見性」。當然,vlatile還有個作用就是,「禁止指令重排」,但是它「不保證原子性」。
所以當面試官問你「volatile的作用或者特性」,都可以這么回答:
-
保證變量對所有線程可見性; -
禁止指令重排序 -
不保證原子性
3. 現代計算機的內存模型(計算機模型,MESI協議,嗅探技術,總線)
為了更好理解volatile,先回顧一下計算機的內存模型與JMM(Java內存模型)吧~
計算機模型
計算機執行程序時,指令是由CPU處理器執行的,而打交道的數據是在主內存當中的。
由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,總不能每次CPU執行完指令,然后等主內存慢悠悠存取數據吧, 所以現代計算機系統加入一層讀寫速度接近處理器運算速度的高速緩存(Cache),以作為來作為內存與處理器之間的緩沖。
在多路處理器系統中,每個處理器都有自己的高速緩存,而它們共享同一主內存。「計算機抽象內存模型」如下:

-
程序執行時,把需要用到的數據,從主內存拷貝一份到高速緩存。 -
CPU處理器計算時,從它的高速緩存中讀取,把計算完的數據寫入高速緩存。 -
當程序運算結束,把高速緩存的數據刷新會主內存。
隨着科學技術的發展,為了效率,高速緩存又衍生出一級緩存(L1),二級緩存(L2),甚至三級緩存(L3);
當多個處理器的運算任務都涉及同一塊主內存區域,可能導致「緩存數據不一致」問題。如何解決這個問題呢?有兩種方案
❝❞
1、通過在總線加LOCK#鎖的方式。 2、通過緩存一致性協議(Cache Coherence Protocol)
總線
❝總線(Bus)是計算機各種功能部件之間傳送信息的公共通信干線,它是由導線組成的傳輸線束, 按照計算機所傳輸的信息種類,計算機的總線可以划分為數據總線、地址總線和控制總線,分別用來傳輸數據、數據地址和控制信號。
❞
CPU和其他功能部件是通過總線通信的,如果在總線加LOCK#鎖,那么在鎖住總線期間,其他CPU是無法訪問內存,這樣一來,「效率就比較低了」。
MESI協議
為了解決一致性問題,還可以通過緩存一致性協議。即各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比較著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)協議,它的核心思想是:
❝當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置為無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那么它就會從內存重新讀取。
❞
CPU中每個緩存行標記的4種狀態(M、E、S、I),也了解一下吧:
緩存狀態 | 描述 |
---|---|
M,被修改(Modified) | 該緩存行只被該CPU緩存,與主存的值不同,會在它被其他CPU讀取之前寫入內存,並設置為Shared |
E,獨享的(Exclusive) | 該緩存行只被該CPU緩存,與主存的值相同,被其他CPU讀取時置為Shared,被其他CPU寫時置為Modified |
S,共享的(Shared) | 該緩存行可能被多個CPU緩存,各個緩存中的數據與主存數據相同 |
I,無效的(Invalid) | 該緩存行數據是無效,需要時需重新從主存載入 |
MESI協議是如何實現的?如何保證當前處理器的內部緩存、主內存和其他處理器的緩存數據在總線上保持一致的?「多處理器總線嗅探」
嗅探技術
❝在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己的緩存值是不是過期了,如果處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據庫讀到處理器緩存中。
❞
4. Java內存模型(JMM)
-
Java虛擬機規范試圖定義一種Java內存模型,來 「屏蔽掉各種硬件和操作系統的內存訪問差異」,以實現讓Java程序在各種平台上都能達到一致的內存訪問效果。 -
Java內存模型 「類比」於計算機內存模型。 -
為了更好的執行性能,java內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存打交道,也沒有限制編譯器進行調整代碼順序優化。所以Java內存模型 「會存在緩存一致性問題和指令重排序問題的」。 -
Java內存模型規定所有的變量都是存在主內存當中(類似於計算機模型中的物理內存),每個線程都有自己的工作內存(類似於計算機模型的高速緩存)。這里的 「變量」包括實例變量和靜態變量,但是 「不包括局部變量」,因為局部變量是線程私有的。 -
線程的工作內存保存了被該線程使用的變量的主內存副本, 「線程對變量的所有操作都必須在工作內存中進行」,而不能直接操作操作主內存。並且每個線程不能訪問其他線程的工作內存。

舉個例子吧,假設i的初始值是0,執行以下語句:
i = i+1;
首先,執行線程t1從主內存中讀取到i=0,到工作內存。然后在工作內存中,賦值i+1,工作內存就得到i=1,最后把結果寫回主內存。因此,如果是單線程的話,該語句執行是沒問題的。但是呢,線程t2的本地工作內存還沒過期,那么它讀到的數據就是臟數據了。如圖:
Java內存模型是圍繞着如何在並發過程中如何處理「原子性、可見性和有序性」這3個特征來建立的,我們再來一起回顧一下~
5.並發編程的3個特性(原子性、可見性、有序性)
原子性
原子性,指操作是不可中斷的,要么執行完成,要么不執行,基本數據類型的訪問和讀寫都是具有原子性,當然(long和double的非原子性協定除外)。我們來看幾個小例子:
i =666; // 語句1
i = j; // 語句2
i = i+1; //語句 3
i++; // 語句4
-
語句1操作顯然是原子性的,將數值666賦值給i,即線程執行這個語句時,直接將數值666寫入到工作內存中。 -
語句2操作看起來也是原子性的,但是它實際上涉及兩個操作,先去讀j的值,再把j的值寫入工作內存,兩個操作分開都是原子操作,但是合起來就不滿足原子性了。 -
語句3讀取i的值,加1,再寫回主存,這個就不是原子性操作了。 -
語句4 等同於語句3,也是非原子性操作。
可見性
-
可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。 -
Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此。 -
volatile變量,保證新值能立即同步回主內存,以及每次使用前立即從主內存刷新,所以我們說volatile保證了多線程操作變量的可見性。 -
synchronized和Lock也能夠保證可見性,線程在釋放鎖之前,會把共享變量值都刷回主存。final也可以實現可見性。
有序性
Java虛擬機這樣描述Java程序的有序性的:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中,觀察另一個線程,所有的操作都是無序的。
后半句意思就是,在Java內存模型中,「允許編譯器和處理器對指令進行重排序」,會影響到多線程並發執行的正確性;前半句意思就是「as-if-serial」的語義,即不管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不會被改變。
比如以下程序代碼:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
步驟C依賴於步驟A和B,因為指令重排的存在,程序執行順訊可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面執行,這將違反as-if-serial語義。
看段代碼吧,假設程序先執行read方法,再執行add方法,結果一定是輸出sum=2嘛?
bool flag = false;
int b = 0;
public void read() {
b = 1; //1
flag = true; //2
}
public void add() {
if (flag) { //3
int sum =b+b; //4
System.out.println("bb sum is"+sum);
}
}
如果是單線程,結果應該沒問題,如果是多線程,線程t1對步驟1和2進行了「指令重排序」呢?結果sum就不是2了,而是0,如下圖所示:
這是為啥呢?「指令重排序」了解一下,指令重排是指在程序執行過程中,「為了提高性能」, 「編譯器和CPU可能會對指令進行重新排序」。CPU重排序包括指令並行重排序和內存系統重排序,重排序類型和重排序執行過程如下:

實際上,可以給flag加上volatile關鍵字,來保證有序性。當然,也可以通過synchronized和Lock來保證有序性。synchronized和Lock保證某一時刻是只有一個線程執行同步代碼,相當於是讓線程順序執行程序代碼了,自然就保證了有序性。
實際上Java內存模型的有序性並不是僅靠volatile、synchronized和Lock來保證有序性的。這是因為Java語言中,有一個先行發生原則(happens-before):
-
「程序次序規則」:在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在后面的操作。 -
「管程鎖定規則」:一個unLock操作先行發生於后面對同一個鎖額lock操作 -
「volatile變量規則」:對一個變量的寫操作先行發生於后面對這個變量的讀操作 -
「線程啟動規則」:Thread對象的start()方法先行發生於此線程的每個一個動作 -
「線程終止規則」:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行 -
「線程中斷規則」:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生 -
「對象終結規則」:一個對象的初始化完成先行發生於他的finalize()方法的開始 -
「傳遞性」:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
根據happens-before的八大規則,我們回到剛的例子,一起分析一下。給flag加上volatile關鍵字,look look它是如何保證有序性的,
volatile bool flag = false;
int b = 0;
public void read() {
b = 1; //1
flag = true; //2
}
public void add() {
if (flag) { //3
int sum =b+b; //4
System.out.println("bb sum is"+sum);
}
}
-
首先呢,flag加上volatile關鍵字,那就禁止了指令重排,也就是1 happens-before 2了 -
根據 「volatile變量規則」,2 happens-before 3 -
由 「程序次序規則」,得出 3 happens-before 4 -
最后由 「傳遞性」,得出1 happens-before 4,因此妥妥的輸出sum=2啦~
6.volatile底層原理
以上討論學習,我們知道volatile的語義就是保證變量對所有線程可見性以及禁止指令重排優化。那么,它的底層是如何保證可見性和禁止指令重排的呢?
圖解volatile是如何保證可見性的?
在這里,先看幾個圖吧,哈哈~
假設flag變量的初始值false,現在有兩條線程t1和t2要訪問它,就可以簡化為以下圖:
如果線程t1執行以下代碼語句,並且flag沒有volatile修飾的話;t1剛修改完flag的值,還沒來得及刷新到主內存,t2又跑過來讀取了,很容易就數據flag不一致了,如下:
flag=true;

如果flag變量是由volatile修飾的話,就不一樣了,如果線程t1修改了flag值,volatile能保證修飾的flag變量后,可以「立即同步回主內存」。如圖:

細心的朋友會發現,線程t2不還是flag舊的值嗎,這不還有問題嘛?其實volatile還有一個保證,就是「每次使用前立即先從主內存刷新最新的值」,線程t1修改完后,線程t2的變量副本會過期了,如圖:
顯然,這里還不是底層,實際上volatile保證可見性和禁止指令重排都跟「內存屏障」有關,我們編譯volatile相關代碼看看~
DCL單例模式(volatile)&編譯對比
DCL單例模式(Double Check Lock,雙重檢查鎖)比較常用,它是需要volatile修飾的,所以就拿這段代碼編譯吧
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
編譯這段代碼后,觀察有volatile關鍵字和沒有volatile關鍵字時的instance所生成的匯編代碼發現,有volatile關鍵字修飾時,會多出一個lock addl $0x0,(%esp),即多出一個lock前綴指令
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24
lock指令相當於一個「內存屏障」,它保證以下這幾點:
❝❞
1.重排序時不能把后面的指令重排序到內存屏障之前的位置 2.將本處理器的緩存寫入內存 3.如果是寫入動作,會導致其他處理器中對應的緩存無效。
顯然,第2、3點不就是volatile保證可見性的體現嘛,第1點就是禁止指令重排列的體現。
內存屏障
內存屏障四大分類:(Load 代表讀取指令,Store代表寫入指令)
內存屏障類型 | 抽象場景 | 描述 |
---|---|---|
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的內存語義,Java內存模型采取以下的保守策略
-
在每個volatile寫操作的前面插入一個StoreStore屏障。 -
在每個volatile寫操作的后面插入一個StoreLoad屏障。 -
在每個volatile讀操作的前面插入一個LoadLoad屏障。 -
在每個volatile讀操作的后面插入一個LoadStore屏障。
有些小伙伴,可能對這個還是有點疑惑,內存屏障這玩意太抽象了。我們照着代碼看下吧:
內存屏障保證前面的指令先執行,所以這就保證了禁止了指令重排啦,同時內存屏障保證緩存寫入內存和其他處理器緩存失效,這也就保證了可見性,哈哈~
7.volatile的典型場景
通常來說,使用volatile必須具備以下2個條件:
-
1)對變量的寫操作不依賴於當前值 -
2)該變量沒有包含在具有其他變量的不變式中
實際上,volatile場景一般就是「狀態標志」,以及「DCL單例模式」。
7.1 狀態標志
深入理解Java虛擬機,書中的例子:
Map configOptions;
char[] configText;
// 此變量必須定義為 volatile
volatile boolean initialized = false;
// 假設以下代碼在線程 A 中運行
// 模擬讀取配置信息, 當讀取完成后將 initialized 設置為 true 以告知其他線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假設以下代碼在線程 B 中運行
// 等待 initialized 為 true, 代表線程 A 已經把配置信息初始化完成
while(!initialized) {
sleep();
}
// 使用線程 A 中初始化好的配置信息
doSomethingWithConfig();
7.2 DCL單例模式
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
8. volatile相關經典面試題
-
談談volatile的特性 -
volatile的內存語義 -
說說並發編程的3大特性 -
什么是內存可見性,什么是指令重排序? -
volatile是如何解決java並發中可見性的問題 -
volatile如何防止指令重排 -
volatile可以解決原子性嘛?為什么? -
volatile底層的實現機制 -
volatile和synchronized的區別?
8.1 談談volatile的特性

8.2 volatile的內存語義
-
當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存。 -
當讀一個 volatile 變量時,JMM 會把該線程對應的本地內存置為無效。線程接下來將從主內存中讀取共享變量。
8.3 說說並發編程的3大特性
-
原子性 -
可見性 -
有序性
8.4 什么是內存可見性,什么是指令重排序?
-
可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。 -
指令重排是指JVM在編譯Java代碼的時候,或者CPU在執行JVM字節碼的時候,對現有的指令順序進行重新排序。
8.5 volatile是如何解決java並發中可見性的問題
底層是通過內存屏障實現的哦,volatile能保證修飾的變量后,可以立即同步回主內存,每次使用前立即先從主內存刷新最新的值。
8.6 volatile如何防止指令重排
也是內存屏障哦,跟面試官講下Java內存的保守策略:
-
在每個volatile寫操作的前面插入一個StoreStore屏障。 -
在每個volatile寫操作的后面插入一個StoreLoad屏障。 -
在每個volatile讀操作的前面插入一個LoadLoad屏障。 -
在每個volatile讀操作的后面插入一個LoadStore屏障。
再講下volatile的語義哦,重排序時不能把內存屏障后面的指令重排序到內存屏障之前的位置
8.7 volatile可以解決原子性嘛?為什么?
不可以,可以直接舉i++那個例子,原子性需要synchronzied或者lock保證
public class Test {
public volatile int race = 0;
public void increase() {
race++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
//等待所有累加線程結束
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.race);
}
}
8.8 volatile底層的實現機制
可以看本文的第六小節,volatile底層原理哈,主要你要跟面試官講述,volatile如何保證可見性和禁止指令重排,需要講到內存屏障~
8.9 volatile和synchronized的區別?
-
volatile修飾的是變量,synchronized一般修飾代碼塊或者方法 -
volatile保證可見性、禁止指令重排,但是不保證原子性;synchronized可以保證原子性 -
volatile不會造成線程阻塞,synchronized可能會造成線程的阻塞,所以后面才有鎖優化那么多故事~ -
哈哈,你還有補充嘛~
推薦之前寫的一篇文章: Synchronized解析——如果你願意一層一層剝開我的心[1]
公眾號

參考與感謝
-
<<深入理解Java虛擬機>> -
Java並發編程:volatile關鍵字解析 [2] -
面試官最愛的volatile關鍵字 [3] -
面試官沒想到一個Volatile,我都能跟他扯半小時 [4] -
再有人問你Java內存模型是什么,就把這篇文章發給他。 [5] -
【並發編程】MESI--CPU緩存一致性協議 [6] -
漫畫:volatile對指令重排的影響 [7] -
volatile三大特性詳解 [8]
Reference
Synchronized解析——如果你願意一層一層剝開我的心: https://juejin.im/post/6844903918653145102
[2]Java並發編程:volatile關鍵字解析: https://www.cnblogs.com/dolphin0520/p/3920373.html
[3]面試官最愛的volatile關鍵字: https://juejin.im/post/6844903520760496141
[4]面試官沒想到一個Volatile,我都能跟他扯半小時: https://juejin.im/post/6844904149536997384
[5]再有人問你Java內存模型是什么,就把這篇文章發給他。: http://47.103.216.138/archives/2550
[6]【並發編程】MESI--CPU緩存一致性協議: https://www.cnblogs.com/z00377750/p/9180644.html
[7]漫畫:volatile對指令重排的影響 : https://www.sohu.com/a/211287207_684445
[8]volatile三大特性詳解: https://www.jianshu.com/p/765e3abbe89a