如果熟悉Java並發編程的話,應該知道在多線程共享變量的情況下,存在“內存可見性問題”:
在一個線程中對某個變量進行賦值,然后在另外一個線程中讀取該變量的值,讀取到的可能仍然是以前的值;
這里並非說的是時序的問題,即使在另外一個線程中循環讀取該變量的值,也可能永遠讀不到該變量的最新值。
請看下面這段代碼:
1 public class Main extends Thread { 2 private static boolean flag = false; 3 4 @Override 5 public void run() { 6 while (!flag) { 7 //System.out.flush(); 8 } 9 } 10 11 public static void main(String[] args) { 12 Main m = new Main(); 13 m.start(); 14 try { 15 Thread.sleep(200); 16 } catch (InterruptedException e) { 17 e.printStackTrace(); 18 } 19 flag = true; 20 try { 21 m.join(); 22 } catch (InterruptedException e) { 23 e.printStackTrace(); 24 } 25 System.out.println("done"); 26 } 27 }
這段代碼在Windows(Java 7 HotSpot),Linux(Java 7 OpenJDK),MacOS(Java 7 HotSpot)上運行的時候根本停不下開;然而在Android(Dalvik)上,類似的代碼則可以正常結束;我們知道,如果將變量flag聲明為volatile的話,那么這段代碼不管在哪個平台上運行都可以正常結束,事實也確實如此;這些平台都沒有問題,它們的行為都符合JMM規范,只不過Android(Dalvik)的行為更保守一些而已。
疑惑在於,為什么是“永遠不可見”?我之前一直以為“內存可見性問題”只是時間長短而已。
更詭異的是,如果將while循環中的System.out.flush()打開的話,程序又都可以正常結束了,這又是什么原因呢?
首先,我們從字節碼入手,發現它們對應的字節碼基本上是一樣的;即使是volatile版本,也只不過是在變量上增加了一個volatile標記,字節碼並無不同。
據此,我們可以推斷,差異可能來源於JIT,於是關掉JIT(如何控制JVM中的JIT行為?),果然,這些代碼又都可以正常結束了。
按照我之前學習到的一些有關多核CPU方面的知識,多核CPU的行為並不會導致“永遠不可見”的問題,理由如下:
1.如果是CPU緩存,多核CPU之間存在“緩存一致性”協議,所以這里並不會導致“不可見”的問題;
2.如果是CPU Store Buffer,因為容量有限,遲早會寫回到緩存,所以這里並不會導致“永遠不可見”的問題;
3.如果是CPU指令重排序,由於這段代碼是在一個循環中讀取變量的值,所以這里不會有任何影響。
那么,問題就只能出在JIT生成的代碼上了,讓我們查看一下JIT生成的代碼(如何控制JVM中的JIT行為?):
這個是無volatile無System.out.flush()的版本,它不能停止,說明如下:
第一個紅色標記,讀取flag的值
第二個紅色標記,判斷flag的值是否為false,如果是則順序執行到第三個紅色標記處
第三個紅色標記,這里是一個死循環
從這里可以看出,JIT對生成的代碼做了高度優化,它認為代碼中沒有地方對flag進行修改,因此直接生成一段死循環代碼,避免反復讀取flag的值以提升性能,但是這違背了這段代碼的原意,導致程序不能停止。
這個是有volatile的版本,它可以正常結束,說明如下:
第一個紅色標記,讀取flag的值
第二個紅色標記,判斷flag的值是否為false,如果是則跳轉到第個紅色標記處
這完全符合這段代碼的原意,因此可以正常結束。
這個是有System.out.flush()的版本,從紅色標記處可以看出,這里也完全符合代碼原意,因此可以正常結束;由於某種原因,JIT沒有對生成的代碼進行優化。
至此,疑惑已完全解開,在此也順便總結一下Java中的volatile關鍵字:
1.阻止Java編譯器對字節碼進行重排序(似乎沒有Java實現在字節碼層面進行重排序)
2.在JIT生成的代碼中插入適當的內存屏障指令
3.禁止JIT過度優化生成的代碼
3.字節碼層面並不會關心volatile(變量標記除外),執行引擎和JIT應該關心