天天看點

volatile關鍵字的作用1 保證記憶體可見性2 禁止指令重排序3 不保證原子性

1 保證記憶體可見性

說到記憶體可見性就必須要提到Java的記憶體模型,如下圖所示:

volatile關鍵字的作用1 保證記憶體可見性2 禁止指令重排序3 不保證原子性

如上圖所示,所有線程的共享變量都存儲在主記憶體中,每一個線程都有一個獨有的工作記憶體,每個線程不直接操作在主記憶體中的變量,而是将主記憶體上變量的副本放進自己的工作記憶體中,隻操作工作記憶體中的資料。當修改完畢後,再把修改後的結果放回到主記憶體中。每個線程都隻操作自己工作記憶體中的變量,無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞需要通過主記憶體來完成。

上述的Java記憶體模型在單線程的環境下不會出現問題,但在多線程的環境下可能會出現髒資料,例如:如果有AB兩個線程同時拿到變量i,進行遞增操作。A線程将變量i放到自己的工作記憶體中,然後做+1操作,然而此時,線程A還沒有将修改後的值刷回到主記憶體中,而此時線程B也從主記憶體中拿到修改前的變量i,也進行了一遍+1的操作。最後A和B線程将各自的結果分别刷回到主記憶體中,看到的結果就是變量i隻進行了一遍+1的操作,而實際上A和B進行了兩次累加的操作,于是就出現了錯誤。究其原因,是因為線程B讀取到了變量i的髒資料的緣故。

此時如果對變量i加上volatile關鍵字修飾的話,它可以保證當A線程對變量i值做了變動之後,會立即刷回到主記憶體中,而其它線程讀取到該變量的值也廢棄,強迫重新從主記憶體中讀取該變量的值,這樣在任何時刻,AB線程總是會看到變量i的同一個值。

1.1 MESI緩存一緻性協定

volatile可見性是通過彙編加上Lock字首指令,觸發底層的MESI緩存一緻性協定來實作的。當然這個協定有很多種,不過最常用的就是MESI。MESI表示四種狀态,如下所示:

狀态 描述
M 修改(Modified) 此時緩存行中的資料與主記憶體中的資料不一緻,資料隻存在于本工作記憶體中。其他線程從主記憶體中讀取共享變量值的操作會被延遲執行,直到該緩存行将資料寫回到主記憶體後
E 獨享(Exclusive) 此時緩存行中的資料與主記憶體中的資料一緻,資料隻存在于本工作記憶體中。此時會監聽其他線程讀主記憶體中共享變量的操作,如果發生,該緩存行需要變成共享狀态
S 共享(Shared) 此時緩存行中的資料與主記憶體中的資料一緻,資料存在于很多工作記憶體中。此時會監聽其他線程使該緩存行無效的請求,如果發生,該緩存行需要變成無效狀态
I 無效(Invalid) 此時該緩存行無效

假如說目前有一個cpu去主記憶體拿到一個變量x的值初始為1,放到自己的工作記憶體中。此時它的狀态就是獨享狀态E,然後此時另外一個cpu也拿到了這個x的值,放到自己的工作記憶體中。此時之前那個cpu會不斷地監聽記憶體總線,發現這個x有多個cpu在擷取,那麼這個時候這兩個cpu所獲得的x的值的狀态就都是共享狀态S。然後第一個cpu将自己工作記憶體中x的值帶入到自己的ALU計算單元去進行計算,傳回來x的值變為2,接着會告訴給記憶體總線,将此時自己的x的狀态置為修改狀态M。而另一個cpu此時也會去不斷的監聽記憶體總線,發現這個x已經有别的cpu将其置為了修改狀态,是以自己内部的x的狀态會被置為無效狀态I,等待第一個cpu将修改後的值刷回到主記憶體後,重新去擷取新的值。這個誰先改變x的值可能是同一時刻進行修改的,此時cpu就會通過底層硬體在同一個指令周期内進行裁決,裁決是誰進行修改的,就置為修改狀态,而另一個就置為無效狀态,被丢棄或者是被覆寫(有争論)。

當然,MESI也會有失效的時候,緩存的最小單元是緩存行,如果目前的共享資料的長度超過一個緩存行的長度的時候,就會使MESI協定失敗,此時的話就會觸發總線加鎖的機制,第一個線程cpu拿到這個x的時候,其他的線程都不允許去擷取這個x的值。

2 禁止指令重排序

指令的執行順序并不一定會像我們編寫的順序那樣執行,為了保證執行上的效率,JVM(包括CPU)可能會對指令進行重排序。比方說下面的代碼:

int i = 1;
int j = 2;
           

