前言
且看一段測試程式碼, 在不借助外界工具的條件下得出你自己的答案。
import java.util.*;
import java.util.concurrent.CountDownLatch;
public class Reordering {
static int a = 0;
static int b = 0;
static int x = 0;
static int y = 0;
static final Set<Map<Integer, Integer>> ans = new HashSet<>(4);
public void help() throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(2);
Thread threadOne = new Thread(() -> {
a = 1;
x = b;
latch.countDown();
});
Thread threadTwo = new Thread(() -> {
b = 1;
y = a;
latch.countDown();
});
threadOne.start();
threadTwo.start();
latch.await();
Map<Integer, Integer> map = new HashMap<>();
map.put(x, y);
if (!ans.contains(map)) {
ans.add(map);
}
}
@Test
public void testReordering() throws InterruptedException {
for (int i = 0; i < 20000 && ans.size() != 4; i++) {
help();
a = x = b = y = 0;
}
help();
System.out.println(ans);
}
}
複製程式碼
你的結果ans可能是[{0=>1}, {1=>1}, {1=>0}]
, 因為執行緒排程是隨機的, 有可能一個執行緒執行了, 另外一個執行緒才獲得cpu的執行權, 又或者是兩個執行緒交疊執行, 這種情況下ans的答案無疑是上面三種結果, 至於上面三種結果對應的執行緒執行順序, 我這裡就不模擬了, 這不是重點。但是其實ans除了上面的三種結果之外, 還有另外一種結果{0=>0}
, 這是為什麼呢? 要想出現{0=>0}這種結果無非就是:
- threadOne先執行x = b = > x = 0;
- threadTwo執行b = 1, y = a => y = 0
- threadOne執行a = 1。
或者把threadOne和two的角色互換一下。 你或許很疑問為啥會出現
x = b happens before a = 1
呢? 這其實就是指令重排序。
指令重排序
大多數現代微處理器都會採用將指令亂序執行的方法, 在條件允許的情況下, 直接執行當前有能力立即執行的後續指令, 避開獲取下一條指令所需資料時造成的等待。通過亂序執行的技術, 處理器可以大大提高執行效率。除了cpu會對指令重排序來優化效能之外, java JIT也會對指令進行重排序。
什麼時候不進行指令重排序
那麼什麼時候不禁止指令重排序或者怎麼禁止指令重排序呢?不然一切都亂套了。
資料依賴性
其一, 有資料依賴關係的指令不會進行指令重排序! 什麼意思呢?
a = 1;
x = a;
複製程式碼
就像上面兩條指令, x
依賴於a
, 所以x = a
這條指令不會重排序到a = 1
這條指令的前面。
有資料依賴關係分為以下三種:
- 寫後讀, 就像上面我們舉的那個例子
a = 1
和x = a
, 這就是典型的寫後讀, 這種不會進行指令重排序。 - 寫後寫, 如
a = 1
和a = 2
, 這種也不會進行重排序。 - 還有最後一種資料依賴關係, 就是讀後寫, 如
x = a
和a = 1
。
as-if-serial語義
什麼是as-if-serial? as-if-serial語義就是: 不管怎麼重排序(編譯器和處理器為了提高並行度), 單執行緒程式的執行結果不能被改變。所以編譯器和cpu進行指令重排序時候回遵守as-if-serial語義。舉個栗子:
x = 1; //1
y = 1; //2
ans = x + y; //3
複製程式碼
上面三條指令, 指令1和指令2沒有資料依賴關係, 指令3依賴指令1和指令2。根據上面我們講的重排序不會改變我們的資料依賴關係, 依據這個結論, 我們可以確信指令3是不會重排序於指令1和指令2的前面。我們看一下上面上條指令編譯成位元組碼檔案之後:
public int add() {
int x = 1;
int y = 1;
int ans = x + y;
return ans
}
複製程式碼
對應的位元組碼
public int add();
Code:
0: iconst_1 // 將int型數值1入運算元棧
1: istore_1 // 將運算元棧頂數值寫到區域性變數表的第2個變數(因為非靜態方法會傳入this, this就是第一個變數)
2: iconst_1 // 將int型數值1入運算元棧
3: istore_2 // 將將運算元棧頂數值寫到區域性變數表的第3個變數
4: iload_1 // 將第2個變數的值入運算元棧
5: iload_2 // 將第三個變數的值入運算元棧
6: iadd // 運算元棧頂元素和棧頂下一個元素做int型add操作, 並將結果壓入棧
7: istore_3 // 將棧頂的數值存入第四個變數
8: iload_3 // 將第四個變數入棧
9: ireturn // 返回
複製程式碼
以上的位元組碼我們只關心0->7行, 以上8行指令我們可以分為:
- 寫x
- 寫y
- 讀x
- 讀y
- 加法操作寫回ans
上面的5個操作, 1操作和2、4可能會重排序, 2操作和1、3ch重排序, 操作3可能和2、4重排序, 操作4可能和1、3重排序。對應上面的賦值x和賦值y有可能會進行重排序, 對, 這並不難以理解, 因為寫x和寫y並沒有明確的資料依賴關係。但是操作1和3和5並不能重排序, 因為3依賴1, 5依賴3, 同理操作2、4、5也不能進行重排序。
所以為了保證資料依賴性不被破壞, 重排序要遵守as-if-serial語義。
@Test
public void testReordering2() {
int x = 1;
try {
x = 2; //A
y = 2 / 0; //B
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println(x);
}
}
複製程式碼
上面這段程式碼A和B是有可能重排序的, 因為x和y並沒有資料依賴關係, 並且也沒有特殊的語義做限制。但是如果發生B happens-before A的話, 此時是不是就列印了錯誤的x的值, 其實不然: 為了保證as-if-serial語義, Java異常處理機制對重排序做了一種特殊的處理: JIT在重排序時會在catch語句中插入錯誤代償程式碼(即重排序到B後面的A), 這樣做雖然會導致catch裡面的邏輯變得複雜, 但是JIT優化原則是: 儘可能地優化程式正常執行下的邏輯, 哪怕以catch塊邏輯變得複雜為代價。
程式順序原則
- 如果A happens-before B
- 如果B happens-before C 那麼
- A happens-before C
這就是happens-before傳遞性
重排序與JMM
Java記憶體模型(Java Memory Model簡稱JMM)總結了以下8條規則, 保證符合以下8條規則, happens-before前後兩個操作, 不會被重排序且後者對前者的記憶體可見。
- 程式次序法則: 執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B, 其中, 在程式中, 所有的動作B都能出現在A之後。
- 監視器鎖法則: 對一個監視器鎖的解鎖happens-before於每一個後續對同一監視器鎖的加鎖。
- volatile變數法則: 對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
- 執行緒啟動法則: 在一個執行緒裡, 對Thread.start的呼叫會happens-before於每個啟動執行緒的動作。
- 執行緒終結法則: 執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已經終結、或者從Thread.join呼叫中成功返回, 或Thread.isAlive返回false。
- 中斷法則: 一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷。
- 終結法則: 一個物件的建構函式的結束happens-before於這個物件finalizer的開始。
- 傳遞性: 如果A happens-before於B, 且B happens-before於C, 則A happens-before於C。
指令重排序導致錯誤的double-check單例模式
有人肯定寫過下面的double-check單例模式
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
複製程式碼
但是這種double-check加鎖的單例是正常的嗎? No. 因為建立一個例項物件並不是一個原子性的操作, 而且還可能發生重排序, 具體如下: 假定建立一個物件需要:
- 申請記憶體
- 初始化
- instance指向分配的那塊記憶體
上面的2和3操作是有可能重排序的, 如果3重排序到2的前面, 這時候2操作還沒有執行, instance已經不是null了, 當然不是安全的。
那麼怎麼防止這種指令重排序? 修改如下:
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
複製程式碼
volatile
關鍵字有兩個語義:
其一保證記憶體可見性, 這個語義我們下次部落格會講到(其實就是一個執行緒修改會對另一個執行緒可見, 如果不是volatile
, 執行緒操作都是在TLAB有副本的, 修改了副本的值之後不即時重新整理到主存, 其他執行緒是不可見的)
其二, 禁止指令重排序, 如果上面new
的時候, 禁止了指令重排序, 所以能得到期望的情況。
題外話, 關於執行緒安全的單例, 往往可以採用靜態內部類的形式來實現, 這種無疑是最合適的了。
public class Singleton {
public static Singleton getInstance() {
return Helper.instance;
}
static class Helper {
private static final Singleton instance = new Singleton();
}
}
複製程式碼
怎麼禁止指令重排序
我們之前一會允許重排序, 一會禁止重排序, 但是重排序禁止是怎麼實現的呢? 是用記憶體屏障cpu指令來實現的, 顧名思義, 就是加個障礙, 不讓你重排序。
記憶體屏障可以被分為以下幾種型別:
- LoadLoad屏障: 對於這樣的語句Load1; LoadLoad; Load2, 在Load2及後續讀取操作要讀取的資料被訪問前, 保證Load1要讀取的資料被讀取完畢。
- StoreStore屏障: 對於這樣的語句Store1; StoreStore; Store2, 在Store2及後續寫入操作執行前, 保證Store1的寫入操作對其它處理器可見。
- LoadStore屏障: 對於這樣的語句Load1; LoadStore; Store2, 在Store2及後續寫入操作被刷出前, 保證Load1要讀取的資料被讀取完畢。
- StoreLoad屏障: 對於這樣的語句Store1; StoreLoad; Load2, 在Load2及後續所有讀取操作執行前, 保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中, 這個屏障是個萬能屏障, 兼具其它三種記憶體屏障的功能。