1. 程式人生 > >java的volatile關鍵字詳解

java的volatile關鍵字詳解

 

記憶體可見性

  volatile是Java提供的一種輕量級的同步機制,在併發程式設計中,它也扮演著比較重要的角色。同synchronized相比(synchronized通常稱為重量級鎖),volatile更輕量級,相比使用synchronized所帶來的龐大開銷,倘若能恰當的合理的使用volatile,自然是美事一樁。

  為了能比較清晰徹底的理解volatile,我們一步一步來分析。首先來看看如下程式碼

複製程式碼

public class TestVolatile {
    boolean status = false;

    /**
     * 狀態切換為true
     */
    public void changeStatus(){
        status = true;
    }

    /**
     * 若狀態為true,則running。
     */
    public void run(){
        if(status){
            System.out.println("running....");
        }
    }
}

複製程式碼

  上面這個例子,在多執行緒環境裡,假設執行緒A執行changeStatus()方法後,執行緒B執行run()方法,可以保證輸出"running....."嗎?

  答案是NO! 

  這個結論會讓人有些疑惑,可以理解。因為倘若在單執行緒模型裡,先執行changeStatus方法,再執行run方法,自然是可以正確輸出"running...."的;但是在多執行緒模型中,是沒法做這種保證的。因為對於共享變數status來說,執行緒A的修改,對於執行緒B來講,是"不可見"的。也就是說,執行緒B此時可能無法觀測到status已被修改為true。那麼什麼是可見性呢?

  所謂可見性,是指當一條執行緒修改了共享變數的值,新值對於其他執行緒來說是可以立即得知的。很顯然,上述的例子中是沒有辦法做到記憶體可見性的。

  Java記憶體模型

  為什麼出現這種情況呢,我們需要先了解一下JMM(java記憶體模型)

  java虛擬機器有自己的記憶體模型(Java Memory Model,JMM),JMM可以遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓java程式在各種平臺下都能達到一致的記憶體訪問效果。

  JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見,JMM定義了執行緒和主記憶體之間的抽象關係:共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體儲存了被該執行緒使用到的主記憶體的副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。這三者之間的互動關係如下

 

  需要注意的是,JMM是個抽象的記憶體模型,所以所謂的本地記憶體,主記憶體都是抽象概念,並不一定就真實的對應cpu快取和實體記憶體。當然如果是出於理解的目的,這樣對應起來也無不可。

  大概瞭解了JMM的簡單定義後,問題就很容易理解了,對於普通的共享變數來講,比如我們上文中的status,執行緒A將其修改為true這個動作發生線上程A的本地記憶體中,此時還未同步到主記憶體中去;而執行緒B快取了status的初始值false,此時可能沒有觀測到status的值被修改了,所以就導致了上述的問題。那麼這種共享變數在多執行緒模型中的不可見性如何解決呢?比較粗暴的方式自然就是加鎖,但是此處使用synchronized或者Lock這些方式太重量級了,有點炮打蚊子的意思。比較合理的方式其實就是volatile

  volatile具備兩種特性,第一就是保證共享變數對所有執行緒的可見性。將一個共享變數宣告為volatile後,會有以下效應:

    1.當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的變數強制重新整理到主記憶體中去;

    2.這個寫會操作會導致其他執行緒中的快取無效。

上面的例子只需將status宣告為volatile,即可保證線上程A將其修改為true時,執行緒B可以立刻得知

 volatile boolean status = false;

留意複合類操作

  但是需要注意的是,我們一直在拿volatile和synchronized做對比,僅僅是因為這兩個關鍵字在某些記憶體語義上有共通之處,volatile並不能完全替代synchronized,它依然是個輕量級鎖,在很多場景下,volatile並不能勝任。看下這個例子:

複製程式碼

package test;

import java.util.concurrent.CountDownLatch;

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
    public static volatile int num = 0;
    //使用CountDownLatch來等待計算執行緒執行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //開啟30個執行緒進行累加操作
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num++;//自加操作
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待計算執行緒執行完
        countDownLatch.await();
        System.out.println(num);
    }
}

複製程式碼

執行結果:

224291