上述的兩條指派語句在同一個線程之中,根據程式上的次序,“int i = 1;”的操作要先行發生于“int j = 2;”,但是“int j = 2;”的代碼完全可能會被處理器先執行。JVM會保證在單線程的情況下,重排序後的執行結果會和重排序之前的結果一緻。但是在多線程的場景下就不一定了。最典型的例子就是雙重檢查加鎖版的單例實作,代碼如下所示:

public class Singleton {

    private volatile static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
           

由上可以看到,instance變量被volatile關鍵字所修飾,但是如果去掉該關鍵字,就不能保證該代碼執行的正确性。這是因為“instance = new Singleton();”這行代碼并不是原子操作,其在JVM中被分為如下三個階段執行:

  1. 為instance配置設定記憶體
  2. 初始化instance
  3. 将instance變量指向配置設定的記憶體空間

由于JVM可能存在重排序,上述的二三步驟沒有依賴的關系,可能會出現先執行第三步,後執行第二步的情況。也就是說可能會出現instance變量還沒初始化完成,其他線程就已經判斷了該變量值不為null,結果傳回了一個沒有初始化完成的半成品的情況。而加上volatile關鍵字修飾後,可以保證instance變量的操作不會被JVM所重排序,每個線程都是按照上述一二三的步驟順序的執行,這樣就不會出現問題。

2.1 記憶體屏障

volatile有序性是通過記憶體屏障實作的。JVM和CPU都會對指令做重排優化,是以在指令間插入一個屏障點,就告訴JVM和CPU,不能進行重排優化。具體的會分為讀讀、讀寫、寫讀、寫寫屏障這四種,同時它也會有一些插入屏障點的政策,下面是JMM基于保守政策的記憶體屏障點插入政策:

屏障點 描述
每個volatile寫的前面插入一個store-store屏障 禁止上面的普通寫和下面的volatile寫重排序
每個volatile寫的後面插入一個store-load屏障 禁止上面的volatile寫與下面的volatile讀/寫重排序
每個volatile讀的後面插入一個load-load屏障 禁止下面的普通讀和上面的volatile讀重排序
每個volatile讀的後面插入一個load-store屏障 禁止下面的普通寫和上面的volatile讀重排序

上面的插入政策非常保守,但是它可以保證在任意處理器平台上的正确性。在實際執行時,編譯器可以省略沒必要的屏障點,同時在某些處理器上會做進一步的優化。

3 不保證原子性

需要重點說明的一點是,盡管volatile關鍵字可以保證記憶體可見性和有序性,但不能保證原子性。也就是說,對volatile修飾的變量進行的操作,不保證多線程安全。請看以下的例子:

public class Test {

    private static CountDownLatch countDownLatch = new CountDownLatch(1000);
    private volatile static int   num            = 0;

    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 1000; i++) {
            executor.execute(() -> {
                try {
                    num++;
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executor.shutdown();
        System.out.println(num);
    }
}
           

靜态變量num被volatile所修飾,并且同時開啟1000個線程對其進行累加的操作,按道理來說,其結果應該為1000,但實際的情況是,每次運作結果可能都是一個小于1000的數字(也有結果為1000的時候,但出現幾率很小),并且不固定。那麼這是為什麼呢?原因是因為“num++;”這行代碼并不是原子操作,盡管它被volatile所修飾了也依然如此。++操作的執行過程如下面所示:

  1. 首先擷取變量i的值
  2. 将該變量的值+1
  3. 将該變量的值寫回到對應的主記憶體中

雖然每次擷取num值的時候,也就是執行上述第一步的時候,都拿到的是主記憶體的最新變量值,但是在進行第二步num+1的時候,可能其他線程在此期間已經對num做了修改,這時候就會觸發MESI協定的失效動作,将該線程内部的值廢棄。那麼該次+1的動作就會失效了,也就是少加了一次1。比如說:線程A在執行第一步的時候讀取到此時num的值為3,然後在執行第二步之前,其他多個線程已經對該值進行了修改,使得num值變為了4。而線程A此時的num值就會失效,重新從主記憶體中讀取最新值。也就是兩個線程做了兩次+1的動作,但實際的結果最後隻加了一次1。是以這也就是最後的執行結果為什麼大機率會是一個小于1000的值的原因。

是以如果要解決上面代碼的多線程安全問題,可以采取加鎖synchronized的方式,也可以使用JUC包下的原子類AtomicInteger,以下的代碼示範了使用AtomicInteger來包裝num變量的方式:

public class Test {

    private static CountDownLatch countDownLatch = new CountDownLatch(1000);
    private static AtomicInteger  num            = new AtomicInteger();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 1000; i++) {
            executor.execute(() -> {
                try {
                    num.getAndIncrement();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        executor.shutdown();
        System.out.println(num);
    }
}
           

多次運作上面的代碼,結果都為1000。

繼續閱讀