本文暫不講JMM(Java Memory Model)中的主存, 工作內存以及數據如何在其中流轉等等,
這些本身還牽扯到硬件內存架構, 直接上手容易繞暈, 先從以下幾個點探索JMM
- 原子性
- 有序性
- 可見性
- 指令重排
- CPU指令重排
- 編譯器優化重排
- Happen-Before規則
原子性
原子性是指一個操作是不可中斷的. 即使是在多個線程一起執行的時候,
一個操作一旦開始,就不會被其它線程干擾. 例如CPU中的一些指令, 屬於原子性的,
又或者變量直接賦值操作(i = 1), 也是原子性的, 即使有多個線程對i賦值, 相互也不會干擾.
而如i++, 則不是原子性的, 因為他實際上i = i + 1, 若存在多個線程操作i, 結果將不可預期.

有序性
有序性是指在單線程環境中, 程序是按序依次執行的.
而在多線程環境中, 程序的執行可能因為指令重排而出現亂序, 下文會有詳細講述.
1 class OrderExample { 2 int a = 0; 3 boolean flag = false; 4 5 public void writer() { 6 // 以下兩句執行順序可能會在指令重排等場景下發生變化 7 a = 1; 8 flag = true; 9 } 10 11 public void reader() { 12 if (flag) { 13 int i = a + 1; 14 …… 15 } 16 } 17 }

可見性
可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改.
會有多種場景影響到可見性:
CPU指令重排
多條匯編指令執行時, 考慮性能因素, 會導致執行亂序, 下文會有詳細講述.
硬件優化(如寫吸收,批操作)
cpu2修改了變量T, 而cpu1卻從高速緩存cache中讀取了之前T的副本, 導致數據不一致.

編譯器優化
主要是Java虛擬機層面的可見性, 下文會有詳細講述.
指令重排
指令重排是指在程序執行過程中, 為了性能考慮, 編譯器和CPU可能會對指令重新排序.
CPU指令重排
一條匯編指令的執行是可以分為很多步驟的, 分為不同的硬件執行
- 取指 IF
- 譯碼和取寄存器操作數 ID
- 執行或者有效地址計算 EX (ALU邏輯計算單元)
- 存儲器訪問 MEM
- 寫回 WB (寄存器)
既然指令可以被分解為很多步驟, 那么多條指令就不一定依次序執行.
因為每次只執行一條指令, 依次執行效率太低了, 假設上述每一個步驟都要消耗一個時鍾周期,
那么依次執行的話, 一條指令要5個時鍾周期, 兩條指令要占用10個時鍾周期, 三條指令消耗15個時鍾.

而如果硬件空閑即可執行下一步, 類似於工廠中的流水線, 一條指令要5個時鍾周期,
兩條指令只需要6個時鍾周期, 因為是錯位流水執行, 三條指令消耗7個時鍾.

舉個例子 A = B + C, 需要如下指令
- 指令1 : 加載B到寄存器R1中
- 指令2 : 加載C到寄存器R2中
- 指令3 : 將R1與R2相加, 得到R3
- 指令4 : 將R3賦值給A
注意下圖紅色框選部分, 指令1, 2獨立執行, 互不干擾.
指令3依賴於指令1, 2加載結果, 因此紅色框選部分表示在等待指令1, 2結束.
待指令1, 2都已經走完MEM部分, 數據加載到內存后, 指令3繼續執行計算EX.
同理指令4需要等指令3計算完, 才可以拿到R3, 因此也需要錯位等待.

再來看一個復雜的例子
a = b + c
d = e - f
具體指令執行步驟如圖, 不再贅述, 與上圖類似, 在執行過程中同樣會出現等待.

這邊框選的X統稱一個氣泡, 有沒有什么方案可以削減這類氣泡呢.
答案自然是可以的, 我們可以在出現氣泡之前, 執行其他不相干指令來減少氣泡.
例如可以將第五步的加載e到寄存器提前執行, 消除第一個氣泡,
同理將第六步的加載f到寄存器提前執行, 消除第二個氣泡.

經過指令重排后, 整個流水線會更加順暢, 無氣泡阻塞執行.

原先需要14個時鍾周期的指令, 重排后, 只需要12個時鍾周期即可執行完畢.
指令重排只可能發生在毫無關系的指令之間, 如果指令之間存在依賴關系, 則不會重排.
如 指令1 : a = 1 指令2: b = a - 1, 則指令1, 2 不會發生重排.
編譯器優化
主要指jvm層面的, 如下代碼, 在jvm client模式很快就跳出了while循環, 而在server模式下運行, 永遠不會停止.
1 /** 2 * Created by Administrator on 2018/5/3/0003. 3 */ 4 public class VisibilityTest extends Thread { 5 private boolean stop; 6 7 public void run() { 8 int i = 0; 9 while (!stop) { 10 i++; 11 } 12 System.out.println("finish loop,i=" + i); 13 } 14 15 public void stopIt() { 16 stop = true; 17 } 18 19 public boolean getStop() { 20 return stop; 21 } 22 23 public static void main(String[] args) throws Exception { 24 VisibilityTest v = new VisibilityTest(); 25 v.start(); 26 Thread.sleep(1000); 27 v.stopIt(); 28 Thread.sleep(2000); 29 System.out.println("finish main"); 30 System.out.println(v.getStop()); 31 } 32 }
以32位jdk1.7.0_55為例, 我們可以通過修改JAVA_HOME/jre/lib/i386/jvm.cfg, 將jvm調整為server模式驗證下.
修改內容如下圖所示, 將-server調整到-client的上面.
-server KNOWN
-client KNOWN
-hotspot ALIASED_TO -client
-classic WARN
-native ERROR
-green ERROR
修改成功后, java -version會產生如圖變化.

兩者區別在於當jvm運行在-client模式的時候,使用的是一個代號為C1的輕量級編譯器,
而-server模式啟動的虛擬機采用相對重量級,代號為C2的編譯器. C2比C1編譯器編譯的相對徹底,
會導致程序啟動慢, 但服務起來之后, 性能更高, 同時有可能帶來可見性問題.
我們將上述代碼運行的匯編代碼打印出來, 打印方法也簡單提一下.
給主類運行時加上VM Options, -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
此時會提示Could not load hsdis-i386.dll; library not loadable; PrintAssembly is disabled
因為打印匯編需要給jdk安裝一個插件, 可能需要自己編譯hsdis, 不同平台不太一樣,
Windows下32位jdk需要的是hsdis-i386.dll, 64位jdk需要hsdis-amd64.dll.
我們把編譯好的hsdis-i386.dll放到JAVA_HOME/jre/bin/server以及JAVA_HOME/jre/bin/client目錄中.
運行代碼, 控制台會把代碼對應的匯編指令一起打印出來. 會有很多行, 我們只需要搜索run方法對應的匯編.
搜索 'run' '()V' in 'VisibilityTest', 可以找到對應的指令.
如下代碼所示, 從紅字注釋的部分可以看出來,
只有第一次進入循環之前, 檢查了下stop的值, 不滿足條件進入循環后,
再也沒有檢查stop, 一直在做循環i++.
1 public void run() { 2 int i = 0; 3 while (!stop) { 4 i++; 5 } 6 System.out.println("finish loop,i=" + i); 7 } 8 9 10 # {method} 'run' '()V' in 'VisibilityTest' 11 ...... 12 0x02d486e9: jne 0x02d48715 13 // 獲取stop的值 14 0x02d486eb: movzbl 0x64(%ebp),%ecx ; implicit exception: dispatches to 0x02d48703 15 0x02d486ef: test %ecx,%ecx 16 // 進入while之前, 若stop滿足條件, 則跳轉到0x02d48703, 不執行while循環 17 0x02d486f1: jne 0x02d48703 ;*goto 18 ; - VisibilityTest::run@12 (line 10) 19 // 循環體內, i++ 20 0x02d486f3: inc %edi ; OopMap{ebp=Oop off=52} 21 ;*goto 22 ; - VisibilityTest::run@12 (line 10) 23 0x02d486f4: test %edi,0xe00000 ;*goto 24 ; - VisibilityTest::run@12 (line 10) 25 ; {poll} 26 // jmp, 無條件跳轉到0x02d486f3, 一直執行i++操作, 根本不檢查stop的值 27 // 導致死循環 28 0x02d486fa: jmp 0x02d486f3 29 0x02d486fc: mov $0x0,%ebp 30 0x02d48701: jmp 0x02d486eb 31 // 跳出循環 32 0x02d48703: mov $0xffffff86,%ecx 33 ......
解決方案也很簡單, 只要給stop加上volatile關鍵字, 再次打印匯編代碼, 發現他每次都會檢查stop的值.
就不會出現無限循環了.
1 // 給stop加上volatile后 2 public void run() { 3 int i = 0; 4 while (!stop) { 5 i++; 6 } 7 System.out.println("finish loop,i=" + i); 8 } 9 10 # {method} 'run' '()V' in 'VisibilityTest' 11 ...... 12 0x02b4895c: mov 0x4(%ebp),%ecx ; implicit exception: dispatches to 0x02b4899d 13 0x02b4895f: cmp $0x5dd5238,%ecx ; {oop('VisibilityTest')} 14 // 進入while判斷 15 0x02b48965: jne 0x02b4898d ;*aload_0 16 ; - VisibilityTest::run@2 (line 9) 17 // 跳轉到0x02b48977獲取stop 18 0x02b48967: jmp 0x02b48977 19 0x02b48969: nopl 0x0(%eax)
// 循環體內, i++ 20 0x02b48970: inc %ebx ; OopMap{ebp=Oop off=49} 21 ;*goto 22 ; - VisibilityTest::run@12 (line 10) 23 0x02b48971: test %edi,0xb30000 ;*aload_0 24 ; - VisibilityTest::run@2 (line 9) 25 ; {poll} 26 // 循環過程中獲取stop的值 27 0x02b48977: movzbl 0x64(%ebp),%eax ;*getfield stop 28 ; - VisibilityTest::run@3 (line 9) 29 // 驗證stop的值 30 0x02b4897b: test %eax,%eax 31 // 若stop不符合條件, 則繼續跳轉到0x02b48970: inc, 執行i++, 否則中斷循環 32 0x02b4897d: je 0x02b48970 ;*ifne 33 ; - VisibilityTest::run@6 (line 9) 34 0x02b4897f: mov $0x33,%ecx 35 0x02b48984: mov %ebx,%ebp 36 0x02b48986: nop 37 // 跳出循環, 執行System.out.print打印 38 0x02b48987: call 0x02b2cac0 ; OopMap{off=76} 39 ;*getstatic out 40 ; - VisibilityTest::run@15 (line 12) 41 ; {runtime_call} 42 0x02b4898c: int3 44 0x02b4898d: mov $0xffffff9d,%ecx 45 ......
再來看兩個從Java語言規范中摘取的例子, 也是涉及到編譯器優化重排, 這里不再做詳細解釋, 只說下結果.
例子1中有可能出現r2 = 2 並且 r1 = 1;

例子2中是r2, r5值因為都是=r1.x, 編譯器會使用向前替換, 把r5指向到r2, 最終可能導致r2=r5=0, r4 = 3;

Happen-Before先行發生規則
如果光靠sychronized和volatile來保證程序執行過程中的原子性, 有序性, 可見性, 那么代碼將會變得異常繁瑣.
JMM提供了Happen-Before規則來約束數據之間是否存在競爭, 線程環境是否安全, 具體如下:
順序原則
一個線程內保證語義的串行性; a = 1; b = a + 1;
volatile規則
volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,
鎖規則
解鎖(unlock)必然發生在隨后的加鎖(lock)前.
傳遞性
A先於B,B先於C,那么A必然先於C.
線程啟動, 中斷, 終止
線程的start()方法先於它的每一個動作.
線程的中斷(interrupt())先於被中斷線程的代碼.
線程的所有操作先於線程的終結(Thread.join()).
對象終結
對象的構造函數執行結束先於finalize()方法.