針對這個示例,一些同學可能會覺得疑惑,如果用volatile修飾的共享變數可以保證可見性,那麼結果不應該是300000麼?

問題就出在num++這個操作上,因為num++不是個原子性的操作,而是個複合操作。我們可以簡單講這個操作理解為由這三步組成:

  1.讀取

  2.加一

  3.賦值

  所以,在多執行緒環境下,有可能執行緒A將num讀取到本地記憶體中,此時其他執行緒可能已經將num增大了很多,執行緒A依然對過期的num進行自加,重新寫到主存中,最終導致了num的結果不合預期,而是小於30000。

解決num++操作的原子性問題

  針對num++這類複合類的操作,可以使用java併發包中的原子操作類原子操作類是通過迴圈CAS的方式來保證其原子性的。

複製程式碼

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
  //使用原子操作類
    public static AtomicInteger num = new AtomicInteger(0);
    //使用CountDownLatch來等待計算執行緒執行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //開啟30個執行緒進行累加操作
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num.incrementAndGet();//原子性的num++,通過迴圈CAS方式
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待計算執行緒執行完
        countDownLatch.await();
        System.out.println(num);
    }
}

複製程式碼

執行結果

300000

關於原子類操作的基本原理,會在後面的章節進行介紹,此處不再贅述。

禁止指令重排序

volatile還有一個特性:禁止指令重排序優化。

重排序是指編譯器和處理器為了優化程式效能而對指令序列進行排序的一種手段。但是重排序也需要遵守一定規則:

  1.重排序操作不會對存在資料依賴關係的操作進行重排序。

    比如:a=1;b=a; 這個指令序列,由於第二個操作依賴於第一個操作,所以在編譯時和處理器執行時這兩個操作不會被重排序。

  2.重排序是為了優化效能,但是不管怎麼重排序,單執行緒下程式的執行結果不能被改變

    比如:a=1;b=2;c=a+b這三個操作,第一步(a=1)和第二步(b=2)由於不存在資料依賴關係,所以可能會發生重排序,但是c=a+b這個操作是不會被重排序的,因為需要保證最終的結果一定是c=a+b=3。

  重排序在單執行緒模式下是一定會保證最終結果的正確性,但是在多執行緒環境下,問題就出來了,來開個例子,我們對第一個TestVolatile的例子稍稍改進,再增加個共享變數a

複製程式碼

public class TestVolatile {
    int a = 1;
    boolean status = false;

    /**
     * 狀態切換為true
     */
    public void changeStatus(){
        a = 2;//1
        status = true;//2
    }

    /**
     * 若狀態為true,則running。
     */
    public void run(){
        if(status){//3
            int b = a+1;//4
            System.out.println(b);
        }
    }
}

複製程式碼

  假設執行緒A執行changeStatus後,執行緒B執行run,我們能保證在4處,b一定等於3麼?

  答案依然是無法保證!也有可能b仍然為2。上面我們提到過,為了提供程式並行度,編譯器和處理器可能會對指令進行重排序,而上例中的1和2由於不存在資料依賴關係,則有可能會被重排序,先執行status=true再執行a=2。而此時執行緒B會順利到達4處,而執行緒A中a=2這個操作還未被執行,所以b=a+1的結果也有可能依然等於2。

  使用volatile關鍵字修飾共享變數便可以禁止這種重排序。若用volatile修飾共享變數,在編譯時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序

  volatile禁止指令重排序也有一些規則,簡單列舉一下:

  1.當第二個操作是voaltile寫時,無論第一個操作是什麼,都不能進行重排序

  2.當地一個操作是volatile讀時,不管第二個操作是什麼,都不能進行重排序

  3.當第一個操作是volatile寫時,第二個操作是volatile讀時,不能進行重排序

總結:

  簡單總結下,volatile是一種輕量級的同步機制,它主要有兩個特性:一是保證共享變數對所有執行緒的可見性;二是禁止指令重排序優化。同時需要注意的是,volatile對於單個的共享變數的讀/寫具有原子性,但是像num++這種複合操作,volatile無法保證其原子性,當然文中也提出瞭解決方案,就是使用併發包中的原子操作類,通過迴圈CAS地方式來保證num++操作的原子性。關於原子操作類,會在後續的文章進行介紹。