多執行緒的指令重排問題:as-if-serial語義,happens-before語義;volatile關鍵字,volatile和synchronized的區別
阿新 • • 發佈:2020-08-20
# 一、指令重排問題
你寫的程式碼有可能,根本沒有按照你期望的順序執行,因為**編譯器和 CPU 會嘗試指令重排來讓程式碼執行更高效**,這就是指令重排。
## 1.1 虛擬機器層面
我們都知道CPU執行指令的時候,**訪問記憶體的速度遠慢於 CPU 速度**。
為了儘可能減少記憶體操作帶來的 CPU 空置的影響,虛擬機器會按照自己的一些規則將程式編寫順序打亂:即寫在後面的程式碼在時間順序上可能會先執行,而寫在前面的程式碼會後執行。
當然這樣的前提是**不會產生錯誤**。不管誰先開始,總之後面的程式碼在一些情況下存在先結束的可能。
## 1.2 硬體層面
在硬體層面,CPU會將接收到的一批指令按照其規則重排序,同樣是**基於 CPU 速度比快取速度快的原因。**
和上一點的目的類似,只是硬體處理的話,每次只能在**接收到的有限指令範圍內**重排序,而虛擬機器可以在更大層面、更多指令範圍內重排序。
## 1.3 資料依賴
如果兩個操作訪問的是**同一個變數**,**且其中有一個是寫操作**,那麼這兩個操作之間就存在**資料依賴**。
資料依賴分為讀後寫、寫後寫、寫後讀。
```java
讀後寫:a = b;b = 1;
寫後讀:a = 1;b = a;
寫後寫:a = 1;a = 2;
```
上面這幾種情況,存在資料以來,當存在資料依賴的時候,重排指令就會造成程式結果錯誤,所以編譯器和處理器會遵循資料依賴,不改變順序。
## 1.4 As-If-Serial語義
基於上面的重排序原則,不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)**程式的執行結果不能被改變**。
編譯器,runtime 和處理器都必須遵守 as-if-serial 語義。為了遵守 as-if-serial 語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。
## 1.5 Happens-Before語義
直接翻譯就是,在之前發生,本質上和 as-if-serial 一樣的。
**定義**:如果一個操作 happens-before 另一個操作,那麼意味著第一個操作的結果對第二個操作可見,而且第一個操作的執行順序將排在第二個操作的前面。
如果重排序之後的結果,與按照happens-before關係來**執行的結果一致**,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。
**具體規則如下:**
* 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
* 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
* volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
* 執行緒啟動規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。
* 執行緒終結規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
* 傳遞性規則:如果A happens-before B,且B happens-before C,那麼A happens-before C。
**總結:**
這麼些規則總的來說就在體現一件事,也就是單執行緒裡,程式有關的各個層面都會做指令重排的優化,而且會用這些規則來保證結果正確。
# 二、多執行緒的指令重排
如果是多執行緒,無法保證正確性,指令重排可能會造成結果錯誤。
我個人是這樣理解的:多執行緒最大問題就是一個 先來後到 可能破壞正確性的問題,因此有同步、加鎖等等機制來保障先來後到,可是指令重排讓執行緒本身的指令亂了,就可能讓整體的結果功虧一簣。
一個簡單的例子:
```java
/**
* 指令重排問題
*/
public class HappenBefore {
private static int i = 0, j = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
while (true){
i = 0;
j = 0;
a = 0;
b = 0;
Thread t1 = new Thread(()-> {
a = 1;
i = b;
});
Thread t2 = new Thread(()->{
b = 1;
j = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("i = " + i +" ; j = " + j);
if (i == 0 && j == 0){
break;
}
}
}
}
```
上面程式裡開了兩個執行緒,交替賦值;join保障這兩個執行緒都在 main 執行緒之前執行完,確保最後在執行完後輸出。(注意並不是保證 t1 在 t2 之前執行)
| 執行緒t1 | 執行緒t2 |
|--|--|
| a = 1; | b = 1; |
| i = b; | j = a; |
如果沒有指令重排,按照我們的分析,多執行緒情況下,t1 和 t2 分別執行完,或者經過cpu的排程,t1 和 t2 執行緒經過了切換,那麼可能出現的情況是,最終的 i 和 j 為:
* i = 0,j = 1;(t1先執行完)
* i = 1,j = 0;(t2先執行完)
* i = 1,j = 1;(中間執行緒切換了)
但是結果**應該**是不會出現 i = 0,j = 0 的情況的。
可是程式碼跑起來就會發現,會出現 i = 0,j = 0 的情況,這就是指令重排在多執行緒情況下帶來的問題。在各自單執行緒裡,因為沒有依賴關係,所以編譯器、虛擬機器以及 cpu 可以進行亂序重排,最後導致了這種結果。
# 三、volatile 關鍵字
Volatile 英文翻譯:易變的、可變的、不穩定的。
1. 在之前的示例中,執行緒不安全的問題,我們使用執行緒同步,也就是通過 **synchronized** 關鍵字,對操作的物件加鎖,遮蔽了其他執行緒對這塊程式碼的訪問,從而保證安全。
2. 這裡的 **volatile** 嘗試從**另一個角度**解決這個問題,那就是保證變數可見,有說法將其稱之為**輕量級的synchronized**。
volatile保證變數可見:簡單來說就是,當執行緒 A 對變數 X 進行了修改後,線上程 A 後面執行的其他執行緒能夠看到 X 的變動,就是保證了 X 永遠是最新的。
更詳細的說,就是要符合以下兩個規則:
1. 執行緒對變數進行修改後,要立刻寫回主記憶體;
2. 執行緒對變數讀取的時候,要從主記憶體讀,而不是快取。
另一個角度,**結合指令重排序,volatile 修飾的記憶體空間,在這上面執行的指令是禁止亂序的**。因此,在單例模式的 DCL 寫法中,volatile 也是必須的元素。
## 3.1 volatile使用示例1
```java
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (num == 0){
}
}).start();
Thread.sleep(1000);
num = 1;
}
```
程式碼死迴圈,因為**主執行緒裡的 num = 1,不能及時將資料變化更新到主存**,因此上面的程式碼 while 條件持續為真。
因此可以給變數加上 volatile:
```java
private volatile static int num = 0;
```
這樣就在執行幾秒後就會停止執行。
## 3.2 單例模式Double Checked Locking
在設計模式裡的單例模式,如果在多執行緒的情況下,仍然要保證始終只有一個物件,就要進行同步和鎖。
```java
class DCL{
private static volatile DCL instance;
private DCL(){
}
public static DCL getInstance(){
if (instance == null){//check1
synchronized (DCL.class){
if (instance == null){//check2
instance = new DCL();
}
}
}
return instance;
}
}
```
雙重校驗鎖,實現執行緒安全的單例鎖。
關於單例模式的內容,指路:[單例模式講解及8種寫法](https://www.cnblogs.com/lifegoeson/p/13474269.html)
### 但是:volatile不能保證原子性。
## 3.3 原子性問題
原子操作就是這個操作要麼執行完,要麼不執行,不可能卡在中間。
比如在 Java 裡, i = 2,這個指令是具有原子性的,而 i++ 則不是,事實上 i++ 也是先拿 i,再修改,再重新賦值給 i 。
例如你讓一個volatile的integer自增(i++),其實要分成3步:
1)讀取volatile變數值到local;
2)增加變數的值;
3)把local的值寫回,讓其它的執行緒可見。
這 3 步的jvm指令為:
```java
mov 0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier
```
最後一步是**記憶體屏障**。
**什麼是記憶體屏障?**記憶體屏障告訴CPU和編譯器先於這個命令的必須先執行,後於這個命令的必須後執行,同時強制更新一次不同CPU的快取,也就是通過這個操作,使得 **volatile 關鍵字達到了所謂的變數可見性。**
這個時候我們就知道,**如果一個操作有好幾步,如果其他的執行緒修改了值,將會都產生覆蓋,還是會出現不安全的情況,所以, volatile 關鍵字本身無法保證原子性。**
volatile 無法保證原子性。我們還是用兩數之和來示例:
```java
public class NoAtomic {
private static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i=0; i<100; i++){
new Thread(()->{
for (int j=0; j < 100; j++){
num++;
}
}).start();
}
Thread.sleep(3000);
System.out.println(num);
}
}
```
輸出結果會小於預期,雖然 volatile 保證了可見性,但是卻不能保證操作的原子性。因此想要保證原子性,還是得回去找 synchronized 或者使用juc下的原子資料型別。
## 3.4 volatile 和 synchronized 的區別
* volatile 本質是在告訴 jvm 當前變數在暫存器(工作記憶體)中的值是不確定的,需要從主存中讀取; synchronized 則是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞住。
* volatile 僅能使用在變數級別;synchronized則可以使用在變數、方法、和類級別的。
* volatile 僅能實現變數的修改可見性,不能保證原子性;而 synchronized 則可以保證變數的修改可見性和原子性。
* volatile 不會造成執行緒的阻塞;synchronized 可能會造成執行緒的阻塞。
> 不過:由於硬體層面,從工作記憶體到主存的更新速度已經提升的很快,加上 synchronized 的改進,也已經不用考慮太過重量的問題,所以 volatile 很少