在知乎上看到一個問題《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方法內的局部變量的情況:

-
- 在第一個紅框處檢測stop變量,如果為true,那么跳轉到L0001處繼續執行(L0001處再往下走函數就退出了),但此時stop為false,所以不會走這個分支
- L0000,inc %ebp。也就是i++
- test %eax, -0x239864a(%rip),輪詢SAFEPOINT的操作,可以無視
- jmp L0000,無條件跳轉回L0000處繼續執行i++
如果把jit編譯后的代碼改寫回來,大概是這個樣子
1 if(!stop){ 2 while(true){ 3 i++; 4 } 5 }
非常明顯的指令重排序,JVM覺得每次循環都去訪問非volatile類型的stop變量太浪費了,就只在函數執行之初訪問一次stop,后續無論stop變量怎么變,都不管了。
第一種情況死循環就是這么來的。
B. i為全局的volatile變量的情況:

從第一個紅框開始看:
-
- jmp L0001,無條件跳轉到label L0001處
- movzbl 0x6c(%r10),%r8d; 訪問static變量stop,並將其復制到寄存器r8d里
- test %r8d, %r8d; je L0000; 如果r8d里的值為0,跳轉到L0000處,否則繼續往下走(函數結束)
- L000: mov 0x68(%r10), %r8d; 訪問static變量i,並將其復制到寄存器r8d里
- inc %r8d; 自增r8d里的值
- mov %r8d, 0x68(%r10); 將自增后r8d里的新值復制回static變量i中(上面三行是i++的流程)
- lock addl $0x0, (%rsp); 給rsp寄存器里的值加0,沒有任何效果,關鍵在於前面的lock前綴,會導致cache line的刷新,從而實現變量i的volatile語義
- test %eax, -0x242a056(%rip); 輪詢SAFEPOINT的操作,可以無視
- L0001,回到step 2
也就是說,每次循環都會去訪問一次stop變量,最終訪問到stop被修改后的新值(但是不能確保在所有JVM與所有CPU架構上都一定能訪問到),導致循環結束。
這兩種場景的區別主要在於第二種情況的循環中有對static volatile類型變量i的訪問,導致jit編譯時JVM無法做出激進的優化,是附加的效果。
總結
涉及到內存可見性的問題,一定要用happens-before原則細致分析。因為你很難知道JVM在背后悄悄做了什么奇怪的優化。
