反制面試官 | 14張原理圖 | 再也不怕被問 volatile!
阿新 • • 發佈:2020-08-17
# 反制面試官 | 14張原理圖 | 再也不怕被問 volatile!
> 悟空
> 愛學習的程式猿,自主開發了Java學習平臺、PMP刷題小程式。目前主修Java、多執行緒、SpringBoot、SpringCloud、k8s。本公眾號不限於分享技術,也會分享工具的使用、人生感悟、讀書總結。
# 絮叨
這一篇也算是Java併發程式設計的開篇,看了很多資料,但是輪到自己去整理去總結的時候,發現還是要多看幾遍資料才能完全理解。還有一個很重要的點就是,畫圖是加深印象和檢驗自己是否理解的一個非常好的方法。
# 一、Volatile怎麼念?
![volatile怎麼念](http://cdn.jayh.club/blog/20200813/143958482.png)
看到這個單詞一直不知道怎麼發音
```sh
英 [ˈvɒlətaɪl] 美 [ˈvɑːlətl]
adj. [化學] 揮發性的;不穩定的;爆炸性的;反覆無常的
```
那Java中volatile又是幹啥的呢?
# 二、Java中volatile用來幹啥?
- Volatile是Java虛擬機器提供的`輕量級`的同步機制(三大特性)
- 保證可見性
- 不保證原子性
- 禁止指令重排
要理解三大特性,就必須知道Java記憶體模型(JMM),那JMM又是什麼呢?
![volatile怎麼念](http://cdn.jayh.club/blog/20200813/142913898.png)
# 三、JMM又是啥?
這是一份精心總結的Java記憶體模型思維導圖,拿去不謝。
![拿走不謝](http://cdn.jayh.club/blog/20200817/172346703.png)
![原理圖1-Java記憶體模型](http://cdn.jayh.club/blog/20200812/154932124.png)
## 3.1 為什麼需要Java記憶體模型?
> `Why`:遮蔽各種硬體和作業系統的記憶體訪問差異
JMM是Java記憶體模型,也就是Java Memory Model,簡稱JMM,本身是一種抽象的概念,實際上並不存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。
## 3.2 到底什麼是Java記憶體模型?
- 1.定義程式中各種變數的訪問規則
- 2.把變數值儲存到記憶體的底層細節
- 3.從記憶體中取出變數值的底層細節
## 3.3 Java記憶體模型的兩大記憶體是啥?
![原理圖2-兩大記憶體](http://cdn.jayh.club/blog/20200816/lCr3gHq8fxlV.png)
- 主記憶體
- Java堆中物件例項資料部分
- 對應於物理硬體的記憶體
- 工作記憶體
- Java棧中的部分割槽域
- 優先儲存於暫存器和快取記憶體
## 3.4 Java記憶體模型是怎麼做的?
Java記憶體模型的幾個規範:
- 1.所有變數儲存在主記憶體
- 2.主記憶體是虛擬機器記憶體的一部分
- 3.每條執行緒有自己的工作記憶體
- 4.執行緒的工作記憶體儲存變數的主記憶體副本
- 5.執行緒對變數的操作必須在工作記憶體中進行
- 6.不同執行緒之間無法直接訪問對方工作記憶體中的變數
- 7.執行緒間變數值的傳遞均需要通過主記憶體來完成
由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),工作記憶體是每個執行緒的私有資料區域,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,`但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫會主記憶體`,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中儲存著主記憶體中的變數副本拷貝,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程:
![原理圖3-Java記憶體模型](http://cdn.jayh.club/blog/20200812/141310523.png)
## 3.5 Java記憶體模型的三大特性
- 可見性(當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改)
- 原子性(一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗)
- 有序性(變數賦值操作的順序與程式程式碼中的執行順序一致)
關於有序性:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內似表現為序列的語義”(Within-Thread As-If-Serial Semantics),後半句是指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。
# 四、能給個示例說下怎麼用volatile的嗎?
考慮一下這種場景:
> 有一個物件的欄位`number`初始化值=0,另外這個物件有一個公共方法`setNumberTo100()`可以設定number = 100,當主執行緒通過子執行緒來呼叫`setNumberTo100()`後,主執行緒是否知道number值變了呢?
>
> 答案:如果沒有使用volatile來定義number變數,則主執行緒不知道子執行緒更新了number的值。
(1)定義如上述所說的物件:`ShareData`
``` java
class ShareData {
int number = 0;
public void setNumberTo100() {
this.number = 100;
}
}
```
(2)主執行緒中初始化一個子執行緒,名字叫做`子執行緒`
子執行緒先休眠3s,然後設定number=100。主執行緒不斷檢測的number值是否等於0,如果不等於0,則退出主執行緒。
```java
public class volatileVisibility {
public static void main(String[] args) {
// 資源類
ShareData shareData = new ShareData();
// 子執行緒 實現了Runnable介面的,lambda表示式
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 執行緒睡眠3秒,假設在進行運算
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改number的值
myData.setNumberTo100();
// 輸出修改後的值
System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
}, "子執行緒").start();
while(myData.number == 0) {
// main執行緒就一直在這裡等待迴圈,直到number的值不等於零
}
// 按道理這個值是不可能打印出來的,因為主執行緒執行的時候,number的值為0,所以一直在迴圈
// 如果能輸出這句話,說明子執行緒在睡眠3秒後,更新的number的值,重新寫入到主記憶體,並被main執行緒感知到了
System.out.println(Thread.currentThread().getName() + "\t 主執行緒感知到了 number 不等於 0");
/**
* 最後輸出結果:
* 子執行緒 come in
* 子執行緒 update number value:100
* 最後執行緒沒有停止,並行沒有輸出"主執行緒知道了 number 不等於0"這句話,說明沒有用volatile修飾的變數,變數的更新是不可見的
*/
}
}
```
![沒有使用volatile](http://cdn.jayh.club/blog/20200812/pjFzpO0940kR.png?imageslim)
(3)我們用volatile修飾變數number
```
class ShareData {
//volatile 修飾的關鍵字,是為了增加多個執行緒之間的可見性,只要有一個執行緒修改了記憶體中的值,其它執行緒也能馬上感知
volatile int number = 0;
public void setNumberTo100() {
this.number = 100;
}
}
```
輸出結果:
``` sh
子執行緒 come in
子執行緒 update number value:100
main 主執行緒知道了 number 不等於 0
Process finished with exit code 0
```
![mark](http://cdn.jayh.club/blog/20200812/L0Iey883RrF1.png?imageslim)
**小結:說明用volatile修飾的變數,當某執行緒更新變數後,其他執行緒也能感知到。**
# 五、那為什麼其他執行緒能感知到變數更新?
![mark](http://cdn.jayh.club/blog/20200813/142850201.png)
其實這裡就是用到了“窺探(snooping)”協議。在說“窺探(snooping)”協議之前,首先談談快取一致性的問題。
## 5.1 快取一致性
當多個CPU持有的快取都來自同一個主記憶體的拷貝,當有其他CPU偷偷改了這個主記憶體資料後,其他CPU並不知道,那拷貝的記憶體將會和主記憶體不一致,這就是快取不一致。那我們如何來保證快取一致呢?這裡就需要作業系統來共同制定一個同步規則來保證,而這個規則就有MESI協議。
如下圖所示,CPU2 偷偷將num修改為2,記憶體中num也被修改為2,但是CPU1和CPU3並不知道num值變了。
![原理圖4-快取一致性1](http://cdn.jayh.club/blog/20200813/151759806.png)
## 5.2 MESI
當CPU寫資料時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,系統會發出訊號通知其它CPU將該記憶體變數的`快取行`設定為無效。如下圖所示,CPU1和CPU3 中num=1已經失效了。
![原理圖5-快取一致性2](http://cdn.jayh.club/blog/20200813/151723708.png)
當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。
如下圖所示,CPU1和CPU3發現快取的num值失效了,就重新從記憶體讀取,num值更新為2。
![原理圖6-快取一致性3](http://cdn.jayh.club/blog/20200813/151741176.png)
## 5.3 匯流排嗅探
那其他CPU是怎麼知道要將快取更新為失效的呢?這裡是用到了匯流排嗅探技術。
每個CPU不斷嗅探總線上傳播的資料來檢查自己快取值是否過期了,如果處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作的時候,會重新從記憶體中把資料讀取到處理器快取中。
![原理圖7-快取一致性4](http://cdn.jayh.club/blog/20200813/153713445.png)
## 5.4 匯流排風暴
匯流排嗅探技術有哪些缺點?
由於MESI快取一致性協議,需要不斷對主線進行記憶體嗅探,大量的互動會導致匯流排頻寬達到峰值。因此不要濫用volatile,可以用鎖來替代,看場景啦~
# 六、能演示下volatile為什麼不保證原子性嗎?
原子性:一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗。
**這個定義和volatile啥關係呀,完全不能理解呀?Show me the code!**
考慮一下這種場景:
> 當20個執行緒同時給number自增1,執行1000次以後,number的值為多少呢?
在單執行緒的場景,答案是20000,如果是多執行緒的場景下呢?答案是可能是20000,但很多情況下都是小於20000。
示例程式碼:
``` java
package com.jackson0714.passjava.threads;
/**
演示volatile 不保證原子性
* @create: 2020-08-13 09:53
*/
public class VolatileAtomicity {
public static volatile int number = 0;
public static void increase() {
number++;
}
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increase();
}
}, String.valueOf(i)).start();
}
// 當所有累加執行緒都結束
while(Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(number);
}
}
```
執行結果:第一次19144,第二次20000,第三次19378。
![volatile第一次執行結果](http://cdn.jayh.club/blog/20200813/123109848.png)
![volatile第二次執行結果](http://cdn.jayh.club/blog/20200813/123128802.png)
![volatile第三次執行結果](http://cdn.jayh.club/blog/20200813/123144768.png)
我們來分析一下increase()方法,通過反編譯工具javap得到如下彙編程式碼:
``` java
public static void increase();
Code:
0: getstatic #2 // Field number:I
3: iconst_1
4: iadd
5: putstatic #2 // Field number:I
8: return
```
number++其實執行了`3條指令`:
> getstatic:拿number的原始值
> iadd:進行加1操作
> putfield:把加1後的值寫回
執行了getstatic指令number的值取到操作棧頂時,volatile關鍵字保證了number的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他執行緒可能已經把number的值改變了,而操作棧頂的值就變成了過期的資料,所以putstatic指令執行後就可能把較小的number值同步回主記憶體之中。
總結如下:
> 在執行number++這行程式碼時,即使使用volatile修飾number變數,在執行期間,還是有可能被其他執行緒修改,沒有保證原子性。
# 七、怎麼保證輸出結果是20000呢?
## 7.1 synchronized同步程式碼塊
我們可以通過使用synchronized同步程式碼塊來保證原子性。從而使結果等於20000
``` java
public synchronized static void increase() {
number++;
}
```
![synchronized同步程式碼塊執行結果](http://cdn.jayh.club/blog/20200813/155502445.png)
但是使用synchronized太重了,會造成阻塞,只有一個執行緒能進入到這個方法。我們可以使用Java併發包(JUC)中的AtomicInterger工具包。
## 7.2 AtomicInterger原子性操作
我們來看看AtomicInterger原子自增的方法getAndIncrement()
![AtomicInterger](http://cdn.jayh.club/blog/20200813/160301296.png)
```java
public static AtomicInteger atomicInteger = new AtomicInteger();
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
atomicInteger.getAndIncrement();
}
}, String.valueOf(i)).start();
}
// 當所有累加執行緒都結束
while(Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(atomicInteger);
}
```
多次執行的結果都是20000。
![getAndIncrement的執行結果](http://cdn.jayh.club/blog/20200813/160859873.png)
# 八、禁止指令重排又是啥?
說到指令重排就得知道為什麼要重排,有哪幾種重排。
如下圖所示,指令執行順序是按照1>2>3>4的順序,經過重排後,執行順序更新為指令3->4->2->1。
![原理圖8-指令重排](http://cdn.jayh.club/blog/20200813/163136149.png)
會不會感覺到重排把指令順序都打亂了,這樣好嗎?
可以回想下小學時候的數學題:`2+3-5=?`,如果把運算順序改為`3-5+2=?`,結果也是一樣的。所以指令重排是要保證單執行緒下程式結果不變的情況下做重排。
## 8.1 為什麼要重排
計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。
## 8.2 有哪幾種重排
- 1.編譯器優化重排:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
- 2.指令級的並行重排:現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 3.記憶體系統的重排:由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。
![原理圖9-三種重排](http://cdn.jayh.club/blog/20200813/163810499.png)
注意:
- 單執行緒環境裡面確保最終執行結果和程式碼順序的結果一致
- 處理器在進行重排序時,必須要考慮指令之間的`資料依賴性`
- 多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。
## 8.3 舉個例子來說說多執行緒中的指令重排?
設想一下這種場景:定義了變數num=0和變數flag=false,執行緒1呼叫初始化函式init()執行後,執行緒呼叫add()方法,當另外執行緒判斷flag=true後,執行num+100操作,那麼我們預期的結果是num會等於101,但因為有指令重排的可能,num=1和flag=true執行順序可能會顛倒,以至於num可能等於100
```java
public class VolatileResort {
static int num = 0;
static boolean flag = false;
public static void init() {
num= 1;
flag = true;
}
public static void add() {
if (flag) {
num = num + 5;
System.out.println("num:" + num);
}
}
public static void main(String[] args) {
init();
new Thread(() -> {
add();
},"子執行緒").start();
}
}
```
先看執行緒1中指令重排:
`num= 1;flag = true;` 的執行順序變為` flag=true;num = 1;`,如下圖所示的時序圖
![原理圖10-執行緒1指令重排](http://cdn.jayh.club/blog/20200817/103712457.png)
如果執行緒2 num=num+5 線上程1設定num=1之前執行,那麼執行緒2的num變數值為5。如下圖所示的時序圖。
![原理圖11-執行緒2在num=1之前執行](http://cdn.jayh.club/blog/20200817/104431268.png)
## 8.4 volatile怎麼實現禁止指令重排?
我們使用volatile定義flag變數:
```java
static volatile boolean flag = false;
```
**如何實現禁止指令重排:**
原理:在volatile生成的指令序列前後插入`記憶體屏障`(Memory Barries)來禁止處理器重排序。
**有如下四種記憶體屏障:**
![四種記憶體屏障](http://cdn.jayh.club/blog/20200817/111016399.png)
**volatile寫的場景如何插入記憶體屏障:**
- 在每個volatile寫操作的前面插入一個StoreStore屏障(寫-寫 屏障)。
- 在每個volatile寫操作的後面插入一個StoreLoad屏障(寫-讀 屏障)。
![原理圖12-volatile寫的場景如何插入記憶體屏障](http://cdn.jayh.club/blog/20200817/140553584.png)
> StoreStore屏障可以保證在volatile寫(flag賦值操作flag=true)之前,其前面的所有普通寫(num的賦值操作num=1) 操作已經對任意處理器可見了,保障所有普通寫在volatile寫之前重新整理到主記憶體。
**volatile讀場景如何插入記憶體屏障:**
- 在每個volatile讀操作的後面插入一個LoadLoad屏障(讀-讀 屏障)。
- 在每個volatile讀操作的後面插入一個LoadStore屏障(讀-寫 屏障)。
![原理圖13-volatile讀場景如何插入記憶體屏障](http://cdn.jayh.club/blog/20200817/140656284.png)
> LoadStore屏障可以保證其後面的所有普通寫(num的賦值操作num=num+5) 操作必須在volatile讀(if(flag))之後執行。
# 十、volatile常見應用
這裡舉一個應用,雙重檢測鎖定的單例模式
```java
package com.jackson0714.passjava.threads;
/**
演示volatile 單例模式應用(雙邊檢測)
* @author: 悟空聊架構
* @create: 2020-08-17
*/
class VolatileSingleton {
private static VolatileSingleton instance = null;
private VolatileSingleton() {
System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
}
public static VolatileSingleton getInstance() {
// 第一重檢測
if(instance == null) {
// 鎖定程式碼塊
synchronized (VolatileSingleton.class) {
// 第二重檢測
if(instance == null) {
// 例項化物件
instance = new VolatileSingleton();
}
}
}
return instance;
}
}
```
程式碼看起來沒有問題,但是 `instance = new VolatileSingleton();`其實可以看作三條虛擬碼:
``` java
memory = allocate(); // 1、分配物件記憶體空間
instance(memory); // 2、初始化物件
instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null
```
步驟2 和 步驟3之間不存在 資料依賴關係,而且無論重排前 還是重排後,程式的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。
```java
memory = allocate(); // 1、分配物件記憶體空間
instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null,但是物件還沒有初始化完成
instance(memory); // 2、初始化物件
```
如果另外一個執行緒執行:`if(instance == null) `時,則返回剛剛分配的記憶體地址,但是物件還沒有初始化完成,拿到的instance是個假的。如下圖所示:
![原理圖14-雙重檢鎖存在的併發問題](http://cdn.jayh.club/blog/20200817/163917793.png)
解決方案:定義instance為volatile變數
```java
private static volatile VolatileSingleton instance = null;
```
# 十一、volatile都不保證原子性,為啥我們還要用它?
奇怪的是,volatile都不保證原子性,為啥我們還要用它?
volatile是輕量級的同步機制,對效能的影響比synchronized小。
> 典型的用法:檢查某個狀態標記以判斷是否退出迴圈。
比如執行緒試圖通過類似於數綿羊的傳統方法進入休眠狀態,為了使這個示例能正確執行,asleep必須為volatile變數。否則,當asleep被另一個執行緒修改時,執行判斷的執行緒卻發現不了。
**那為什麼我們不直接用synchorized,lock鎖?它們既可以保證可見性,又可以保證原子性為何不用呢?**
因為synchorized和lock是排他鎖(悲觀鎖),如果有多個執行緒需要訪問這個變數,將會發生競爭,只有一個執行緒可以訪問這個變數,其他執行緒被阻塞了,會影響程式的效能。
> 注意:當且僅當滿足以下所有條件時,才應該用volatile變數
>
> - 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
> - 該變數不會與其他的狀態一起納入不變性條件中。
> - 在訪問變數時不需要加鎖。
# 十二、volatile和synchronzied的區別
- volatile只能修飾例項變數和類變數,synchronized可以修飾方法和程式碼塊。
- volatile不保證原子性,而synchronized保證原子性
- volatile 不會造成阻塞,而synchronized可能會造成阻塞
- volatile 輕量級鎖,synchronized重量級鎖
- volatile 和synchronized都保證了可見性和有序性
# 十三、小結
- volatile 保證了可見性:當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改。
- volatile 保證了單執行緒下指令不重排:通過插入記憶體屏障保證指令執行順序。
- volatitle不保證原子性,如a++這種自增操作是有併發風險的,比如扣減庫存、發放優惠券的場景。
- volatile 型別的64位的long型和double型變數,對該變數的讀/寫具有原子性。
- volatile 可以用在雙重檢鎖的單例模式種,比synchronized效能更好。
- volatile 可以用在檢查某個狀態標記以判斷是否退出迴圈。
**期待後篇麼?CAS走起!**
**我是悟空,越挫越勇的悟空,奧利給!**
![悟空](http://cdn.jayh.club/blog/20200817/165822357.png)
參考資料:
《深入理解Java虛擬機器》
《Java併發程式設計的藝術》
《Java併發程式設計實戰》