1. C1編譯器線程
C1編譯器(aka Client Compiler)的代碼位於hotspot\share\c1
。C1編譯線程(C1 CompilerThread)會阻塞在任務隊列,當發現隊列有編譯任務即可CompileTask的時候,線程喚醒然后調用CompilerBroker,CompilerBroker再進一步選擇合適編譯器,以此進入JIT編譯器的世界。
有一個取巧的辦法可以得到詳細的工作流程:C1編譯器會對每個階段做性能計時,這個計時取名就是階段名字,所以我們可以通過計時看看詳細步驟:
//hotspot\share\c1\c1_Compilation.cpp
typedef enum {
_t_compile, // C1編譯
_t_setup, // 1)設置C1編譯環境
_t_buildIR, // 2)構造HIR
_t_hir_parse, // 從字節碼生成HIR
_t_gvn, // GVN優化
_t_optimize_blocks, // 基本塊優化
_t_optimize_null_checks, // null檢查優化消除
_t_rangeCheckElimination, // 數組范圍檢查消除
_t_emit_lir, // 3)構造LIR
_t_linearScan, // 線性掃描寄存器分配
_t_lirGeneration, // 生成LIR
_t_codeemit, // 機器代碼生成
_t_codeinstall, // 替換解釋執行的代碼為機器代碼(nmethod)
max_phase_timers
} TimerName;
這里我們主要關心第二三階段。它們位於c1/compilation
模塊。從CompilerBroker到該階段的調用棧如下:
CompileBroker::invoke_compiler_on_method()
-> Compiler::compile_method()
-> Compilation::Compilation()
-> Compilation::compile_method()
-> Compilation::compile_java_method()
在compile_java_method()方法中完成了C1編譯最主要的流程:
int Compilation::compile_java_method() {
...
// 構造HIR
{
PhaseTraceTime timeit(_t_buildIR);
build_hir();
}
// 構造LIR
{
PhaseTraceTime timeit(_t_emit_lir);
_frame_map = new FrameMap(method(), hir()->number_of_locks(), MAX2(4, hir()->max_stack()));
emit_lir();
}
// 生成機器代碼
{
PhaseTraceTime timeit(_t_codeemit);
return emit_code_body();
}
}
二三階段將Java字節碼轉換為各種形式的中間表示(HIR,LIR),然后在其上做代碼優化和機器代碼生成,這個機器代碼就是C1 JIT產出的東西。可以看出,溝通Java字節碼和JIT產出的機器代碼之前的橋梁就是中間表示,C1的大部分工作也是針對中間表示做各種變換,要明白C1的工作得先說說什么是中間表示。
2. 中間表示簡介
C1編譯器(Client Compiler)將字節碼轉化為SSA-based HIR(Single Static Assignment based High-Level Intermediate Representation)。HIR即高級中間表示,我們用Java寫代碼,編譯得到字節碼。但即便是字節碼對於編譯器優化來說也不太理想,編譯器會使用一種更適合優化的形式來表征字節碼。這個更適合優化的形式就是HIR,HIR又有很多,有些是圖的形式,有些是線性的形式,
HotSpot C1使用的HIR是一種基於SSA(靜態單賦值形式)的圖IR,它由基本塊(Basic Block)構成一個控制流圖結構(Control Flow Graph),基本塊里面是SSA,直觀表示如下:
圖片來源於Java HotSpot™ Client Compiler - Compiler Design Lab。左邊是Java字節碼,右邊是字節碼在C1中的HIR表示,右邊的塊狀結構就是基本塊,基本塊里面即SSA。
這句話里面名詞有點多,我們試着來解釋一下。基本塊是指單一入口,單一出口的塊,塊中沒有跳轉代碼。對照圖片看,4 5 6
行代碼是一個基本塊,中間沒有跳轉,由4進入塊,出口6的if_icmpgt轉移到另一個基本塊。這樣的構型可以高效的做控制流分析,是編譯優化常用的一種IR。CFG即控制流圖,是由基本塊構成的圖結構。那么SSA又是什么呢?SSA表示每次變量的賦值都會創建新的且獨一無二的名稱,舉個例子:
// 源碼
int foo(){
int a = 6;
a = 8;
return a;
// SSA表示
foo:
a1 = 6
a2 = 8
return a2
這段代碼里a最開始的賦值是多余的,如果用SSA表示這段代碼,編譯器很容易發現a1這個值在foo塊中沒有使用,直接優化為:
foo:
a2 = 8
return a2
SSA還有很多好處,這里只是一個小的方面,有興趣的可以閱讀編譯理論方面的書籍,入門推薦《編譯器設計》和《編譯原理》。
回到主題,我們常說的C1編譯器優化大部分都是在HIR之上完成的。當優化完成之后它會將HIR轉化為LIR(Low-Level Intermediate Representation),LIR又是一種編譯器內部用到的表示,這種表示消除了HIR中的PHI節點,然后使用LSRA(Linear Scan Register Allocation,線性寄存器分配算法)將虛擬寄存器映射到物理寄存器,最后再將LIR轉化為CPU相關的機器碼,完成JIT工作。
3. 構造HIR
3.1 HIR與優化
build_hir()不僅會構造出HIR,還會執行很多平台無關的代碼優化。代碼優化不用多講,JVM給我們帶來性能上的信心很大程度上都源於此,這是評判JIT編譯器的重要指標,也是編譯器后端的主要任務。
void Compilation::build_hir() {
CHECK_BAILOUT();
// 創建HIR
CompileLog* log = this->log();
if (log != NULL) {
log->begin_head("parse method='%d' ",
log->identify(_method));
log->stamp();
log->end_head();
}
{
PhaseTraceTime timeit(_t_hir_parse);
_hir = new IR(this, method(), osr_bci());
}
if (log) log->done("parse");
if (!_hir->is_valid()) {
bailout("invalid parsing");
return;
}
// 驗證HIR
_hir->verify();
// 優化:條件表達式消除,基本塊消除
if (UseC1Optimizations) {
NEEDS_CLEANUP
PhaseTraceTime timeit(_t_optimize_blocks);
_hir->optimize_blocks();
}
_hir->verify();
_hir->split_critical_edges();
_hir->verify();
_hir->compute_code();
// 優化:全局值編號優化
if (UseGlobalValueNumbering) {
// No resource mark here! LoopInvariantCodeMotion can allocate ValueStack objects.
PhaseTraceTime timeit(_t_gvn);
int instructions = Instruction::number_of_instructions();
GlobalValueNumbering gvn(_hir);
}
_hir->verify();
// 優化:范圍檢查消除
if (RangeCheckElimination) {
if (_hir->osr_entry() == NULL) {
PhaseTraceTime timeit(_t_rangeCheckElimination);
RangeCheckElimination::eliminate(_hir);
}
}
// 優化:null檢查消除
if (UseC1Optimizations) {
NEEDS_CLEANUP
PhaseTraceTime timeit(_t_optimize_null_checks);
_hir->eliminate_null_checks();
}
_hir->verify();
_hir->compute_use_counts();
_hir->verify();
}
build_hir()第一階段解析字節碼生成HIR;之后會檢查HIR是否有效,如果無效會發生Compilation Bailout,即編譯脫離,這個詞在JIT編譯器中經常出現,它指的是當編譯器在編譯過程中遇到一些很難處理的情況,或者一些極特殊情況時會停止編譯,然后回退到解釋器。當對HIR的檢查通過后,C1對其進行條件表達式消除,基本塊消除;接着使用全局值編號(GVN,Global Value Numbering);后再消除一些數組范圍檢查(Range Check Elimination);最后做NULL檢查消除。另外要注意的是,如果開啟了分層編譯(TieredCompilation),那么條件表達式消除和基本塊消除只會發生在Tier1,Tier2層。
3.2 查看各階段的HIR
如果JVM是fastdebug
版,加上-XX:+PrintIR
參數可以輸出每一個步驟的HIR:
(我使用的完整參數是:-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:CompileCommand=compileonly,com.github.kelthuzadx.HelloWorld::jerkSum -Xcomp -XX:TieredStopAtLevel=1 -XX:+PrintIR -XX:+PauseAtExit
)
17669 28 b 1 com.github.kelthuzadx.HelloWorld::jerkSum (23 bytes)
IR after parsing
B4 [0, 0] -> B0 sux: B0
empty stack
inlining depth 0
__bci__use__tid____instr____________________________________
. 0 0 17 std entry B0
B0 (SV) [0, 4] -> B1 sux: B1 pred: B4
empty stack
inlining depth 0
__bci__use__tid____instr____________________________________
2 0 i5 0
. 4 0 6 goto B1
B1 (LHV) [4, 8] -> B3 B2 sux: B3 B2 pred: B0 B2
Locals:
1 i7 [ i4 i11]
2 i8 [ i5 i13]
empty stack
inlining depth 0
__bci__use__tid____instr____________________________________
5 0 i9 10000
. 8 0 10 if i8 >= i9 then B3 else B2
B2 (V) [11, 18] -> B1 sux: B1 pred: B1
empty stack
inlining depth 0
__bci__use__tid____instr____________________________________
13 0 i11 i7 + i8
15 0 i12 1
15 0 i13 i8 + i12
. 18 0 14 goto B1 (safepoint)
B3 (V) [21, 22] pred: B1
empty stack
inlining depth 0
__bci__use__tid____instr____________________________________
. 22 0 i15 ireturn i7
....
另外加上-XX:+PrintCFGToFile
標志在字節碼文件同目錄下得到一個output_xx.cfg
文件,里面的begin_block
和end_block
表示一個基本塊;predecessors
是當前塊的前驅塊;successors
是后繼塊,這兩個屬性用於控制流的轉移,以此構成CFG(Control Flow Graph);from_bci
表示當前基本塊對應的字節碼起始偏移,to_bci
表示對應的終止偏移;flags表示一些屬性,比如該塊是標准入口,還是OSR(On-Stack Replacement)入口,還是異常入口等;xhandlers表示異常處理器。
使用c1 visualizer還可以對它進行可視化:
4. 構造LIR
C2使用圖着色算法做寄存器分配;C1使用相對簡單的線性掃描寄存器分配算法將虛擬寄存器映射到具體機器架構的物理寄存器上。更多LSRA內容請參見論文Linear Scan Register Allocation for the Java HotSpot™ Client Compiler
HotSpot對LIR也有很多虛擬機標志,都位於hotspot\share\c1\c1_global.hpp
。比如-XX:+PrintLIR
可以得到產出的LIR,-XX:+PrintLIRWithAssembly
可以得到LIR對應的匯編表示。說了這么多,不如來實戰一下。我們准備了一段Java代碼:
package com.github.kelthuzadx;
public class HelloWorld {
public static int jerkSum(int start){
int total = start;
for(int i=0;i<10000;i++){
total+=i;
}
return total;
}
public static void main(String[] args) {
System.out.println(jerkSum(1024));
}
}
然后得到了對應的C1產出(-XX:+PrintLIRWithAssembly
):
jerkSum:
;函數開始
0x000001999a7109a0: mov %eax,-0x9000(%rsp)
0x000001999a7109a7: push %rbp
0x000001999a7109a8: sub $0x30,%rsp
;int i = 0
0x000001999a7109ac: mov $0x0,%eax
0x000001999a7109b1: jmpq 0x000001999a7109b6
;循環開始
0x000001999a7109b6: nop
0x000001999a7109b7: nop
; total += i
0x000001999a7109b8: add %eax,%edx
; i++
0x000001999a7109ba: inc %eax
; 循環結束處插入安全點
34 safepoint [bci:18]
0x000001999a7109bc: mov 0x120(%r15),%r10
0x000001999a7109c3: test %eax,(%r10)
; i<10000
0x000001999a7109c6: cmp $0x2710,%eax
; 小於就跳到循環開始,否則結束循環
0x000001999a7109cc: jl 0x000001999a7109b8
; total放入rax,作為返回值
0x000001999a7109ce: mov %rdx,%rax
; 函數返回
0x000001999a7109d1: add $0x30,%rsp
0x000001999a7109d5: pop %rbp
0x000001999a7109d6: mov 0x120(%r15),%r10
0x000001999a7109dd: test %eax,(%r10)
0x000001999a7109e0: retq
C1在一次循環結束(B2基本塊)插入了一個安全點(Safepoint),也就是說每次循環結束都有機會進行垃圾回收,這樣是有意義的:試想一個死循環里面一直new Object(),如果在循環體外面插入安全點,那么GC根本得不到執行就會內存溢出,所以必須在每次循環結束時插入安全點讓GC可執行,當然隨之帶來的還有每次循環多執行幾條指令的性能懲罰,說JVM略慢不是沒有理由的...然后這個例子C1沒有做其他優化。