簡單來說,假設你寫了下面的程式:
int a = 1;
int b = 2;
System.out.println(a);
System.out.println(b);
但經過編譯器/CPU優化(指令重排序,和程式語言無關)後可能就變成了這樣:
int b = 2;
int a = 1;
System.out.println(a);
System.out.println(b);
當然上面例子這種情況,就算調整了程式碼順序,也沒有任何影響。但實際工作過程中,這種擅自優化
,並不總是沒有問題的,在多執行緒情況下,有時候就會給我們的程式中埋下一個隱藏的bug。
我說有指令重排序就有啊,那不得拿出證據來?
這個證明有點複雜,我先寫一個程式,跑起來看結果,然後再解釋:
public class DisOrder {
private static int a, b, x, y = 0;
public static void main(String[] args) throws InterruptedException {
for (long i = 0; i < Long.MAX_VALUE; i++) {
a = 0;b = 0;x = 0;y = 0;
CountDownLatch cdl = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
a = 1;
x = b;
cdl.countDown();
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
cdl.countDown();
});
t1.start();
t2.start();
cdl.await();
if (x == 0 && y == 0) {
System.out.println("第" + i + "次迴圈時, (" + x + "," + y + ")");
break;
}
}
}
}
跑這個程式需要等一會,執行結果:
我來解釋下這個程式在幹什麼,程式中有四個成員變數 a, b, x, y ,初始都是0。然後執行一個無限迴圈,迴圈中啟動兩個執行緒,兩個執行緒分別去修改 a, b, x, y 四個變數,一共有四行程式碼:
a = 1;
x = b;
b = 1;
y = a;
每次修改完成後,判斷下x和y是否都為0,是則列印 x, y 並停止迴圈,否則重新迴圈,並將四個變數歸零。
現在我們來簡單推理下,假設程式嚴格按照程式碼的順序去執行,那麼兩個執行緒修改完成後,a, b, x, y 的值有哪些可能呢?
我猜你懶得推理,直接說結論吧,這裡我使用一張馬士兵老師的圖:
結果一共有6種可能性,一種為x=0,y=1,一種為x=1,y=0,另外四種都為x=1,y=1,可以發現,沒有任何情況的結果是x=0,y=0的。
但是,我們從上面程式實際執行結果可以看到,迴圈終止了,也列印出了當第35239次迴圈時,x=0,y=0
。那麼也就是說,必然發生了上面6種可能性以外的其他情況。大家可以再簡單推理下,發生什麼情況會導致 x=0,y=0 呢?
我猜你還是懶得推理,直接上圖:
我們發現只有這2種情況,會導致x=0,y=0。而從這兩種情況可以發現,程式碼執行的順序,和我們寫的順序發生了交換,第一個執行緒裡原本是a = 1;x = b;
,在這兩種情況裡,都變成了x = b;a = 1;
,第二個執行緒裡也是如此。由此可以證明,指令重排序的存在。
下面我們來看個經典的面試題
DCL(Double Check Lock)雙重檢查鎖,單例模式的一種實現方案,程式碼如下:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
這段程式碼看起來很完美,但它是有問題的。主要在於instance = new Singleton()
這句,這其實並非是一個原子操作,事實上這行程式碼大概做了下面 3 件事情:
但是由於存在指令重排序的優化,上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是1-3-2,則就有可能在 3 執行完畢、2 未執行之前,被執行緒二搶佔了CPU,去呼叫 getSingleton() 方法。這時 instance 已經是非 null 了(但卻沒有執行第二步的初始化,此時只是完成第一步的半初始化狀態),所以執行緒二會直接返回 instance,然後使用,然後理所當然發生錯誤。因為此時 instance 物件還沒有執行第二步 ,沒有呼叫建構函式初始化成員變數。
這裡給大家看下 new 一個物件,位元組碼長什麼樣,證實一下確實是這三個步驟,可不是我胡說:
圖中紅色框起來的三行位元組碼指令,就是上面對應的三個步驟( dup 和 return 指令在這裡暫時不需要關注)。
0 new #2 <java/lang/Object>
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
那麼這個問題要怎麼解決呢?其實只要將變數 instance ⽤ volatile 修飾,就可以避免這個問題了。
public class Singleton {
/** 宣告成 volatile */
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
可另一個問題又來了,為什麼加了 volatile ,就可以避免指令重排序導致的問題呢?
我們想一下,發生上面指令重排序的情況,本質上就是兩行程式碼的順序發生了交換。比如你站在A點,我站在B點,你我交換位置就會出現問題。那如果不想讓你我交換位置,有什麼辦法呢?給咱倆中間加一堵牆就行了嘛,你過不來,我也過不去,就不會發生位置交換了。沒錯,其實 volatile 就是這麼幹的,這堵「牆」,就被稱之為記憶體屏障。
記憶體屏障,其本質上是一條特殊的屏障指令,編譯器/CPU當看到這條指令的時候,就絕對不會將這條指令之前的指令,和之後的指令換順序。
那屏障指令有哪些呢?不同的CPU,是不一樣的。我們以英特爾CPU舉例,它的屏障指令有3個:lfence、mfence、sfence。這個東西是組合級別的,我是學Java的,暫時不用關心這些。
那Java裡面有沒有屏障指令呢?Java裡也得有一種機制,來告訴JVM,不能隨便換順序啊。沒錯,這語句就是volatile。
JVM在看到 volatile 之後呢,就會給被 volatile 修飾的變數加屏障指令。注意這裡和快取一致性協定沒有關係,快取一致性協定是硬體級別的東西,我們現在講的是 Java 虛擬機器器中的實現。
JVM中的記憶體屏障一共有四種,這是JVM的規範:
看著有點懵,其實很簡單。
以第一個LoadLoad屏障為例,有個一個變數X,它前面有人讀它,它後面也有人讀它,中間有個LoadLoad屏障,那麼前面的Load和後面的Load就不能換順序。
再比如第二個StoreStore屏障,有個一個變數X,它前面有人寫它,它後面也有人寫它,中間有個StoreStore屏障,那麼前面的Store和後面的Store就不能換順序。
好了後面的兩個指令就不用講了吧。
那麼就可以看volatile是怎麼實現的了,在JVM層面:
對於被volatile修飾的變數,在發生寫的前面,會加上StoreStore屏障,在後面會加上StoreLoad屏障。
對於被volatile修飾的變數,在發生讀的後面,會加上LoadLoad屏障,和LoadStore屏障。
這樣就從JVM上保證了讀寫的有序性。
好了,今天就到這裡,下次有空再聊聊最難的原子性。