volatile是個很老的關鍵字,幾乎伴隨着JDK的誕生而誕生,咱們都知道這個關鍵字,但又不太清楚何時會使用它;咱們在JDK及開源框架中隨處可見這個關鍵字,但併發專家又每每建議咱們遠離它。好比Thread這個很基礎的類,其中很重要的線程狀態字段,就是用volatile來修飾,見代碼 html
/* Java thread status for tools,
* initialized to indicate thread 'not yet started'
*/
private volatile int threadStatus = 0;
如上面所說,併發專家建議咱們遠離它,尤爲是在JDK6的synchronized關鍵字的性能被大幅優化以後,更是幾乎沒有使用它的場景,但這仍然是個值得研究的關鍵字,研究它的意義不在於去使用它,而在於理解它對理解Java的整個多線程的機制是頗有幫助的。 java
先來體會一下volatile的做用,從下面代碼開始 linux
1: public class VolatileExample extends Thread{
2: //設置類靜態變量,各線程訪問這同一共享變量
3: private static boolean flag = false;
4:
5: //無限循環,等待flag變爲true時才跳出循環
6: public void run() {while (!flag){};}
7:
8: public static void main(String[] args) throws Exception {
9: new VolatileExample().start();
10: //sleep的目的是等待線程啓動完畢,也就是說進入run的無限循環體了
11: Thread.sleep(100);
12: flag = true;
13: }
14: }
這個例子很好理解,main函數裏啓動一個線程,其run方法是一個以flag爲標誌位的無限循環。若是flag爲true則跳出循環。當main執行到12行的時候,flag被置爲true,按邏輯分析此時線程該結束,即整個程序執行完畢。 編程
執行一下看看是什麼結果?結果是使人驚訝的,程序始終也不會結束。main是確定結束了的,其緣由就是線程的run方法未結束,即run方法中的flag仍然爲false。 緩存
把第3行加上volatile修飾符,即 多線程
private static volatile boolean flag = false;
再執行一遍看看?結果是程序正常退出,volatile生效了。 併發
咱們再修改一下。去掉volatile關鍵字,恢復到起始的例子,而後把while(!flag){}改成while(!flag){System.out.println(1);},再執行一下看看。按分析,沒有volatile關鍵字的時候,程序不會執行結束,雖然加上了打印語句,但沒有作任何的關鍵字/邏輯的修改,應該程序也不會結束纔對,但執行結果倒是:程序正常結束。 oracle
有了這些感性認識,咱們再來分析volatile的語義以及它的做用。 app
volatile的第一條語義是保證線程間變量的可見性,簡單地說就是當線程A對變量X進行了修改後,在線程A後面執行的其餘線程能看到變量X的變更,更詳細地說是要符合如下兩個規則: 框架
要詳細地解釋這個問題,就不得不提一下Java的內存模型(Java Memory Model,簡稱JMM)。Java的內存模型是一個比較複雜的話題,屬於Java語言規範的範疇,我的水平有限,不能在有限篇幅裏完整地講述清楚這個事,若是要清晰地認識,請學習《深刻理解Java虛擬機-JVM高級特性與最佳實踐》和《The Java Language Specification, Java SE 7 Edition》,這裏簡單地引用一些資料略加解釋。
Java爲了保證其平臺性,使Java應用程序與操做系統內存模型隔離開,須要定義本身的內存模型。在Java內存模型中,內存分爲主內存和工做內存兩個部分,其中主內存是全部線程所共享的,而工做內存則是每一個線程分配一份,各線程的工做內存間彼此獨立、互不可見,在線程啓動的時候,虛擬機爲每一個內存分配一塊工做內存,不只包含了線程內部定義的局部變量,也包含了線程所須要使用的共享變量(非線程內構造的對象)的副本,即爲了提升執行效率,讀取副本比直接讀取主內存更快(這裏能夠簡單地將主內存理解爲虛擬機中的堆,而工做內存理解爲棧(或稱爲虛擬機棧),棧是連續的小空間、順序入棧出棧,而堆是不連續的大空間,因此在棧中尋址的速度比堆要快不少)。工做內存與主內存之間的數據交換經過主內存來進行,以下圖:
同時,Java內存模型還定義了一系列工做內存和主內存之間交互的操做及操做之間的順序的規則(這規則比較多也比較複雜,參見《深刻理解Java虛擬機-JVM高級特性與最佳實踐》第12章12.3.2部分),這裏只談和volatile有關的部分。對於共享普通變量來講,約定了變量在工做內存中發生變化了以後,必需要回寫到工做內存(早晚要回寫但並不是立刻回寫),但對於volatile變量則要求工做內存中發生變化以後,必須立刻回寫到工做內存,而線程讀取volatile變量的時候,必須立刻到工做內存中去取最新值而不是讀取本地工做內存的副本,此規則保證了前面所說的「當線程A對變量X進行了修改後,在線程A後面執行的其餘線程能看到變量X的變更」。
大部分網上的文章對於volatile的解釋都是到此爲止,但我以爲仍是有遺漏的,提出來探討。工做內存能夠說是主內存的一份緩存,爲了不緩存的不一致性,因此volatile須要廢棄此緩存。但除了內存緩存以外,在CPU硬件級別也是有緩存的,即寄存器。假如線程A將變量X由0修改成1的時候,CPU是在其緩存內操做,沒有及時回寫到內存,那麼JVM是沒法X=1是能及時被以後執行的線程B看到的,因此我以爲JVM在處理volatile變量的時候,也一樣用了硬件級別的緩存一致性原則(CPU的緩存一致性原則參見《Java的多線程機制系列:(二)緩存一致性和CAS》。
volatile的第二條語義:禁止指令重排序。關於指令重排序請參見後面的「指令重排序」章節。這是volatile目前主要的一個使用場景。
介紹volatile不能保證原子性的文章比較多,這裏就不舉詳細例子了,你們能夠去網上查閱相關資料。在多線程併發執行i++的操做結果來講,i加與不加volatile都是同樣的,只要線程數足夠,必定會出現不一致。這裏就其爲何不能保證原子性的原理說一下。
上面提到volatile的兩條語義保證了線程間共享變量的及時可見性,但整個過程並無保證同步(參見《Java的多線程機制系列:(一)總述及基礎概念》中對「鎖」的兩種特性的描述),這是與volatile的使命有關的,創造它的背景就是在某些狀況下能夠代替synchronized實現可見性的目的,規避synchronized帶來的線程掛起、調度的開銷。若是volatile也能保證同步,那麼它就是個鎖,能夠徹底取代synchronized了。從這點看,volatile不可能保證同步,也正基於上面的緣由,隨着synchronized性能逐漸提升,volatile逐漸退出歷史舞臺。
爲何volatile不能保證原子性?以i++爲例,其包括讀取、操做、賦值三個操做,下面是兩個線程的操做順序
假如說線程A在作了i+1,但未賦值的時候,線程B就開始讀取i,那麼當線程A賦值i=1,並回寫到主內存,而此時線程B已經再也不須要i的值了,而是直接交給處理器去作+1的操做,因而當線程B執行完並回寫到主內存,i的值仍然是1,而不是預期的2。也就是說,volatile縮短了普通變量在不一樣線程之間執行的時間差,但仍然存有漏洞,依然不能保證原子性。
這裏必需要提的是,在本章開頭所說的「各線程的工做內存間彼此獨立、互不可見,在線程啓動的時候,虛擬機爲每一個內存分配一塊工做內存,不只包含了線程內部定義的局部變量,也包含了線程所須要使用的共享變量(非線程內構造的對象)的副本,即爲了提升執行效率」並不許確。現在的volatile的例子已是很難重現,如本文開頭時只有在while死循環時才體現出volatile的做用,哪怕只是加了System.out.println(1)這麼一小段,普通變量也能達到volatile的效果,這是什麼緣由呢?原來只有在對變量讀取頻率很高的狀況下,虛擬機纔不會及時回寫主內存,而當頻率沒有達到虛擬機認爲的高頻率時,普通變量和volatile是一樣的處理邏輯。如在每一個循環中執行System.out.println(1)加大了讀取變量的時間間隔,使虛擬機認爲讀取頻率並不那麼高,因此實現了和volatile的效果(本文開頭的例子只在HotSpot24上測試過,沒有在JRockit之類其他版本JDK上測過)。volatile的效果在jdk1.2及以前很容易重現,但隨着虛擬機的不斷優化,現在的普通變量的可見性已經不是那麼嚴重的問題了,這也是volatile現在確實不太有使用場景的緣由吧。
併發專家建議咱們遠離volatile是有道理的,這裏再總結一下:
因此若是咱們肯定能正確使用volatile,那麼在禁止重排序時是一個較好的使用場景,不然咱們不須要再使用它。這裏只列舉出一種volatile的使用場景,即做爲標識位的時候(好比本文例子中boolean類型的flag)。用專業點更普遍的說法就是「對變量的寫操做不依賴於當前值且該變量沒有包含在其餘具體變量的不變式中」,具體參見《Java 理論與實踐: 正確使用 Volatile 變量》。
指令重排序是個比較複雜、以爲有些難以想象的問題,一樣是先以例子開頭(建議你們跑下例子,這是實實在在能夠重現的,重排序的機率仍是挺高的),有個感性的認識
/**
* 一個簡單的展現Happen-Before的例子.
* 這裏有兩個共享變量:a和flag,初始值分別爲0和false.在ThreadA中先給a=1,而後flag=true.
* 若是按照有序的話,那麼在ThreadB中若是if(flag)成功的話,則應該a=1,而a=a*1以後a仍然爲1,下方的if(a==0)應該永遠不會爲真,永遠不會打印.
* 但實際狀況是:在試驗100次的狀況下會出現0次或幾回的打印結果,而試驗1000次結果更明顯,有十幾回打印.
*/
public class SimpleHappenBefore {
/** 這是一個驗證結果的變量 */
private static int a=0;
/** 這是一個標誌位 */
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
//因爲多線程狀況下未必會試出重排序的結論,因此多試一些次
for(int i=0;i<1000;i++){
ThreadA threadA=new ThreadA();
ThreadB threadB=new ThreadB();
threadA.start();
threadB.start();
//這裏等待線程結束後,重置共享變量,以使驗證結果的工做變得簡單些.
threadA.join();
threadB.join();
a=0;
flag=false;
}
}
static class ThreadA extends Thread{
public void run(){
a=1;
flag=true;
}
}
static class ThreadB extends Thread{
public void run(){
if(flag){
a=a*1;
}
if(a==0){
System.out.println("ha,a==0");
}
}
}
}
例子比較簡單,也添加了註釋,再也不詳細敘述。
什麼是指令重排序?有兩個層面:
重排序很很差理解,上面只是簡單地提了下其場景,要想較好地理解這個概念,須要構造一些例子和圖表,在這裏介紹兩篇介紹比較詳細、生動的文章《happens-before俗解》和《深刻理解Java內存模型(二)——重排序》。其中的「as-if-serial」是應該掌握的,即:無論怎麼重排序,單線程程序的執行結果不能被改變。編譯器、運行時和處理器都必須遵照「as-if-serial」語義。拿個簡單例子來講,
public void execute(){
int a=0;
int b=1;
int c=a+b;
}
這裏a=0,b=1兩句能夠隨便排序,不影響程序邏輯結果,但c=a+b這句必須在前兩句的後面執行。
從前面那個例子能夠看到,重排序在多線程環境下出現的機率仍是挺高的,在關鍵字上有volatile和synchronized能夠禁用重排序,除此以外還有一些規則,也正是這些規則,使得咱們在平時的編程工做中沒有感覺到重排序的壞處。
正是以上這些規則保障了happen-before的順序,若是不符合以上規則,那麼在多線程環境下就不能保證執行順序等同於代碼順序,也就是「若是在本線程中觀察,全部的操做都是有序的;若是在一個線程中觀察另一個線程,則不符合以上規則的都是無序的」,所以,若是咱們的多線程程序依賴於代碼書寫順序,那麼就要考慮是否符合以上規則,若是不符合就要經過一些機制使其符合,最經常使用的就是synchronized、Lock以及volatile修飾符。