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

如上圖所示,所有線程的共享變量都存儲在主記憶體中,每一個線程都有一個獨有的工作記憶體,每個線程不直接操作在主記憶體中的變量,而是将主記憶體上變量的副本放進自己的工作記憶體中,隻操作工作記憶體中的資料。當修改完畢後,再把修改後的結果放回到主記憶體中。每個線程都隻操作自己工作記憶體中的變量,無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞需要通過主記憶體來完成。
上述的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中被分為如下三個階段執行:
- 為instance配置設定記憶體
- 初始化instance
- 将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所修飾了也依然如此。++操作的執行過程如下面所示:
- 首先擷取變量i的值
- 将該變量的值+1
- 将該變量的值寫回到對應的主記憶體中
雖然每次擷取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。