一、序言
指令重排在單執行緒環境下有利於提高程式的執行效率,不會對程式產生負面影響;在多執行緒環境下,指令重排會給程式帶來意想不到的錯誤。
本文對多執行緒指令重排問題進行復原,並針對指令重排給出相應的解決方案。
二、問題復原
(一)關聯變數
下面給出一個能夠百分之百復原指令重排的例子。
public class D {
static Integer a;
static Boolean flag;
public static void writer() {
a = 1;
flag = true;
}
public static void reader() {
if (flag != null && flag) {
System.out.println(a);
a = 0;
flag = false;
}
}
}
1、結果預測
reader
方法僅在flag
變數為true時向控制檯列印變數a
的值。
writer
方法先執行變數a
的賦值操作,後執行變數flag
的賦值操作。
如果按照上述分析邏輯,那麼控制檯列印的結果一定全為1。
2、指令重排
假如程式碼未發生指令重排,那麼當flag
變數為true時,變數a
一定為1。
上述程式碼中關於變數a
和變數flag
在兩個方法類均存在指令重排的情況。
public static void writer() {
a = 1;
flag = true;
}
通過觀察日誌輸出,發現有大量的0輸出。
當writer
方法內部發生指令重排時,flag
變數先完成賦值,此時假如當前執行緒發生中斷,其它執行緒在呼叫reader
方法,檢測到flag
變數為true,那麼便列印變數a
的值。此時控制檯存在超出期望值的結果。
(二)new建立物件
使用關鍵字new建立物件時,因其非原子操作,故存在指令重排,指令重排在多執行緒環境下會帶來負面影響。
public class Singleton {
private static UserModel instance;
public static UserModel getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new UserModel(2, "B");
}
}
}
return instance;
}
}
@Data
@AllArgsConstructor
class UserModel {
private Integer userId;
private String userName;
}
1、解析建立過程
- 使用關鍵字new建立一個物件,大致分為一下過程:
- 在棧空間建立引用地址
- 以類檔案為模版在堆空間物件分配記憶體
- 成員變數初始化
- 使用建構函式初始化
- 將引用值賦值給左側儲存變數
2、重排序過程分析
針對上述示例,假設第一個執行緒進入synchronized程式碼塊,並開始建立物件,由於重排序存在,正常的建立物件過程被打亂,可能會出現在棧空間建立引用地址後,將引用值賦值給左側儲存變數,隨後因CPU排程時間片耗盡而產生中斷的情況。
後續執行緒在檢測到instance
變數不為空,則直接使用。因為單例物件併為例項化完成,直接使用會帶來意想不到的結果。
三、應對指令重排
(一)AtomicReference原子類
使用原子類將一組相關聯的變數封裝成一個物件,利用原子操作的特性,有效迴避指令重排問題。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ValueModel {
private Integer value;
private Boolean flag;
}
原子類應該是解決多執行緒環境下指令重排的首選方案,不僅通俗易懂,而且執行緒間使用的非重量級互斥鎖,效率相對較高。
public class E {
private static final AtomicReference<ValueModel> ar = new AtomicReference<>(new ValueModel());
public static void writer() {
ar.set(new ValueModel(1, true));
}
public static void reader() {
ValueModel valueModel = ar.get();
if (valueModel.getFlag() != null && valueModel.getFlag()) {
System.out.println(valueModel.getValue());
ar.set(new ValueModel(0, false));
}
}
}
當一組相關聯的變數發生指令重排時,使用原子操作類是比較優的解法。
(二)volatile關鍵字
public class Singleton {
private volatile static UserModel instance;
public static UserModel getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new UserModel(2, "B");
}
}
}
return instance;
}
}
@Data
@AllArgsConstructor
class UserModel {
private Integer userId;
private String userName;
}
四、指令重排的理解
1、指令重排廣泛存在
指令重排不僅限於Java程式,實際上各種編譯器均有指令重排的操作,從軟體到CPU硬體都有。指令重排是對單執行緒執行的程式的一種效能優化,需要明確的是,指令重排在單執行緒環境下,不會改變順序程式執行的預期結果。
2、多執行緒環境指令重排
上面討論了兩種典型多執行緒環境下指令重排,分析其帶來負面影響,並分別提供了應對方式。
- 對於關聯變數,先封裝成一個物件,然後使用原子類來操作
- 對於new物件,使用volatile關鍵字修飾目標物件即可
3、synchronized鎖與重排序無關
synchronized鎖通過互斥鎖,有序的保證執行緒訪問特定的程式碼塊。程式碼塊內部的程式碼正常按照編譯器執行的策略重排序。
儘管synchronized鎖能夠迴避多執行緒環境下重排序帶來的不利影響,但是互斥鎖帶來的執行緒開銷相對較大,不推薦使用。
synchronized 塊裡的非原子操作依舊可能發生指令重排