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


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

 

問題是這樣的:

 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發生變動,則程序退出。

但是如果實際運行這段代碼會造成死循環,程序無法正常退出。

如果對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的修改對於子線程是可見的!

 

 

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

先給出概括性的答案: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在背后悄悄做了什么奇怪的優化。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM