本文腦圖volatile
是java
中熱門關鍵字,也是面試中的高頻問點,今天就來深刻的從各類volatile
面試題中剖析它的底層原理實現,並經過簡單的代碼去證實。java
在深刻volatile
以前,咱們先從原理入手,而後層層深刻,逐步剖析它的底層原理,使用過volatile
關鍵字的程序員都知道,在多線程併發場景中volitile
可以保障共享變量的可見性。程序員
那麼問題來了,什麼是可見性呢?volatile是怎麼保障共享變量的可見性的呢?面試
在說可見性以前,咱們先來了解在多線程的條件下,線程與線程之間是怎麼通訊的,咱們先來看看一張圖:緩存
在Java線程中每次的 讀取和 寫入不會直接操做主內存,由於 cpu
的速度遠快於主內存的速度,如果直接操做主內存,大大限制了cpu的性能,對性能有很大的影響,因此每條線程都有各自的 工做內存。多線程
這裏的工做內存相似於緩存,並不是實際存在的,由於緩存的讀取和寫入的速度遠大於主內存,這樣就大大提升了cpu
與數據交互的性能。併發
全部的共享變量都是直接存儲於主內存中,工做內存保存線程在使用主內存共享變量的副本,當操做完工做內存的變量,會寫入主內存,完成對共享變量的讀取和寫入。app
在單線程時代,不存在數據一致性的的問題,線程都是排隊的順序執行,前面的線程執行完纔會到後面的線程執行。ide
隨着計算機的發展,到了 多核多線程的時代, 緩存的出現雖然提高了 cpu
的執行效率,可是卻出現了 緩存一致性的問題,爲了解決數據的一致性問題,提出兩種解決方案:性能
總線上加Lock#鎖:該方法簡單粗暴,在總線上加鎖,其它cpu的線程只能排隊等候,效率低下。優化
緩存一致性協議:該方案是JMM中提出的解決方案,經過對變量地址加鎖,減少鎖的粒度,執行變得更加高效。
爲了提升程序的執行效率,設計者們提出了底層對編譯器和執行器(處理器)的優化方案,分別是編譯器和處理器的重排序
那麼什麼是編譯器重排序和處理器啊重排序呢?
編譯器重排序就是在不改變單線程的語義的前提下,能夠從新排列語句的執行順序。
處理器排序是在機器指令的層面,假如不存在數據依賴,處理器能夠改變機器指令的執行順序,爲了提升程序的執行效率,在多線程中假如兩行的代碼存在數據依賴,將會被禁止重排序。
不論是編譯器重排序和處理器的重排序,前提條件都不能改變單線程語義的前提下進行重排序,說白了就是最後的執行結果要準確無誤。
學過大學的計算機基礎課都知道,咱們的程序用高級語言寫完後是不能被各大平臺的機器所執行的,須要執行編譯,而後將編譯後的字節碼文件處理成機器指令,才能被計算機執行。
從java源代碼到最終的機器執行指令,分別會通過下面三種重排序:
在這裏插入圖片描述
前面說到了數據依賴的特性,什麼是數據依賴呢?
數據依賴就是假設一句代碼中對一個變量a++
自增,而後後一句代碼b=a
將a的值賦值給b,便表示這兩句代碼存在數據依賴,兩句代碼執行順序不能互換。
前面提到編譯器和處理器的重排序,在編譯器和處理器進行重排序的時候,就會遵照數據的依賴性,編譯器和處理器就會禁止存在數據依賴的兩個操做進行重排序,保證了數據的準確性。
在JDK5
開始,爲了保證程序的有序性,便提出了happen-before
原則,假如兩個操做符合該原則,那麼這兩個操做能夠隨意的進行重排序,並不會影響結果的正確性。
具體happen-before
原則有6條,具體原則以下所示:
同一個線程中前面的操做先於後續的操做(可是這個並非絕對的,假如在單線程的環境下,重排序後不會影響結果的準確性,是能夠進行重排序,不按代碼的順序執行)。
Synchronized
規則中解鎖操做先於後續的加鎖操做。
volatile
規則中寫操做先於後續的讀取操做,保證數據的可見性。
一個線程的start()
方法先於任何該線程的全部後續操做。
線程的全部操做先於其餘該線程在該線程上調用join返回成功的操做。
若是操做a先於操做b,操做b先於操做c,那麼操做a先於操做c,傳遞性原理。
咱們來看重點第三條,也就是咱們今天所瞭解的重點volatile關鍵字,爲了實現volatile內存語義,規定有volatile修飾的共享變量在機器指令層面會出出現Lock前綴的指令。
咱們來看看一個例子經典的例子,具體的代碼以下:
public class TestVolatile extends Thread { private static boolean flag = false; public void run() { while (!flag) ; System.out.println("run方法退出了") } public static void main(String[] args) throws Exception { new TestVolatile().start(); Thread.sleep(5000); flag = true; } }
看上面的代碼執行run方法能執行退出嗎?是不能的,由於對於這兩個線程來講,首先new TestVolatile().start()
線程拿到flag
共享變量的值爲false,並存儲在於本身的工做內存中。
第一個線程到while循環中,就直接進入死循環,即便主線程讀取flag的值,而後改變該值爲true。
可是對於第一個線程來講並不知道,flag的值已經被修改,在第一個線程的工做內存中flag仍然爲false。具體的執行原理圖以下:
這樣對於共享變量flag,主線程修改後,對於線程1來講是不可見的,而後咱們加上volatile變量修飾該變量,修改代碼以下:
private static volatile boolean flag = false;
輸出的結果中,就會輸出run方法退出了,具體的原理假如一個共享變量被Volatile
修飾,該指令在多核處理器下會引起兩件事情。
將當前處理器緩存行數據寫回主內存中。
這個寫入的操做會讓其它處理器中已經緩存了該變量的內存地址失效,當其它處理器需求再次使用該變量時,必須從主內存中從新讀取該值。
讓咱們具體從idea的輸出的彙編指令中能夠看出,咱們看到紅色線框裏面的那行指令:putstatic flag
,將靜態變量flag
入棧,注意觀察add指令前面有一個lock
前綴指令。
注意:讓idea輸出程序的彙編指令,在啓動程序的時候,能夠加上
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
做爲啓動參數,就能夠查看彙編指令。
簡單的說被volatile修飾的共享變量,在lock指令後是一個原子操做,該原子操做不會被其它線程的調度機制打斷,該原子操做一旦執行就會運行到結束,中間不會切換到任意一個線程。
當使用lock前綴的機器指令,它會向cpu發送一個LOCK#信號,這樣能保證在多核多線程的狀況下互斥的使用該共享變量的內存地址。直到執行完畢,該鎖定纔會消失。
volatile的底層就是經過內存屏障來實現的,lock前綴指令就至關於一個內存屏障。
那麼什麼又是內存屏障呢?
內存屏障是一組CPU
指令,爲了提升程序的運行效率,編譯器和處理器運行對指令進行重排序,JMM爲了保證程序運行結果的準確性,規定存在數據依賴的機器指令禁止重排序。
經過插入特定類型的內存屏障(例如lock前綴指令)來禁止特定類型的編譯器重排序和處理器重排序,插入一條內存屏障會告訴編譯器和CPU:無論什麼指令都不能和這條Memory Barrier
指令重排序。
因此爲了保證每一個cpu的數據一致性,每個cpu會經過嗅探總線上傳播的數據來檢查本身數據的有效性,當發現本身緩存的數據的內存地址被修改,就會讓本身緩存該數據的緩存行失效,從新獲取數據,保證了數據的可見性。
那麼既然volatile能夠保證可見性,它能夠保證數據的原子性嗎?
什麼是原子性呢?原子性就是即不可再分了,不能分爲多步操做。在Java中只有對基本類型變量的賦值和讀取才是原子操做。
如i = 1
,可是像j = i
或者i++
都不是原子操做,由於他們都進行了屢次原子操做,好比先讀取i的值,再將i的值賦值給j,兩個原子操做加起來就不是原子操做了。
因此假如一個volatile
的integer
自增(i++)
,其實要分紅3步:
讀取主內存中volatile變量值到工做內存;
在工做內存中增長變量的值;
把工做內存的值寫入主內存。
假若有兩個線程都要執行a變量的自增操做,當線程1執行a++;語句時,先是讀入a的值爲0,此時a線程的執行時間被讓出。
線程2得到執行,線程2會從新從主內存中,讀入a的值仍是0,而後線程2執行+1操做,最後把a=1刷新到主內存中;
線程2執行完後,線程1又開始執行,但以前已經讀取的a的值0,由於前面的讀取原子操做已經結束了,因此它仍是在0的基礎上執行+1操做,也就是仍是等於1,並刷新到主內存中。因此最終的結果是a變量的值爲1