內存可見性,指令重排序,JIT。。。。。。從一個知乎問題談起

在知乎上看到一個問題《java中volatile關鍵字的疑惑?》,引發了個人興趣html

 

問題是這樣的:java

 1 package com.cc.test.volatileTest;
 2 
 3 public class VolatileBarrierExample {
 4     private static boolean stop = false;
 5 
 6     public static void main(String[] args) throws InterruptedException {
 7         Thread thread = new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 while (!stop) {
11                 }
12             }
13         });
14 
15         thread.start();
16         Thread.sleep(1000);
17         stop = true;
18         thread.join();
19     }
20 }

 

這段代碼的主要目的是:主線程修改非volatile類型的全局變量stop,子線程輪詢stop,若是stop發生變更,則程序退出。編程

可是若是實際運行這段代碼會形成死循環,程序沒法正常退出。windows

若是對Java併發編程有必定的基礎,應該已經知道這個現象是因爲stop變量不是volatile的,主線程對stop的修改不必定能被子線程看到而引發的。架構

 

可是題主玩了個花樣,額外定義了一個static類型的volatile變量i,在while循環中對i進行自增操做,代碼以下所示:併發

 1 package com.cc.test.volatileTest;
 2 
 3 public class VolatileBarrierExample {
 4     private static boolean stop = false;
 5     private static volatile int i = 0;
 6 
 7     public static void main(String[] args) throws InterruptedException {
 8         Thread thread = new Thread(new Runnable() {
 9             @Override
10             public void run() {
11                 int i = 0;
12                 while (!stop) {
13                     i++; 14                 }
15             }
16         });
17 
18         thread.start();
19         Thread.sleep(1000);
20         stop = true;
21         thread.join();
22     }
23 }

 

這段程序是能夠在運行一秒後結束的,也就是說子線程對volatile類型變量i的讀寫,使非volatile類型變量stop的修改對於子線程是可見的!app

 

 

看起來使人感到困惑,可是實際上這個問題是不成立的。ide

先給出歸納性的答案:stop變量的可見性不管在哪一種場景中都沒有獲得保證。這兩個場景中程序是否能正常退出,跟JVM實現與CPU架構有關,沒有肯定性的答案。函數

 

下面從兩個不一樣的角度來分析優化

一:happens-before原則:

第一個場景就不談了,即便在第二種場景裏,雖然子線程中有對volatile類型變量i的讀寫+非volatile類型變量stop的讀,可是主線程中只有對非volatile類型變量stop的寫入,所以沒法創建 (主線程對stop的寫) happens-before於 (子線程對stop的讀) 的關係

也就是不能期望主線程對stop的寫必定能被子線程看到。

雖然場景二在實際運行時程序依然正確終止了,可是這個只能算是運氣好,若是換一種JVM實現或者換一種CPU架構,可能場景二也會陷入死循環。

能夠設想這樣的一個場景,主/子線程分別在core1/core2上運行,core1的cache中有stop的副本,core2的cache中有stop與i的副本,並且stop和i不在同一條cacheline裏。

core1修改了stop變量,可是因爲stop不是volatile的,這個改動能夠只發生在core1的cache裏,而被修改的cacheline理論上能夠永遠不刷回內存,這樣core2上的子線程就永遠也看不到stop的變化了。

二:JIT角度:

因爲run方法裏的while循環會被執行不少次,因此必然會觸發jit編譯,下面來分析兩種狀況下jit編譯後的結果(觸發了屢次jit編譯,只貼出最後一次C2等級jit編譯後的結果)

如何查看JIT後的彙編碼請參看個人這篇博文:《如何在windows平臺下使用hsdis與jitwatch查看JIT後的彙編碼》

ps. 回答首發於知乎,從新截圖太麻煩,所以實際分析使用的Java源碼與前面貼的代碼略有不一樣,不影響理解,會意便可。

A. i爲run方法內的局部變量的狀況:

 

    1. 在第一個紅框處檢測stop變量,若是爲true,那麼跳轉到L0001處繼續執行(L0001處再往下走函數就退出了),但此時stop爲false,因此不會走這個分支
    2. L0000,inc %ebp。也就是i++
    3. test %eax, -0x239864a(%rip),輪詢SAFEPOINT的操做,能夠無視
    4. jmp L0000,無條件跳轉回L0000處繼續執行i++

 

若是把jit編譯後的代碼改寫回來,大概是這個樣子

1 if(!stop){
2      while(true){
3           i++;
4     }
5 }

 

很是明顯的指令重排序,JVM以爲每次循環都去訪問非volatile類型的stop變量太浪費了,就只在函數執行之初訪問一次stop,後續不管stop變量怎麼變,都無論了。

第一種狀況死循環就是這麼來的。

 

B. i爲全局的volatile變量的狀況:

 

 

從第一個紅框開始看:

    1. jmp L0001,無條件跳轉到label L0001處
    2. movzbl 0x6c(%r10),%r8d; 訪問static變量stop,並將其複製到寄存器r8d裏
    3. test %r8d, %r8d; je L0000; 若是r8d裏的值爲0,跳轉到L0000處,不然繼續往下走(函數結束)
    4. L000: mov 0x68(%r10), %r8d; 訪問static變量i,並將其複製到寄存器r8d裏
    5. inc %r8d; 自增r8d裏的值
    6. mov %r8d, 0x68(%r10); 將自增後r8d裏的新值複製回static變量i中(上面三行是i++的流程)
    7. lock addl $0x0, (%rsp); 給rsp寄存器裏的值加0,沒有任何效果,關鍵在於前面的lock前綴,會致使cache line的刷新,從而實現變量i的volatile語義
    8. test %eax, -0x242a056(%rip); 輪詢SAFEPOINT的操做,能夠無視
    9. L0001,回到step 2

也就是說,每次循環都會去訪問一次stop變量,最終訪問到stop被修改後的新值(可是不能確保在全部JVM與全部CPU架構上都必定能訪問到),致使循環結束。

 

 這兩種場景的區別主要在於第二種狀況的循環中有對static volatile類型變量i的訪問,致使jit編譯時JVM沒法作出激進的優化,是附加的效果。

 

 

總結

涉及到內存可見性的問題,必定要用happens-before原則細緻分析。由於你很難知道JVM在背後悄悄作了什麼奇怪的優化。

相關文章
相關標籤/搜索