
歷史成為了傳說,傳說又成為了神話,兩千五百多年來,無人得知至尊魔戒的下落。直到,當(dāng)機(jī)緣來臨,它又誘惑了一個(gè)新的持有者。
我——的——寶——貝————
這段文字是指環(huán)王的開篇旁白。但我覺得用來形容volatile關(guān)鍵字卻再合適不過了。volatile的字面意思是“易變的,反復(fù)無常的”,但它實(shí)際的意思卻復(fù)雜得多。大量的初學(xué)者面對(duì)著它無比渴求,希望一窺究竟,卻很難在實(shí)際項(xiàng)目中用對(duì)。同時(shí),最令人討厭的是面試時(shí)還經(jīng)常被問到它。
本文嘗試為眾生梳理梳理Java的volatile。如果你覺得本文內(nèi)容比較長(zhǎng),請(qǐng)直接跳到結(jié)論。
JDK1.5之前的volatile
JDK1.5之前,volatile還是比較好理解的,即volatile是設(shè)計(jì)被用來簡(jiǎn)單解決變量可見性的。聽上去很玄乎?容我來說明一下。

上圖是一個(gè)一般的CPU和內(nèi)存的體系結(jié)構(gòu)的簡(jiǎn)化示意圖。程序運(yùn)行時(shí),每個(gè)線程會(huì)被調(diào)度到某一個(gè)CPU內(nèi)核上。為了提高性能,CPU內(nèi)核都有自己的緩存。所以當(dāng)線程1訪問某個(gè)主存內(nèi)的變量A時(shí),該變量會(huì)被復(fù)制到核內(nèi)緩存上。這就意味著,如果同一個(gè)進(jìn)程的不同線程表面上訪問一個(gè)變量時(shí),實(shí)際上訪問的變量是不同的。如果是只讀,一切安好。但一旦某個(gè)線程改變了變量的值,默認(rèn)的行為是:這個(gè)變化只會(huì)發(fā)生在核內(nèi)緩存,不會(huì)立刻更新到主內(nèi)存,更不會(huì)讓運(yùn)行在其他核的線程看到。至于什么時(shí)候這個(gè)變化能讓其他線程知道是無法定義的,也許一會(huì)就可以了,也許直到進(jìn)程結(jié)束也不會(huì)。
例如,原來這個(gè)變量是3。線程1和2都讀取了變量。然后線程1將變量修改為8。這個(gè)變化只有他自己能看得見。

