關于JVM和OS中的指令重排以及JIT優化
前言:
這東西應該很重要才對,可是大多數博客都是以訛傳訛,全是錯誤,尤其是JVM會對字節碼進行重排都出來了,明明自己測一測就出來的東西,寫出來誤人子弟…
研究了兩天,算是有點名堂了,只是不能看到到CPU的重排過程有點可惜
紙上得來終覺淺,建議手動截一下字節碼以及匯編自己研究一下,肯定會有不一樣的收獲
關于JMM和JIT可以嘗試看一下油管Jakob Jenkov的教程,很不錯!
? 通俗易懂的說,指令重排是為了最大化執行效率,會在保證語意不變的情況下,調整代碼的順序。
而JIT會修改優化代碼中的熱點部分,使其效率大幅提升
OS中的指令重排:
比如:
a = b + c;
b = a + c;
d = e + f;
e = d + f;
這段代碼可能會被調整為:
a = b + c;
d = e + f;
b = a + c;
e = d + f;
但是肯定不會調整為:
b = a + c;
a = b + c;
d = e + f;
e = d + f;
因為這樣改變了代碼語意
具體會調成什么樣取決于JVM和OS
實際上來說,指令重排并不是以一行java代碼為單位進行的,也就是說,我舉的例子并不恰當
一行代碼是由多句指令構成的,比如一個簡單的Java程序:
public class test {public static void main(String[] args) {int a = 1;int b = 2;int c = a + b;}
}
其轉化為字節碼:
public class test {// 構造函數的聲明public <init>()V // 這是無參構造方法,返回類型為 voidL0LINENUMBER 1 L0 // 表示該字節碼位置對應源代碼的第 1 行ALOAD 0 // 將當前對象(this)加載到棧上。這里的 0 表示加載 this(當前對象)。INVOKESPECIAL java/lang/Object.<init> ()V // 調用父類 Object 的構造方法(<init>),構造函數是無參的RETURN // 從構造方法中返回L1LOCALVARIABLE this Ltest; L0 L1 0 // 在字節碼中定義了一個局部變量 'this',類型是 test,對應的范圍是 L0 到 L1,局部變量索引為 0MAXSTACK = 1 // 最大棧深度為 1MAXLOCALS = 1 // 最大局部變量數為 1// main 方法的聲明public static main([Ljava/lang/String;)V // main 方法,接受字符串數組作為參數,返回類型為 voidL0LINENUMBER 3 L0 // 表示該字節碼位置對應源代碼的第 3 行ICONST_1 // 將常量 1 壓入棧中ISTORE 1 // 將棧頂的值(1)存入局部變量 1 中L1LINENUMBER 4 L1 // 表示該字節碼位置對應源代碼的第 4 行ICONST_2 // 將常量 2 壓入棧中ISTORE 2 // 將棧頂的值(2)存入局部變量 2 中L2LINENUMBER 5 L2 // 表示該字節碼位置對應源代碼的第 5 行ILOAD 1 // 將局部變量 1 的值(即 1)加載到棧上ILOAD 2 // 將局部變量 2 的值(即 2)加載到棧上IADD // 將棧頂的兩個整數相加(1 + 2 = 3)ISTORE 3 // 將結果(3)存入局部變量 3 中L3LINENUMBER 6 L3 // 表示該字節碼位置對應源代碼的第 6 行RETURN // 返回,從 main 方法中返回L4LOCALVARIABLE args [Ljava/lang/String; L0 L4 0 // 定義了局部變量 args,類型為 String[],范圍是 L0 到 L4LOCALVARIABLE a I L1 L4 1 // 定義了局部變量 a,類型為 int,范圍是 L1 到 L4LOCALVARIABLE b I L2 L4 2 // 定義了局部變量 b,類型為 int,范圍是 L2 到 L4LOCALVARIABLE c I L3 L4 3 // 定義了局部變量 c,類型為 int,范圍是 L3 到 L4MAXSTACK = 2 // 最大棧深度為 2MAXLOCALS = 4 // 最大局部變量數為 4
}
可以看到,轉換成字節碼多出了很多操作
其實字節碼轉成機器碼/匯編時還會接著細分,為了演示就不再向下分析了
那么轉換成字節碼后我們會發現什么?
一個簡單的 **int a = 1;**被轉化成了
LINENUMBER 3 L0 // 表示該字節碼位置對應源代碼的第 3 行ICONST_1 // 將常量 1 壓入棧中ISTORE 1 // 將棧頂的值(1)存入局部變量 1 中LOCALVARIABLE a I L1 L4 1 // 定義了局部變量 a,類型為 int,范圍是 L1 到 L4
展示的目的在于,每一行代碼把其溯源到底層的機器碼,都是由一系列操作組成的
一般可以分為:
- 取指(IF):從存儲器中讀取指令,并將指令送入指令寄存器IR;同時更新程序計數器PC,指向下一條指令的地址。
- 譯碼(ID):對IR中的指令進行譯碼,確定操作碼、操作數和功能;同時從寄存器文件中讀取源操作數,并放入臨時寄存器A和B中;如果有立即數,還要進行符號擴展,并放入臨時寄存器Imm中。
- 執行(EX):根據操作碼和功能,對A、B或Imm中的操作數進行算術或邏輯運算,并將結果放入臨時寄存器ALUOutput中;或者根據操作碼和功能,對A和Imm中的操作數進行有效地址計算,并將結果放入臨時寄存器ALUOutput中。
- 訪存(MEM):如果是加載指令,從存儲器中讀取數據,并放入臨時寄存器LMD中;如果是存儲指令,從B中讀取數據,并寫入存儲器中;如果是分支指令,根據條件判斷是否跳轉,并更新PC。
- 寫回(WB):如果是運算指令或加載指令,將ALUOutput或LMD中的結果寫回目標寄存器;如果是其他類型的指令,則不進行寫回操作。
(這些操作在上述字節碼不太能看出來,因為這是針對匯編/機器碼而設計的)
知道指令是由這么一個順序來的了,那這和指令重排有什么關系呢?
你或許會發現,或許我們可以不用從上到下執行完所有指令,而是挑一些可以并發一起執行?
比如我們可以在執行一條指令的IF時還能執行別的指令的ID?
但有人不就會問CPU不就單核怎么做到?
CPU是單核,但每條指令用到的單元不一樣啊,取指用PC寄存器,計算時就用ALU,互不干擾!
所以我們完全可以調整這些指令的執行順序來做到最大化效率
而這種技術稱之為指令流水線
而擁有像上面五層指令執行類別的CPU的流水線稱之為五層流水線
這張圖展示的處理器就能同時執行五條指令,原理就是充分利用了CPU中的其他單元,形成了一種“偽并行”
能夠“預測”到后面的指令,并能找到可以提前用空閑的處理單元處理的指令提取執行
這樣對計算機的提升非常大,以至于有CPU擁有1000多層的流水線
那CPU是怎么知道該怎么樣找到可以并發的指令?
是通過分析“數據依賴”來發現那些可以并行運行
那既然存在數據依賴,那就一定會存在一種情況:下個指令必須用到上個指令的結果,且沒有其他指令能插進來
那這樣就會產生氣泡,也稱為**“打嗝”**:
一旦產生了氣泡,會讓后續操作周期延誤,所以,為了維持流水線的高效率,CPU會盡力去進行指令重排來填補氣泡
讓能并發執行(互不干擾)的指令提前執行來填補氣泡,避免延誤執行周期
那么古爾丹,代價是什么呢?
盡管指令重組能保證語義不變,但不能保證在高并發條件下不會出錯!
畢竟提前和延后修改共享變量都可能會引起不可預測的錯誤!
所以會采用synchronized 或 volatile 來針對性的避免這種情況
JVM中的指令重排與優化:
? JVM中的指令重排和優化是發生在JIT編譯階段,而不是翻譯成字節碼階段,網上很多博客都說錯了!
? 仔細想一下也很正常,指令重排針對的是匯編機器碼層面的操作,字節碼根本接觸不到
? 想要驗證很簡單,你找個程序,挑個能體現指令重組的程序,比較一下加了volatile和不加volatile的字節碼,你會發現除了那個加了volatile的變量之外根本沒區別
? 而且Java是解釋+編譯,一般情況下是由JVM一句一句照著字節碼翻譯成機器碼走一步看一步,遇到有循環,執行多次的代碼塊就會用JIT對其進行編譯優化,下次執行就直接調用JIT編譯出來的機器碼
? 所以很容易理解JVM的指令重排發生在JIT編譯階段
? 那JIT會干什么呢?
- JIT會根據JVM的不同(也就是底層的不同),適當的修改代碼,調整順序來迎合OS的流水線和指令重排。
- JIT也會給你寫的屎山做優化,優化一些不必要的操作
? 舉個例子:
package com.jitTest;public class test {static boolean noUse = true;public static void main(String[] args) {int cnt = 0;while(noUse){cnt++;if(cnt == 10000)break;}}
}
這里循環了10000次,肯定會觸發JIT的熱點代碼優化
我們先下一個JITWatch
使用教程參考JITWatch很折騰?有這篇文章在可以放心大多數情況下,通過諸如javap等反編譯工具來查看源碼的字節碼已經能夠滿足我 - 掘金
但是別照著它去自己編譯dll,可直接在atzhangsan/file_loaded下載,JITWatch要下載源碼手動編譯,不能下jar!
之后我們用JITWatch截取其字節碼和匯編代碼:
字節碼:
0: iconst_0 1: istore_1 2: getstatic #2 // Field noUse:Z5: ifeq 21 8: iinc 1, 1
11: iload_1
12: sipush 10000
15: if_icmpne 2
18: goto 21
21: return
可以發現根本沒有優化掉noUse變量,這也證明之前的“代碼重排發生在JIT編譯而不是JVM編譯成字節碼”
接下來看匯編部分:
# {method} {0x000001a4d3fd44f0} 'main' '([Ljava/lang/String;)V' in 'com/jitTest/test'
# parm0: rdx:rdx = '[Ljava/lang/String;'
# [sp+0x20] (sp of caller)
[Entry Point]
0x000001a4b23e6140: sub $0x18,%rsp
0x000001a4b23e6147: mov %rbp,0x10(%rsp) ;*synchronization entry; - com.jitTest.test::main@-1 (line 6)
0x000001a4b23e614c: add $0x10,%rsp
0x000001a4b23e6150: pop %rbp
0x000001a4b23e6151: test %eax,-0x1e36157(%rip) # 0x000001a4b05b0000; {poll_return} *** SAFEPOINT POLL ***
0x000001a4b23e6157: retq
0x000001a4b23e6158: hlt
0x000001a4b23e6159: hlt
0x000001a4b23e615a: hlt
0x000001a4b23e615b: hlt
0x000001a4b23e615c: hlt
0x000001a4b23e615d: hlt
0x000001a4b23e615e: hlt
0x000001a4b23e615f: hlt
[Exception Handler]
[Stub Code]
0x000001a4b23e6160: jmpq 0x000001a4b2264620 ; {no_reloc}
[Deopt Handler Code]
0x000001a4b23e6165: callq 0x000001a4b23e616a
0x000001a4b23e616a: subq $0x5,(%rsp)
0x000001a4b23e616f: jmpq 0x000001a4b2006f40 ; {runtime_call}
0x000001a4b23e6174: hlt
0x000001a4b23e6175: hlt
0x000001a4b23e6176: hlt
0x000001a4b23e6177: hlt
其中的:
[Entry Point]
0x000001a4b23e6140: sub $0x18,%rsp
0x000001a4b23e6147: mov %rbp,0x10(%rsp) ;*synchronization entry; - com.jitTest.test::main@-1 (line 6)
0x000001a4b23e614c: add $0x10,%rsp
0x000001a4b23e6150: pop %rbp
0x000001a4b23e6151: test %eax,-0x1e36157(%rip) # 0x000001a4b05b0000; {poll_return} *** SAFEPOINT POLL ***
0x000001a4b23e6157: retq
便是函數部分,我們可以看到,其中唯一的比較函數就是 0x000001a4b23e6151: test %eax,-0x1e36157(%rip) ,代表著比較cnt是否到了10000,根本沒有看見判斷noUse變量是否為真
說明JIT編譯時就已經發現noUse變量很no use,就將其刪去了
對于指令重排,其實不太好測出來,復雜程序的匯編你看不出來,簡單程序的匯編又被JIT優化后因為太簡單就會按順序執行
而且具體重組的方法是由你的底層決定,大頭也是CPU的指令重排,JIT也是打個下手
但由此我們完全可以看出JIT可以對代碼進行修改優化和重構來提升效率
JIT完全是Java的大爹
總結:
? OS中的指令重排極大的提升了CPU性能,但也帶來了并發風險
? JVM中的JIT會在字節碼轉機器碼時對代碼進行優化修改以及重排,極大的提升了Java的速度,使其與編譯執行語言速度相媲美
? JIT太猛了…寫的一個一百多行的測試屎山給優化到只有十幾行…