Java內存模型與指令重排


本文暫不講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()方法.

 


免責聲明!

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



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