一、指令重排的定義
- 編譯器重排:編譯器在不改變單線程程序語義的前提下,重新安排語句的執行順序。
- 處理器重排:CPU 採用了指令級並行技術,將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應的機器指令的執行順序。
二、指令重排的背景和原因
- 串行執行:廚師可能會先把魚蒸上(需要 10 分鐘),然後在這 10 分鐘裏什麼都不做,一直等魚蒸好,再去炒青菜(需要 2 分鐘)。總耗時 12 分鐘。這顯然效率很低。
- 重排並行執行:一個更高效的廚師會這樣做:他先把魚放進蒸箱,設置好時間。然後,在魚蒸着的這 10 分鐘裏,他轉身去炒青菜。這樣,當青菜炒好時,魚也差不多蒸好了。總耗時大約就是 10 分鐘。
三、指令重排的類型
四、一個經典的例子:指令重排導致的併發問題
java
運行
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次檢查
synchronized (Singleton.class) { // 2. 加鎖
if (instance == null) { // 3. 第二次檢查
instance = new Singleton(); // 4. 創建實例
}
}
}
return instance;
}
}
|
時間點
|
線程 A
|
線程 B
|
|
T1 |
檢查 |
-
|
|
T2 |
獲取鎖。
|
-
|
|
T3 |
再次檢查 |
-
|
|
T4 |
執行
1. 分配內存。
2. 將 |
-
|
|
T5 |
-
|
檢查 |
|
T6 |
-
|
直接返回了這個未被完全初始化的 |
|
T7 |
線程 A 繼續執行對象的初始化工作。
|
線程 B 開始使用一個 “半成品” 對象,可能導致程序崩潰或數據異常。
|
五、如何解決指令重排問題?
- 可見性:一個線程對
volatile變量的修改,會立即被其他線程看到。 - 有序性:
volatile變量的讀寫操作前後,會形成一個 “內存屏障”(Memory Barrier)。這相當於告訴 CPU:“在執行我後面的指令之前,必須先完成我前面所有volatile變量的讀寫操作。”
java
運行
public class Singleton {
// 使用 volatile 關鍵字修飾
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
// volatile 會禁止此處的指令重排
instance = new Singleton();
}
}
}
return instance;
}
}
總結
- 是什麼? 指令重排是 JVM 和 CPU 為優化性能而對指令執行順序進行的重新排列。
- 為什麼? 為了最大化利用 CPU 資源,提升程序運行效率。
- 有何風險? 在單線程中無害,但在多線程環境下,可能會破壞程序的正確性,導致難以預料的 bug。
- 如何應對? 在併發編程中,當多個線程共享狀態時,應使用
volatile、synchronized等關鍵字來顯式地保證內存可見性和操作有序性,從而規避指令重排帶來的風險。