這個(gè)問題被簡(jiǎn)稱為變量的可見性問題。volatile關(guān)鍵字用來解決這個(gè)問題。一旦一個(gè)變量被標(biāo)記為volatile,編譯器就要求該變量被寫入時(shí)生成一組代碼,使得
- 這個(gè)變量在寫入本核緩存的同時(shí)也被寫入主存
- 標(biāo)記所有其他核的包含同一個(gè)變量的Cache為失效。這樣下次其他線程讀取變量時(shí),自然就會(huì)通過主存取到最新值。
簡(jiǎn)單來說,將一個(gè)變量聲明為volatile就保證了一個(gè)共享變量的可見性。
public class SharedObj {
public volatile int sharedVar = 1; // 該變量對(duì)所有線程可見
}
但是,保證一個(gè)變量的可見性并不代表代碼就是正確的。例如,考慮以下代碼。假設(shè)有兩個(gè)任務(wù),一個(gè)計(jì)算;一個(gè)等待計(jì)算完成,并顯示計(jì)算結(jié)果。
public class SharedObj {
public int calcResult = -1; // 表示計(jì)算結(jié)果, -1表示還沒計(jì)算呢
public volatile boolean jobDone = false; // 表示計(jì)算任務(wù)是否已經(jīng)做完
}
public CalcJob extends Thread {
private SharedObj sharedObj;
public CalJob(SharedObj so) {
sharedObj = so;
}
public void run() {
// 耗時(shí)很長(zhǎng)的計(jì)算任務(wù)
so.calcResult = xxxx; // 設(shè)置計(jì)算結(jié)果
so.jobDone = true; // 表示任務(wù)已經(jīng)完成了
}
}
public ShowJob extends Thread {
private SharedObj sharedObj;
public ShowJob(SharedObj so) {
sharedObj = so;
}
public void run() {
while(!so.jobDone) {
// 如果job沒完,則等待。注意實(shí)際中盡量避免使用sleep。這里僅僅為示例。
Thread.sleep(10000);
};
System.out.println(so.calcResult);
}
}
// ... 拼接代碼
SharedObj so = new SharedObj;
new CalcJob(so).start();
new ShowJob(so).start();
看起來還不錯(cuò),但是在JDK1.5之前,這段代碼有可能打不出正確的結(jié)果。這是為什么?這是出于兩個(gè)原因:
-
volatile只保證聲明為volatile關(guān)鍵字的變量,但是不會(huì)管其他變量。你可以留意到caclResult并沒被聲明為volatile。所以它對(duì)于其他線程不保證可見。 -
volatile不保證代碼順序。
在JDK1.5之前CaclJob的代碼
public void run() {
// 耗時(shí)很長(zhǎng)的計(jì)算任務(wù)
so.calcResult = xxxx; // 設(shè)置計(jì)算結(jié)果
so.jobDone = true; // 表示任務(wù)已經(jīng)完成了
}
在編譯時(shí)可能會(huì)把so.jobDone = true這條指令編譯到了設(shè)置計(jì)算結(jié)果之前!而ShowJob的代碼也會(huì)出現(xiàn)類似的問題——代碼可能會(huì)被編譯成是“先輸出結(jié)果,再進(jìn)入循環(huán)等待”。編譯器之所以這樣做是為了提高程序性能——編譯器應(yīng)該在保證結(jié)果正確的情況下找到執(zhí)行效率最高的代碼執(zhí)行順序。在單線程時(shí),這種優(yōu)化很好,calcResult和jobDone哪個(gè)被先設(shè)置都不影響最終結(jié)果。因?yàn)橹挥挟?dāng)方法CalcJob#run結(jié)束了,才輪得到其他代碼執(zhí)行;而在多線程執(zhí)行時(shí),這個(gè)前提明顯不成立,因此可能會(huì)引起災(zāi)難性后果。
這么一個(gè)半殘的volatile顯然對(duì)于實(shí)際開發(fā)沒有什么用處。為此,JDK1.5做了一個(gè)巨大的改進(jìn)。
JDK1.5之后的volatile
JDK1.5實(shí)現(xiàn)了一個(gè)規(guī)范"JSR133"——Java Memory Model and Thread Specification Revision。JSR133內(nèi)容很多,其中一個(gè)要點(diǎn)是引入了一個(gè)名詞"Happens-Before"。這實(shí)際上是兩條規(guī)則:
- 第一點(diǎn),編譯器可以生成與原始代碼順序不同的代碼,但是亂序不可以跨越對(duì)聲明為
voltaile的變量讀寫。即在volatile變量訪問前的代碼不可以亂序到訪問后;訪問后的代碼不可以亂序到訪問前。

