1. 程式人生 > >反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

# 反制面試官 | 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併發程式設計實戰》