在Java相關的崗位面試中,不少面試官都喜歡考察面試者對Java併發的瞭解程度,而以volatile關鍵字做爲一個小的切入點,每每能夠一問到底,把Java內存模型(JMM),Java併發編程的一些特性都牽扯出來,深刻地話還能夠考察JVM底層實現以及操做系統的相關知識。java
下面咱們以一次假想的面試過程,來深刻了解下volitile關鍵字吧!c++
就我理解的而言,被volatile修飾的共享變量,就具備瞭如下兩點特性:面試
1 . 保證了不一樣線程對該變量操做的內存可見性;編程
2 . 禁止指令重排序緩存
這個聊起來可就多了,我仍是從Java內存模型提及吧。多線程
Java虛擬機規範試圖定義一種Java內存模型(JMM),來屏蔽掉各類硬件和操做系統的內存訪問差別,讓Java程序在各類平臺上都能達到一致的內存訪問效果。簡單來講,因爲CPU執行指令的速度是很快的,可是內存訪問的速度就慢了不少,相差的不是一個數量級,因此搞處理器的那羣大佬們又在CPU里加了好幾層高速緩存。併發
在Java內存模型裏,對上述的優化又進行了一波抽象。JMM規定全部變量都是存在主存中的,相似於上面提到的普通內存,每一個線程又包含本身的工做內存,方便理解就能夠當作CPU上的寄存器或者高速緩存。因此線程的操做都是以工做內存爲主,它們只能訪問本身的工做內存,且工做先後都要把值在同步回主內存。app
這麼說得我本身都有些不清楚了,拿張紙畫一下:優化
在線程執行時,首先會從主存中read變量值,再load到工做內存中的副本中,而後再傳給處理器執行,執行完畢後再給工做內存中的副本賦值,隨後工做內存再把值傳回給主存,主存中的值才更新。atom
使用工做內存和主存,雖然加快的速度,可是也帶來了一些問題。好比看下面一個例子:
i = i + 1;
假設i初值爲0,當只有一個線程執行它時,結果確定獲得1,當兩個線程執行時,會獲得結果2嗎?這倒不必定了。可能存在這種狀況:
線程1: load i from 主存 // i = 0 i + 1 // i = 1 線程2: load i from主存 // 由於線程1還沒將i的值寫回主存,因此i仍是0 i + 1 //i = 1 線程1: save i to 主存 線程2: save i to 主存
若是兩個線程按照上面的執行流程,那麼i最後的值竟然是1了。若是最後的寫回生效的慢,你再讀取i的值,均可能是0,這就是緩存不一致問題。
下面就要提到你剛纔問到的問題了,JMM主要就是圍繞着如何在併發過程當中如何處理原子性、可見性和有序性這3個特徵來創建的,經過解決這三個問題,能夠解除緩存不一致的問題。而volatile跟可見性和有序性都有關。
1 . 原子性(Atomicity): Java中,對基本數據類型的讀取和賦值操做是原子性操做,所謂原子性操做就是指這些操做是不可中斷的,要作必定作完,要麼就沒有執行。
好比:
i = 2; j = i; i++; i = i + 1;
上面4個操做中,i=2
是讀取操做,一定是原子性操做,j=i
你覺得是原子性操做,其實吧,分爲兩步,一是讀取i的值,而後再賦值給j,這就是2步操做了,稱不上原子操做,i++
和i = i + 1
實際上是等效的,讀取i的值,加1,再寫回主存,那就是3步操做了。因此上面的舉例中,最後的值可能出現多種狀況,就是由於知足不了原子性。
這麼說來,只有簡單的讀取,賦值是原子操做,還只能是用數字賦值,用變量的話還多了一步讀取變量值的操做。有個例外是,虛擬機規範中容許對64位數據類型(long和double),分爲2次32爲的操做來處理,可是最新JDK實現仍是實現了原子操做的。
JMM只實現了基本的原子性,像上面i++
那樣的操做,必須藉助於synchronized
和Lock
來保證整塊代碼的原子性了。線程在釋放鎖以前,必然會把i
的值刷回到主存的。
2 . 可見性(Visibility):
說到可見性,Java就是利用volatile來提供可見性的。
當一個變量被volatile修飾時,那麼對它的修改會馬上刷新到主存,當其它線程須要讀取該變量時,會去內存中讀取新值。而普通變量則不能保證這一點。
其實經過synchronized和Lock也可以保證可見性,線程在釋放鎖以前,會把共享變量值都刷回主存,可是synchronized和Lock的開銷都更大。
3 . 有序性(Ordering)
JMM是容許編譯器和處理器對指令重排序的,可是規定了as-if-serial語義,即無論怎麼重排序,程序的執行結果不能改變。好比下面的程序段:
double pi = 3.14; //A double r = 1; //B double s= pi * r * r;//C
上面的語句,能夠按照A->B->C
執行,結果爲3.14,可是也能夠按照B->A->C
的順序執行,由於A、B是兩句獨立的語句,而C則依賴於A、B,因此A、B能夠重排序,可是C卻不能排到A、B的前面。JMM保證了重排序不會影響到單線程的執行,可是在多線程中卻容易出問題。
好比這樣的代碼:
int a = 0; bool flag = false; public void write() { a = 2; //1 flag = true; //2 } public void multiply() { if (flag) { //3 int ret = a * a;//4 } }
假若有兩個線程執行上述代碼段,線程1先執行write,隨後線程2再執行multiply,最後ret的值必定是4嗎?結果不必定:
如圖所示,write方法裏的1和2作了重排序,線程1先對flag賦值爲true,隨後執行到線程2,ret直接計算出結果,再到線程1,這時候a才賦值爲2,很明顯遲了一步。
這時候能夠爲flag加上volatile關鍵字,禁止重排序,能夠確保程序的「有序性」,也能夠上重量級的synchronized和Lock來保證有序性,它們能保證那一塊區域裏的代碼都是一次性執行完畢的。
另外,JMM具有一些先天的有序性,即不須要經過任何手段就能夠保證的有序性,一般稱爲happens-before原則。<<JSR-133:Java Memory Model and Thread Specification>>
定義了以下happens-before規則:
ThreadB_start()
(啓動線程B) , 那麼A線程的ThreadB_start()
happens-before 於B中的任意操做ThreadB.join()
而且成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()
操做成功返回。interrupt()
方法的調用先行發生於被中斷線程代碼檢測到中斷事件的發生,能夠經過Thread.interrupted()
方法檢測是否有中斷髮生finalize()
方法的開始第1條規則程序順序規則是說在一個線程裏,全部的操做都是按順序的,可是在JMM裏其實只要執行結果同樣,是容許重排序的,這邊的happens-before強調的重點也是單線程執行結果的正確性,可是沒法保證多線程也是如此。
第2條規則監視器規則其實也好理解,就是在加鎖以前,肯定這個鎖以前已經被釋放了,才能繼續加鎖。
第3條規則,就適用到所討論的volatile,若是一個線程先去寫一個變量,另一個線程再去讀,那麼寫入操做必定在讀操做以前。
第4條規則,就是happens-before的傳遞性。
後面幾條就再也不一一贅述了。
那就要重提volatile變量規則: 對一個volatile域的寫,happens-before於後續對這個volatile域的讀。
這條再拎出來講,其實就是若是一個變量聲明成是volatile的,那麼當我讀變量時,老是能讀到它的最新值,這裏最新值是指無論其它哪一個線程對該變量作了寫操做,都會馬上被更新到主存裏,我也能從主存裏讀到這個剛寫入的值。也就是說volatile關鍵字能夠保證可見性以及有序性。
繼續拿上面的一段代碼舉例:
int a = 0; bool flag = false; public void write() { a = 2; //1 flag = true; //2 } public void multiply() { if (flag) { //3 int ret = a * a;//4 } }
這段代碼不只僅受到重排序的困擾,即便一、2沒有重排序。3也不會那麼順利的執行的。假設仍是線程1先執行write
操做,線程2再執行multiply
操做,因爲線程1是在工做內存裏把flag賦值爲1,不必定馬上寫回主存,因此線程2執行時,multiply
再從主存讀flag值,仍然可能爲false,那麼括號裏的語句將不會執行。
若是改爲下面這樣:
int a = 0; volatile bool flag = false; public void write() { a = 2; //1 flag = true; //2 } public void multiply() { if (flag) { //3 int ret = a * a;//4 } }
那麼線程1先執行write
,線程2再執行multiply
。根據happens-before原則,這個過程會知足如下3類規則:
從內存語義上來看
當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存
當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效,線程接下來將從主內存中讀取共享變量。
首先我回答是不能保證原子性,要是說能保證,也只是對單個volatile變量的讀/寫具備原子性,可是對於相似volatile++這樣的複合操做就無能爲力了,好比下面的例子:
public class Test { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保證前面的線程都執行完 Thread.yield(); System.out.println(test.inc); }
按道理來講結果是10000,可是運行下極可能是個小於10000的值。有人可能會說volatile不是保證了可見性啊,一個線程對inc的修改,另一個線程應該馬上看到啊!但是這裏的操做inc++是個複合操做啊,包括讀取inc的值,對其自增,而後再寫回主存。
假設線程A,讀取了inc的值爲10,這時候被阻塞了,由於沒有對變量進行修改,觸發不了volatile規則。
線程B此時也讀讀inc的值,主存裏inc的值依舊爲10,作自增,而後馬上就被寫回主存了,爲11。
此時又輪到線程A執行,因爲工做內存裏保存的是10,因此繼續作自增,再寫回主存,11又被寫了一遍。因此雖然兩個線程執行了兩次increase(),結果卻只加了一次。
有人說,volatile不是會使緩存行無效的嗎?可是這裏線程A讀取到線程B也進行操做以前,並無修改inc值,因此線程B讀取的時候,仍是讀的10。
又有人說,線程B將11寫回主存,不會把線程A的緩存行設爲無效嗎?可是線程A的讀取操做已經作過了啊,只有在作讀取操做時,發現本身緩存行無效,纔會去讀主存的值,因此這裏線程A只能繼續作自增了。
綜上所述,在這種複合操做的情景下,原子性的功能是維持不了了。可是volatile在上面那種設置flag值的例子裏,因爲對flag的讀/寫操做都是單步的,因此仍是能保證原子性的。
要想保證原子性,只能藉助於synchronized,Lock以及併發包下的atomic的原子操做類了,即對基本數據類型的 自增(加1操做),自減(減1操做)、以及加法操做(加一個數),減法操做(減一個數)進行了封裝,保證這些操做是原子性操做。
若是把加入volatile關鍵字的代碼和未加入volatile關鍵字的代碼都生成彙編代碼,會發現加入volatile關鍵字的代碼會多出一個lock前綴指令。
lock前綴指令實際至關於一個內存屏障,內存屏障提供瞭如下功能:
1 . 重排序時不能把後面的指令重排序到內存屏障以前的位置
2 . 使得本CPU的Cache寫入內存
3 . 寫入動做也會引發別的CPU或者別的內核無效化其Cache,至關於讓新寫入的值對別的線程可見。
1. 狀態量標記,就如上面對flag的標記,我從新提一下:
int a = 0; volatile bool flag = false; public void write() { a = 2; //1 flag = true; //2 } public void multiply() { if (flag) { //3 int ret = a * a;//4 } }
這種對變量的讀寫操做,標記爲volatile能夠保證修改對線程馬上可見。比synchronized,Lock有必定的效率提高。
2.單例模式的實現,典型的雙重檢查鎖定(DCL)
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; } }
這是一種懶漢的單例模式,使用時才建立對象,並且爲了不初始化操做的指令重排序,給instance加上了volatile。
好吧,這又是一個話題了,volatile的問題終於問完了。。。看看你掌握了沒~