Java的多線程機制系列:(四)不得不提的volatile及指令重排序(happen-before)

轉自:http://www.cnblogs.com/mengheng/p/3495379.html


一、不得不提的volatile

volatile是個很老的關鍵字,幾乎伴隨着JDK的誕生而誕生,我們都知道這個關鍵字,但又不太清楚什麼時候會使用它;我們在JDK及開源框架中隨處可見這個關鍵字,但併發專家又往往建議我們遠離它。比如Thread這個很基礎的類,其中很重要的線程狀態字段,就是用volatile來修飾,見代碼

 /* Java thread status for tools,
     * initialized to indicate thread 'not yet started'
     */
 
    private volatile int threadStatus = 0;

如上面所說,併發專家建議我們遠離它,尤其是在JDK6的synchronized關鍵字的性能被大幅優化之後,更是幾乎沒有使用它的場景,但這仍然是個值得研究的關鍵字,研究它的意義不在於去使用它,而在於理解它對理解Java的整個多線程的機制是很有幫助的。

1. 例子

先來體會一下volatile的作用,從下面代碼開始

   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關鍵字的時候,程序不會執行結束,雖然加上了打印語句,但沒有做任何的關鍵字/邏輯的修改,應該程序也不會結束纔對,但執行結果卻是:程序正常結束。

有了這些感性認識,我們再來分析volatile的語義以及它的作用。

2.volatile語義

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內存模型中,內存分爲主內存和工作內存兩個部分,其中主內存是所有線程所共享的,而工作內存則是每個線程分配一份,各線程的工作內存間彼此獨立、互不可見,在線程啓動的時候,虛擬機爲每個內存分配一塊工作內存,不僅包含了線程內部定義的局部變量,也包含了線程所需要使用的共享變量(非線程內構造的對象)的副本,即爲了提高執行效率,讀取副本比直接讀取主內存更快(這裏可以簡單地將主內存理解爲虛擬機中的堆,而工作內存理解爲棧(或稱爲虛擬機棧),棧是連續的小空間、順序入棧出棧,而堆是不連續的大空間,所以在棧中尋址的速度比堆要快很多)。工作內存與主內存之間的數據交換通過主內存來進行,如下圖:QQ截圖20131228132842

同時,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目前主要的一個使用場景。

3. volatile不能保證原子性

介紹volatile不能保證原子性的文章比較多,這裏就不舉詳細例子了,大家可以去網上查閱相關資料。在多線程併發執行i++的操作結果來說,i加與不加volatile都是一樣的,只要線程數足夠,一定會出現不一致。這裏就其爲什麼不能保證原子性的原理說一下。

上面提到volatile的兩條語義保證了線程間共享變量的及時可見性,但整個過程並沒有保證同步(參見《Java的多線程機制系列:(一)總述及基礎概念》中對“鎖”的兩種特性的描述),這是與volatile的使命有關的,創造它的背景就是在某些情況下可以代替synchronized實現可見性的目的,規避synchronized帶來的線程掛起、調度的開銷。如果volatile也能保證同步,那麼它就是個鎖,可以完全取代synchronized了。從這點看,volatile不可能保證同步,也正基於上面的原因,隨着synchronized性能逐漸提高,volatile逐漸退出歷史舞臺。

爲什麼volatile不能保證原子性?以i++爲例,其包括讀取、操作、賦值三個操作,下面是兩個線程的操作順序2

假如說線程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如今確實不太有使用場景的原因吧。

4. volatile的適用場景

併發專家建議我們遠離volatile是有道理的,這裏再總結一下:

  • volatile是在synchronized性能低下的時候提出的。如今synchronized的效率已經大幅提升,所以volatile存在的意義不大。
  • 如今非volatile的共享變量,在訪問不是超級頻繁的情況下,已經和volatile修飾的變量有同樣的效果了。
  • volatile不能保證原子性,這點是大家沒太搞清楚的,所以很容易出錯。
  • volatile可以禁止重排序。

所以如果我們確定能正確使用volatile,那麼在禁止重排序時是一個較好的使用場景,否則我們不需要再使用它。這裏只列舉出一種volatile的使用場景,即作爲標識位的時候(比如本文例子中boolean類型的flag)。用專業點更廣泛的說法就是“對變量的寫操作不依賴於當前值且該變量沒有包含在其他具體變量的不變式中”,具體參見《Java 理論與實踐: 正確使用 Volatile 變量》。

 

二、指令重排序(happen-before)

指令重排序是個比較複雜、覺得有些不可思議的問題,同樣是先以例子開頭(建議大家跑下例子,這是實實在在可以重現的,重排序的概率還是挺高的),有個感性的認識

/**
 * 一個簡單的展示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");
            }
        }
    }
}
例子比較簡單,也添加了註釋,不再詳細敘述。
 
什麼是指令重排序?有兩個層面:
  • 在虛擬機層面,爲了儘可能減少內存操作速度遠慢於CPU運行速度所帶來的CPU空置的影響,虛擬機會按照自己的一些規則(這規則後面再敘述)將程序編寫順序打亂——即寫在後面的代碼在時間順序上可能會先執行,而寫在前面的代碼會後執行——以儘可能充分地利用CPU。拿上面的例子來說:假如不是a=1的操作,而是a=new byte[1024*1024](分配1M空間),那麼它會運行地很慢,此時CPU是等待其執行結束呢,還是先執行下面那句flag=true呢?顯然,先執行flag=true可以提前使用CPU,加快整體效率,當然這樣的前提是不會產生錯誤(什麼樣的錯誤後面再說)。雖然這裏有兩種情況:後面的代碼先於前面的代碼開始執行;前面的代碼先開始執行,但當效率較慢的時候,後面的代碼開始執行並先於前面的代碼執行結束。不管誰先開始,總之後面的代碼在一些情況下存在先結束的可能。
  • 在硬件層面,CPU會將接收到的一批指令按照其規則重排序,同樣是基於CPU速度比緩存速度快的原因,和上一點的目的類似,只是硬件處理的話,每次只能在接收到的有限指令範圍內重排序,而虛擬機可以在更大層面、更多指令範圍內重排序。硬件的重排序機制參見《從JVM併發看CPU內存指令重排序(Memory Reordering)

重排序很不好理解,上面只是簡單地提了下其場景,要想較好地理解這個概念,需要構造一些例子和圖表,在這裏介紹兩篇介紹比較詳細、生動的文章《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可以禁用重排序,除此之外還有一些規則,也正是這些規則,使得我們在平時的編程工作中沒有感受到重排序的壞處。

  • 程序次序規則(Program Order Rule):在一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說應該是控制流順序而不是代碼順序,因爲要考慮分支、循環等結構。
  • 監視器鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個對象鎖的lock操作。這裏強調的是同一個鎖,而“後面”指的是時間上的先後順序,如發生在其他線程中的lock操作。
  • volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作發生於後面對這個變量的讀操作,這裏的“後面”也指的是時間上的先後順序。
  • 線程啓動規則(Thread Start Rule):Thread獨享的start()方法先行於此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的每個操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值檢測到線程已經終止執行。
  • 線程中斷規則(Thread Interruption Rule):對線程interrupte()方法的調用優先於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否已中斷。
  • 對象終結原則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

正是以上這些規則保障了happen-before的順序,如果不符合以上規則,那麼在多線程環境下就不能保證執行順序等同於代碼順序,也就是“如果在本線程中觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,則不符合以上規則的都是無序的”,因此,如果我們的多線程程序依賴於代碼書寫順序,那麼就要考慮是否符合以上規則,如果不符合就要通過一些機制使其符合,最常用的就是synchronized、Lock以及volatile修飾符。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章