更多精彩文章,請關注xhJaver,京東工程師和你一起成長
volatile 簡介
一般用來修飾共享變量,保證可見性和可以禁止指令重排
多線程操作同一個變量的時候,某一個線程修改完,其他線程可以立即看到修改的值,保證了共享變量的可見性
禁止指令重排,保證了代碼執行的有序性
-
不保證原子性,例如常見的i++
(但是對單次讀或者寫保證原子性)
可見性代碼示例
以下代碼建議使用PC端來查看,復制黏貼直接運行,都有詳細注釋
我們來寫個代碼測試一下,多線程修改共享變量時究竟需不需要用volatile修飾變量
- 首先,我們創建一個任務類
public class Task implements Runnable{
@Override
public void run() {
System.out.println("這是"+Thread.currentThread().getName()+"線程開始,flag是 "+Demo.flag);
//當共享變量是true時,就一直卡在這里,不輸出下面那句話
// 當flag是false時,輸出下面這句話
while (Demo.flag){
}
System.out.println("這是"+Thread.currentThread().getName()+"線程結束,flag是 "+Demo.flag);
}
}
2.其次,我們創建個測試類
class Demo {
//共享變量,還沒用volatile修飾
public static boolean flag = true ;
public static void main(String[] args) throws InterruptedException {
System.out.println("這是"+Thread.currentThread().getName()+"線程開始,flag是 "+flag);
//開啟剛才線程
new Thread(new Task()).start();
try {
//沉睡一秒,確保剛才的線程已經跑到了while循環
//要不然還沒跑到while循環,主線程就將flag變為false
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
//改變共享變量flag轉為false
flag = false;
System.out.println("這是"+Thread.currentThread().getName()+"線程結束,flag是 "+flag);
}
}
3.我們查看一下輸出結果
可見,程序并沒有結束,他卡在了這里,為什么卡在了這里呢,就是因為我們在主線程修改了共享變量flag為false,但是另一個線程沒有感知到,這個變量的修改對另一個線程不可見
- 如果要是用volatile變量修飾的話,結果就變成了下面這個樣子
public static volatile boolean flag = true
可見,這次主線程修改的變量被另一個線程所感知到了,保證了變量的可見性
可見性原理分析
那么,神奇的 volatile 底層到底做了什么呢,你的改變,逃不過他的法眼?為什么不用他修飾變量的話,變量的改變其他線程就看不見?
回答此問題的時候首先,我們需要了解一下JMM(Java內存模型)
注:本地內存是JMM的一種抽象,并不是真實存在的,本地內存它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化之后的一個數據存放位置
-
由此我們可以分析出來,主線程修改了變量,但是其他線程不知道,有兩種情況
- 主線程修改的變量還沒有來得及刷新到主內存中,另一個線程讀取的還是以前的變量
- 主線程修改的變量刷新到了主內存中,但是其他線程讀取的還是本地的副本
-
當我們用
volatile
關鍵字修飾共享變量時就可以做到以下兩點- 當線程修改變量時,會強制刷新到主內存中
- 當線程讀取變量時,會強制從主內存讀取變量并且刷新到工作內存中
指令重排
- 何為指令重排?
為了提高程序運行效率,編譯器和cpu會對代碼執行的順序進行重排列,可這有時候會帶來很多問題
我們來看下代碼
//指令重排測試
public class Demo2 {
private Integer number = 10;
private boolean flag = false;
private Integer result = 0;
public void write(){
this.flag = true; // L1
this.number = 20; // L2
}
public void reader(){
while (this.flag){ // L3
this.result = this.number + 1; // L4
}
}
}
假如說我們有A、B兩個線程 他們分別執行write()方法和 reader()方法,執行的順序有可能如下圖所示
- 問題分析: 如圖可見,A線程的L2和L1的執行順序重排序了,如果要是這樣執行的話,當A執行完L2時,B開始執行L3,可是這個時候flag還是為false,那么L4就執行不了了,所以result的值還是初始值0,沒有被改變為21,導致程序執行錯誤
這個時候,我們就可以用volatile
關鍵字來解決這個問題,很簡單,只需
private volatile Integer number = 10;
- 這個時候L1就一定在L2前面執行
A線程在修改
number
變量為20的時候,就確保這句代碼的前面的代碼一定在此行代碼之前執行,在number
處插入了內存屏障 ,為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排
內存屏障
內存屏障又是什么呢?一共有四種內存屏障類型,他們分別是
-
LoadLoad屏障:
- Load1 LoadLoad Load2 確保Load1的數據的裝載先于Load2及所有后續裝載指令的裝載
-
LoadStore屏障:
- Load1 LoadStore Store2 確保Load1的數據的裝載先于Store2及所有后續存儲指令的存儲
-
StoreLoad屏障:
- Store1 StoreLoad Load2 確保Store1的數據對其他處理器可見(刷新到內存)先于Load2及所有后續的裝載指令的裝載
-
StoreStore屏障:
- Store1 StoreStore Store2 確保Store1數據對其他處理器可見(刷新到內存)先于Store2及所有后續存儲指令的存儲
StoreLoad 是一個全能型的屏障,同時具有其他3個屏障的效果。執行該屏障的花銷比較昂貴,因為處理器通常要把當前的寫緩沖區的內容全部刷新到內存中(Buffer Fully Flush)
- 裝載load 就是讀 int a = load1 ( load1的裝載)
- 存儲store就是寫 store1 = 5 ( store1的存儲)
volatile與內存屏障
那么volatile和這四種內存屏障又有什么關系呢,具體是怎么插入的呢?
-
volatile寫 (前后都插入屏障)
- 前面插入一個StoreStore屏障
-
后面插入一個StoreLoad屏障
volatile寫屏障.jpg
-
volatile讀(只在后面插入屏障)
- 后面插入一個LoadLoad屏障
-
后面插入一個LoadStore屏障
volatile讀內存屏障.jpg
官方提供的表格是這樣的
我們此時回過頭來在看我們的那個程序
<pre class="custom" data-tool="mdnice編輯器" style="margin-top: 10px; margin-bottom: 10px; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">this.flag = true; // L1 this.number = 20; // L2
</pre>
由于number被volatile修飾了,L2這句話是volatile寫,那么加入屏障后就應該是這個樣子
this.flag = true; // L1
// StoreStore 確保flag數據對其他處理器可見(刷新到內存)先于number及所有后續存儲指令的存儲
this.number = 20; // L2
// StoreLoad 確保number數據對其他處理器可見(刷新到內存)先于所有后續存儲指令的裝載
所以L1,L2的執行順序不被重排序
更多精彩,請關注xhJaver,京東工程師和你一起成長