面试造飞机系列:volatile面试的连环追击,你还好吗?
?點擊上方?好好學java?,選擇?星標?公眾號
重磅資訊、干貨,第一時間送達 今日推薦:為什么程序員都不喜歡使用switch,而是大量的 if……else if ?個人原創+1博客:點擊前往,查看更多本文腦圖
volatile是java中熱門關鍵字,也是面試中的高頻問點,今天就來深入的從各種volatile面試題中剖析它的底層原理實現,并通過簡單的代碼去證明。
在深入volatile之前,我們先從原理入手,然后層層深入,逐步剖析它的底層原理,使用過volatile關鍵字的程序員都知道,在多線程并發場景中volitile能夠保障共享變量的可見性。
那么問題來了,什么是可見性呢?volatile是怎么保障共享變量的可見性的呢?
附上我歷時三個月總結的?Java 面試 + Java 后端技術學習指南,這是本人這幾年及春招的總結,目前,已經拿到了大廠offer,拿去不謝!
下載方式
1.?首先掃描下方二維碼
2.?后臺回復「Java面試」即可獲取
在說可見性之前,我們先來了解在多線程的條件下,線程與線程之間是怎么通信的,我們先來看看一張圖:
在Java線程中每次的讀取和寫入不會直接操作主內存,因為cpu的速度遠快于主內存的速度,若是直接操作主內存,大大限制了cpu的性能,對性能有很大的影響,所以每條線程都有各自的工作內存。
這里的工作內存類似于緩存,并非實際存在的,因為緩存的讀取和寫入的速度遠大于主內存,這樣就大大提高了cpu與數據交互的性能。
所有的共享變量都是直接存儲于主內存中,工作內存保存線程在使用主內存共享變量的副本,當操作完工作內存的變量,會寫入主內存,完成對共享變量的讀取和寫入。
在單線程時代,不存在數據一致性的的問題,線程都是排隊的順序執行,前面的線程執行完才會到后面的線程執行。
隨著計算機的發展,到了多核多線程的時代,緩存的出現雖然提升了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
最后,再附上我歷時三個月總結的?Java 面試 + Java 后端技術學習指南,這是本人這幾年及春招的總結,目前,已經拿到了大廠offer,拿去不謝!
下載方式
1.?首先掃描下方二維碼
2.?后臺回復「Java面試」即可獲取
總結
以上是生活随笔為你收集整理的面试造飞机系列:volatile面试的连环追击,你还好吗?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java 必知必会的 20 种常用类库和
- 下一篇: Linux最常用命令:简单易学,但能解决