這樣一來,上一節(jié)提到的兩個(gè)線程交互的例子成為可能。
- 第二點(diǎn),如果線程1寫入一個(gè)聲明為
volatile共享變量,線程2讀取這個(gè)共享變量。那么在線程1寫入共享變量之前,所有對(duì)線程1可見的變量,在線程2讀取共享變量之后的代碼均可見。
這個(gè)規(guī)則讀起來比較繞,但關(guān)鍵點(diǎn)在于,volatile不再是只影響1個(gè)變量,而是會(huì)影響多個(gè)變量的可見性。假設(shè)SharedObj有a,b,c,d4個(gè)普通變量,和一個(gè)聲明為volatile的x。
ThreadA:
so.a = 1, so.b = 2, so.c = 3, so.d = 4;
so.x = 10;
ThreadB:
int x = so.x;
System.out.println(String.format("%d %d %d %d", so.a, so.b, so.c, so.d));
對(duì)于上面的代碼,假如實(shí)際執(zhí)行的順序是ThreadA先執(zhí)行完,ThreadB再執(zhí)行的,那么最終一定會(huì)輸出1,2,3,4。ThreadA設(shè)置了so.x=10后,a,b,c,d還不一定對(duì)外可見。ThreadB在尚未執(zhí)行int x = so.x時(shí),不一定能看見a,b,c,d;但是一旦ThreadB執(zhí)行了int x = so.x, "Happens-Before"保證,后續(xù)代碼一定能看到a,b,c,d最新被修改的值。
你可能想知道為什么這個(gè)保證能夠得到實(shí)現(xiàn)。似乎一個(gè)關(guān)鍵字在滿足了一個(gè)復(fù)雜的條件下,達(dá)成一個(gè)很反人類常識(shí)的結(jié)果。這超出了本文的范圍,也許可以找個(gè)時(shí)間專門探究一下。不過目前相信你能夠理解,通過volatile關(guān)鍵字可以保證JDK1.5之前會(huì)出錯(cuò)的代碼可以正確執(zhí)行。
volatile足夠了嗎?
答案明顯是否定的。即便提供了如此復(fù)雜的Happens-Before保證,使得volatile更好地解決了變量的可見性。但是可見性問題并不普遍。業(yè)務(wù)中更加普遍的問題是競(jìng)爭(zhēng)條件。一個(gè)簡(jiǎn)單的例子就是計(jì)數(shù)器——不同線程對(duì)同一個(gè)變量執(zhí)行“讀取、+1、寫入”操作。

圖中兩個(gè)線程都看到了變量值為5,都嘗試對(duì)其加1,然后都嘗試寫入主存。這時(shí)主存里的值就變成了6,而非期望的7。這個(gè)6對(duì)兩個(gè)線程都可見。于是乎下次再更新,又會(huì)重復(fù)上面的過程。計(jì)數(shù)的值會(huì)一直錯(cuò)下去。
對(duì)于存在競(jìng)爭(zhēng)條件的場(chǎng)景,唯一的辦法是使用鎖來同步。volatile此時(shí)完全幫不上忙。
volatile VS 鎖
很多同學(xué)糾結(jié)于,volatile的實(shí)現(xiàn)比鎖要快。所以能用volatile不用鎖的地方就盡量不用鎖。這樣說對(duì)也不對(duì)。
的確,volatile的實(shí)現(xiàn)是基于緩存一致性協(xié)議,說白了就是緩存和內(nèi)存數(shù)據(jù)的同步和失效控制。這套機(jī)制不影響多核并發(fā)工作——這個(gè)核在刷緩存時(shí),別的核該干啥干啥。而鎖的底層實(shí)現(xiàn)需要鎖總線,即總線被一個(gè)CPU核獨(dú)占,其他CPU核都停止與總線的交互。這樣的性能的損失的確是比較大的。但是,Java的鎖一般都會(huì)實(shí)現(xiàn)成“先嘗試用CAS+volatile機(jī)制嘗試樂觀鎖,實(shí)在搶不到鎖再上LOCK大殺器”的方式。所以在競(jìng)爭(zhēng)不是很激烈時(shí),鎖的運(yùn)行效率并不是很差。一般業(yè)務(wù)上的競(jìng)爭(zhēng)都不會(huì)特別激烈,否則就要重新設(shè)計(jì)業(yè)務(wù)使其獨(dú)立性更好。
但在實(shí)現(xiàn)高性能同步機(jī)制時(shí),volatile是必要的,而且往往是關(guān)鍵所在。但這要伴隨著精巧的設(shè)計(jì)(比如,如何將復(fù)雜問題拆解為一個(gè)個(gè)可見性問題?),和嚴(yán)格的壓力測(cè)試。有興趣的同學(xué)可以參考LMAX Distruptor的設(shè)計(jì)實(shí)現(xiàn)。
在業(yè)務(wù)上鎖有一個(gè)好處是,volatile能實(shí)現(xiàn)的功能,鎖都可以實(shí)現(xiàn)。鎖同步后數(shù)據(jù)訪問一定是同步的,總是能得到變量的最新值。這樣在業(yè)務(wù)上的“容忍度”就更好——業(yè)務(wù)變得更復(fù)雜時(shí),鎖還是那個(gè)鎖,沒有變化。基于鎖實(shí)現(xiàn)的各種同步工具(如BlockingQueue、Semphore等)也能更容易的融入業(yè)務(wù)設(shè)計(jì)。而用使用volatile就有相關(guān)代碼徹底重寫的可能性。
總之,在不能用證據(jù)判定volatile成為系統(tǒng)瓶頸之前,盡量不要使用它。
結(jié)論
現(xiàn)在你也許可以明白volatile之所以難用,是因?yàn)樗膽?yīng)用場(chǎng)景非常窄——僅用于多線程之間的變量可見性同步。為了使用它,開發(fā)者必須識(shí)別出要解決的問題剛好是一個(gè)可見性的問題。我的從業(yè)生涯中,遇到的問題的比例大概如下圖所示。如果沒有做過系統(tǒng)及開發(fā),大概率后兩種問題都不會(huì)遇到。

業(yè)務(wù)需求往往變化很快。如果一個(gè)業(yè)務(wù)場(chǎng)景,一開始是一個(gè)可見性問題,使用volatile實(shí)現(xiàn)了一套代碼;但后邊業(yè)務(wù)需求變化升級(jí)為一個(gè)競(jìng)爭(zhēng)條件問題。那么之前所有的開發(fā)全部要廢棄,改用鎖同步重新實(shí)現(xiàn)。這樣看起來得不償失。
此外,業(yè)務(wù)開發(fā)工程師往往會(huì)將大部分注意力放在業(yè)務(wù)模型和業(yè)務(wù)細(xì)節(jié)處理上,很容易忽略volatile的局限性。并發(fā)的代碼也很難測(cè)試,數(shù)據(jù)量少的時(shí)候測(cè)試環(huán)境里的bug也許只是偶發(fā)出現(xiàn),不能必然復(fù)現(xiàn)。追查起來也更加耗費(fèi)精力。但是到了生產(chǎn)環(huán)境,數(shù)據(jù)量大了之后,就會(huì)頻頻出現(xiàn)問題,令用戶抱怨。
因此,我的建議是:
- 如果你是個(gè)業(yè)務(wù)開發(fā)工程師,每天處理接口、業(yè)務(wù)邏輯、用戶請(qǐng)求等,請(qǐng)忘記
volatile。如果你真的需要線程間需要傳遞一些信息,盡量用更高級(jí)的同步工具,單機(jī)的如BlockingQueue,Semphore、CountDownLatch;分布式的如Message Queue、數(shù)據(jù)庫(kù)、redis等。 - 如果你需要解決一個(gè)volatile很合適的場(chǎng)景,請(qǐng)反復(fù)調(diào)查一下還有沒有其他更成熟更直接的高層工具可以使用。比如如果你的任務(wù)真的是一個(gè)管理線程A,需要根據(jù)某些指令停掉工作線程B、C、D,請(qǐng)優(yōu)先考慮
InteruptException而不是volatile。 - 如果你是個(gè)系統(tǒng)工程師,在編寫隊(duì)列中間件、RPC等基礎(chǔ)設(shè)施。請(qǐng)?jiān)陂_工之前反復(fù)閱讀JSR133,并保證自己深刻理解了各種概念;鑒于
volatile比較底層,盡量使用其封裝一些工具(比如一個(gè)根據(jù)業(yè)務(wù)定制設(shè)計(jì)的鎖),而非直接將其使用于數(shù)據(jù)同步。
嗯,在白話了這么久之后的結(jié)論就是,對(duì)volatile要慎用慎用再慎用。