vlambda博客
学习文章列表

这次我们来学习深入解析java虚拟机:C2编译器,编译流程吧


C2编译器

C2编译器即Op to Compiler,又叫Server Compiler,它的定位与C1相反:C1面向客户端程序,需要快速响应用户请求;C2面向长期运行的服务端程序,它允许在编译上花更多时间,以此换取程序峰值执行性能。本章将详细讨论大名鼎鼎的C2编译器(后面简称C2)。

编译流程

本节从源码出发,简单介绍C2的中间表示和编译流程。后续小节将详细描述这些过程。

进入C2

当解释器发现热点方法时会调用
CompilerBroker::comple_method()向编译任务队列投递一个编译任务(CompileTask),然后C2编译器线程会在发现任务队列有编译任务时唤醒,拉取编译任务并进入JIT编译器。目光转向C2编译线程(C2 CompilerThread),它最开始阻塞在编译任务队列,在发现编译任务后唤醒,接着经过如代码清单9-1所示的调用链后开始编译:

代码清单9-1 C2调用链

JavaThread::thread_main_entry()
-> compiler_thread_entry()
-> CompilerBroker::compiler_thread_loop()
-> CompileBroker::invoke_compiler_on_method() // 使用C2
-> C2Compiler::compile_method() // 进入C2世界
-> Compile::Compile() // 代码编译

C2的完整编译周期等价于Compile对象的生命周期。读者可能发现这个过程和C1几乎一样,因为虚拟机创建编译任务时已经设置了该任务用哪个编译器编译,这时的
CompileBroker::invoke_compiler_on_method只需根据编译任务中指定的编译器进行编译即可。

上一章提到,C1是在Compilation对象构造中完成编译,类似的,C2也在Compile对象构造过程中完成编译,只是它更为复杂。C2编译器也会对编译过程中的每个小阶段做性能计时,根据编译器阶段计时同样可以得到完整的C2编译过程,如代码清单9-2所示:

代码清单9-2 C2编译详细流程

enum PhaseTraceId {
_t_parser, // 1. 字节码解析与理想图生成
_t_optimizer, // 2. 机器无关优化
...
_t_matcher, // 3. 指令选择
_t_scheduler, // 4. 指令调度和全局代码提出
_t_registerAllocation, // 5. 寄存器分配
...
_t_blockOrdering, // 6. 移除空基本块
_t_peephole, // 7. 窥孔优化
_t_postalloc_expand,
_t_output, // 8. 生成机器代码
...
_t_registerMethod, // 9. 用编译生成的方法代替Java方法
_t_tec,
max_phase_timers
};

总地来说,C2的编译流程如下:解析字节码,构造理想图机器无关优化代码生成(指令选择、全局代码提出、指令调度、寄存器分配、窥孔优化、生成机器代码)设置编译代码。

常言道,纸上得来终觉浅,要知此事须躬行。由于C2代码量大,且构造复杂,本书不试图面面俱到地讨论上述过程的所有细节,而是希望简单讨论其中几个部分,为读者的躬行做前景提要,扮演一个源码阅读索引的角色。

理想图

几乎所有优化编译器后端的第一步都是生成IR。C1的第一步是解析字节码生成基于静态单赋值的HIR,C2的第一步也不例外,它解析字节码生成理想图(Ideal Graph)。理想图有很多叫法,如节点海(Sea ofNode)、程序依赖图(Program Dependence Graph)、受限静态单赋值(Gated Single Static Assignment)等。本书主要使用Ideal图和理想图两种叫法。

理想图是一个有向图,它由节点和边构成。节点表示程序的行为,如AddLNode节点表示对两个long数据做加法。节点的输入边是有序的,这意味着输入边的顺序具有明确的意义,比如节点的第一条输入边通常表示控制流。但是节点的输出边是无序的,对于输出多个值的节点,这样可能导致不知道哪条边表示哪个值的问题,本章后面将讨论这个问题。

理想图的边表示控制流和数据流,边的实现是一个指针,这使得边显式地包含了Use-Def信息(从使用值的节点指向可能定义值的节点),编译器分析和优化可以直接使用这些信息而不需要再次计算,当对理想图变形时也可以直接修改Use-Def信息而不需要先修改IR再计算Use-Def,如代码清单9-3所示:

代码清单9-3 简单方法

public static int justReturn(int x){
return x;
}

为了对理想图有一个直观的认识,可以试着可视化它。使用如下JVM参数:

-XX:-TieredCompilation:关闭分层编译只使用C2。

-XX:+PrintIdeal:输出Ideal日志。

-XX:PrintIdealGraphFile=ideal.xml:将理想图存储到ideal.xml文件。

-XX:PrintIdealGraphLevel=1:选择输出理想图的展示细节度(4最详细)。

-XX:CompileCommand=compileonly,*DummyMethod.justReturn:只编译justReturn。

使用idealgraphvisualizer打开生成的ideal.xml,会看到如图9-1所示的效果。


根据代码清单9-3的Java代码,直观上可能会觉得return节点只接收一个值x,然后输出一个值,但是理想图有6条输入边,因为一个函数除了参数和返回值外还可能产生副作用,比如可能修改I/O、修改内存,所以理想图会记录这些信息。

图9-1的Parm#5表示control输入,Parm#6表示I/O输入,Parm#7表示内存输入,Parm#8表示frame指针输入,Parm#9表示返回值,Parm#10才是函数的实参。control输入表示控制依赖。控制依赖是指某个计算expr1的执行依赖于带有控制流语意的expr0,如if语句,只有当expr0成立时才计算expr1。假如expr1=a+b,expr0表示if(cond),虽然expr1控制依赖expr0,但是它的计算其实不依赖expr0的结果,只要a和b计算完成就可以计算expr1,在这种情况下,只要a和b的计算没有副作用,它也可以表现出只具有数据依赖。理想图又叫节点海的原因是,它允许节点不受控制流约束固定在某个位置,而是可以浮动,且这些节点可以只依赖数据,不依赖控制流漂浮起来。

由于理想图过于庞大,即使如代码清单9-3一样简单的Java程序也可能膨胀出图9-1那样大量的节点,所以本章使用的所有理想图都是省略细节只留下必要信息的消减后的图,本章将使用“节点名#节点编号”的方式引用理想图中的某个具体的节点。

1. If节点和Projection节点

使用单层结构的理想图代替传统的基本块处理控制流(层1)域SSA指令处理数据流(层2),两层结构需要处理一些问题,分支跳转便是其一,如代码清单9-4所示:

代码清单9-4 分支跳转

public static int branchIf(int x){
if(x<1000) { return 12; } else { return 13+x; }
}

它的理想图如图9-2所示,简单的if判断也会产生较为复杂的理想图。

这次我们来学习深入解析java虚拟机:C2编译器,编译流程吧


节点通常会产出一条输出边,但是有些节点也会产生很多输出,比如If#25节点会输出表示成功的control值和失败的control值。这样就会出现问题:理想图的节点只有输入边是有序的,而输出边是无序的,无序的输出不能告诉后续节点哪条边是true,哪条是false。一个解决办法是让边附加一些信息,如加一个标签。具体到实现上,C2的做法是额外插入一个Projection节点来表示这些信息,此时Projection相当于标签。

在图9-2中,If#25节点接收一个control输入和一个predicate值,根据predicate值选择将control值传递到IfTrue分支或者IfFalse分支。IfTrue与IfFalse都属于Projection节点。Projection节点没有运行时开销,它只是简化了理想图的实现,使边不需要携带信息。

值得注意的是,理想图里面有个CallStaticJava函数,但是代码里面没有调用Java方法,这是C2插入的Uncommon trap。If节点用prob值表示分支为true的可能性,在上例中,根据运行时搜集到的信息,C2认为分支为true的可能性为0,除非x<1000会触发Uncommon trap,否则它会乐观地认为x的值大于等于1000。

2. Region节点和Phi节点

除了特殊的Projection节点外,还有代替基本块的Region节点和为SSA准备的Phi节点。如代码清单9-5所示:

代码清单9-5 “二选一

public static int phi(int x){
int result = 0;
if(x<12345){
int t = 12;
return t + result +1;
}else{
int q = result;
return q * 2;
}
}

它的理想图如图9-3所示。


传统的IR使用基本块构成有向图处理控制流,理想图使用Region节点代替基本块。Region节点可以接收多个control值的输入,然后产生一个合并后的control输出。其他普通节点可以接收一个control输入(通常是第一个输入),表示该节点属于哪个基本块。之前所见到的很多节点并没有control输入,这暗示control输入并不是必需的,移除control输入会使很多全局优化得以进行,但是也会造成一些额外麻烦或者致使优化不可能完成。

有了以上认识,回到图9-3,Region#13节点的第二个和第三个输入表示IfTrue传递的control值和IfFalse传递的control值,输出合并后的control值相当于从true和false选择一个。Region#13节点的第一个输入不是control值而是自己引用自己,所以图中没有边流入(这也佐证了节点的第一条control输入是可选的)。Region作为基本块的替代品可以处理控制流的合并,对于数据流的合并需要用到Phi节点。Phi#17节点的第一个输入是control,其他是数据输入,在图9-3中它根据Region节点输出的control选择一个合适的数据输入,如果是IfTrue则选择节点35,如果是IfFalse则选择节点22。

3. MergeMem节点

理想图将内存状态看作整体,对象字段读写操作实际上是对这个整体其中的一个指定内存切片进行操作,并可能产生新的内存切片。代码清单9-6即对象字段赋值的代码示例:

代码清单9-6 字段赋值

public class MemNode{
private static int a, b;public static void assign(int val1, int val2){
a = val1;
b = a + 1;
}
}


StoreI#25和StoreI#30都依赖control输入Parm#5,这说明控制流允许对它们进行重排序的,但是用a赋值产生的内存切片作为内存状态输入b的赋值就意味着a和b存在内存依赖,a的赋值必须先于b发生,即StoreI#25节点必须先于节点StoreI#30发生。Return#32节点需要合并所有内存切片作为输入,要做到这一点,可以添加MergeMem#16节点收集所有内存切片,然后再将单个内存状态输出到Return#32节点。

理想图流程概述

通过前文代码清单9-2的PhaseTraceId可以看到,C2编译始于将字节码转化为理想图,这一步发生于Parse::Parse(),如代码清单9-7所示:

代码清单9-7 字节码转化为理想图

Parse::Parse(...) {
...
// 导入ciTypeFlow分析的结果,ciTypeFlow会识别基本块,找出循环
init_blocks();
// 构建正常和异常退出节点
build_exits();
// 初始化JVM state map.
SafePointNode* entry_map = create_entry_map();
...
// 假装是通过一个jump指令跳转到方法的起始位置开始解析
Block* entry_block = start_block();
set_map_clone(entry_map);
merge_common(entry_block, entry_block->next_path_num());
// 遍历所有基本块的每一条字节码
do_all_blocks();
C->set_default_node_notes(caller_nn);
// 修正退出节点
set_map(entry_map);do_exits();
}

在解析阶段,init_blocks()会调用
ciMethod::get_flow_analysis()获取类型流数据,该方法会在第一次被调用的时候执行类型流。类型流分析由ci/ciTypeFlow完成,它会将字节码分块形成基本块(ciTypeFlow::df_flow_types)并识别循环(ciTypeFlow::Loop)。

接着C2调用do_all_blocks()逐个解析基本块中的Java字节码,并将字节码转化为理想图的节点。每当创建一个节点,C2会对这个节点应用PhaseGVN::transform做一些局部优化工作:第一个优化是Ideal,第二个优化是Identity,第三个优化是GVN(全局值编号)。

当一切完成后,C2会调用do_exits()修正退出节点。do_exists()主要处理final字段的问题。Java的final字段和普通字段的语意有很大不同,如代码清单9-8所示:

代码清单9-8 final字段与非final字段[1]

class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x; // 保证值为3
int j = f.y; // 值可能为0
}
}
}

x是final字段,虚拟机保证对象构造完毕时它的值一定是对其他线程可见的,但是在普通字段的对象引用不为null后,其他线程看到的可能是未构造完成的对象状态。所以当一个线程执行writer()并设置了f字段后,另一个线程很可能在调用reader()时看到y的初始值,而final字段x不存在这个问题。造成这种问题的根本原因是指令重排序,如代码清单9-9所示:

代码清单9-9 构造函数指令重排伪代码

// 正常代码
tmp = FinalFieldExample::FinalFieldExample();
tmp.x = 3;
tmp.y = 4;f = tmp;
// 指令重排序
tmp = FinalFieldExample::FinalFieldExample();
tmp.x = 3;
f = tmp;
mem_bar() // StoreStore屏障
tmp.y = 4;

上述解释和示例都是合乎逻辑的,在HotSpot VM中解释器的确会在final字段后插入mem_bar(),其效果类似代码清单9-9的指令重排序,但是这在C1和C2编译器中并不完全正确。C2关于final字段的处理如代码清单9-10所示:

代码清单9-10 do_exits()具体实现

void Parse::do_exits() {
...
// 如果构造函数存在一个final字段的赋值
// 或者PPC64架构上的构造函数存在一个volatile写(特殊处理)
// 或者开启了-XX:+AlwaysSafeConstructors,那么退出节点新增内存屏障
if (method()->is_initializer() &&
(wrote_final()||PPC64_ONLY(wrote_volatile()||)
(AlwaysSafeConstructors && wrote_fields()))) {
_exits.insert_mem_bar(Op_MemBarRelease, alloc_with_final());
...
}// 如果任何方法存在一个对@Stable字段的赋值操作,那么插入内存屏障
if (wrote_stable()) {
_exits.insert_mem_bar(Op_MemBarRelease);
...
}
...
}

如果构造函数中存在对final字段的赋值操作,或者启用了参数-XX:+AlwaysSafeConstructors,那么C2只会在退出节点插入内存屏障而非在final字段赋值之后的每个地方插入。这样做的最终效果如代码清单9-11所示:

代码清单9-11 C2构造函数指令重排序伪代码

// C2指令重排序
tmp = FinalFieldExample::FinalFieldExample();
tmp.y = 4;
tmp.x = 3;
f = tmp;
mem_bar() // StoreStore

屏障@Stable注解是JDK内部使用的注解,它修饰的字段和final字段行为类似,都只能赋值一次,且虚拟机都在方法退出节点插入了内存屏障。

对于一些行为确定的字段(如String的value)添加@Stable字段相当于告知虚拟机该字段是常量,这样可以使编译器发现更多的优化机会。

do_exits()只表示其中一个小细节,do_all_blocks()才是真正的理想图的构建过程,后面将详细描述。

C2代码优化

机器无关优化位于Compile::Optimize(),在这一步,编译器将对理想图做各种变换和优化操作,包括迭代式全局值编号(Iterative GlobalValue Numbering,IGVN)→内联→消除自动装箱→消除无用节点→逃逸分析(Escape Analysis)→系列循环转换(循环剥离(Loop Peeling)+循环展开(Loop Unrolling)+向量化(Vectorization)+循环预测(Loop Predication)+范围检查消除(Range Check Elimination,RCE))→条件常量传播(Conditional Constant Propagation,CCP)→IGVN→系列循环转换→宏展开(Macro Expand)→屏障展开→最终理想图变形。

迭代式全局值编号位于PhaseIterGVN,它有一个工作集,每次从工作集获取一个节点,然后对该节点反复应用Ideal优化,如果节点发生改变,那么再次加入工作集,如此反复,直到工作集没有节点,即没有节点可以再次优化时停止,这种算法又叫不动点迭代。IGVN是一个轻量级优化,因为后续优化可能会使节点变形,而节点又可能产生更多的优化机会,所以后续一些优化完成后会反复应用IGVN以发现更多优化机会。

在Java代码中的Integer i = 8会被javac编译器自动转换为Integer i =Integer.valueOf(8),这便是自动装箱(Auto-Boxing)。虚拟机实际上是执行了一个Integer.valueOf()调用,如果开启-XX:+EliminateAutoBox,那么C2编译器会尝试消除自动装箱调用。

参数-XX:+DoEscapeAnalysis的开启允许虚拟机执行逃逸分析。逃逸分析具体位于
ConnectionGraph::do_analysis,它是C2编译器能做的最复杂的分析。逃逸分析会建立一副连接图(Connection Graph)来表示对象和对象引用的可达性关系,然后通过对连接图的分析可以知晓对象是否逃逸出方法(是否可以在方法栈中分配)以及对象是否逃逸出线程(没有其他线程可以访问该对象),并根据分析结果进行标量替换和对象锁消除。

PhaseIdealLoop的主要步骤是识别循环、构造循环树结构、替换一些循环节点以及系列循环优化。在系列循环优化中,数组填充优化(
PhaseIdealLoop::do_intrinsify_fill)会尝试找出数组初始化模式然后将其替换为intrinsic桩代码;循环剥离(PhaseIdealLoop::do_peeling)会剥离出第一次循环;循环预测(PhaseIdealLoop::loop_predication_impl)会在循环前检查每次循环都检查的条件,失败则进入Uncommon trap,成功则进入循环;循环展开(PhaseIdealLoop::do_unroll)可以将循环全部或者部分展开,一个常见场景是将循环赋值展开成多个赋值语句。

循环展开一方面为后续优化(如strength reduce,instructionscheduling,vectorization)提供更大的优化窗口,减少循环依赖,提高指令级并行效率,但是另一方面会增大程序大小,使指令缓冲更容易填满,导致其他有用指令刷出,性能反而下降,所以不能无条件地使用循环展开。现代编译器GCC的-Ox优化选项默认关闭循环优化,使用时,需要-funroll-loops显式开启。C2的循环展开则通常是配合向量化一起进行。向量化会用SIMD指令代替数组初始化、数组赋值等操作,在C2中向量化的实现位于opto/superword,可以使用-XX:+UseSuperWord开启。条件常量传播位于PhaseCCP,它执行普通条件传播优化,同时发现if语句的条件为常量后可以消除if语句的死代码。

宏展开将数组备份、对象分配和加锁解锁等节点展开成一个优化版本的Fast/Slow形式,使得System.arraycopy、Arrays.copyOf等调用可以高效进行。

最终理想图变形是机器无关优化的最后一步,位于
Compile::final_graph_reshaping()。在这一步,编译器会尝试发现无限循环,由于在代码生成阶段很多算法不能很好地处理无限循环,所以在优化阶段发现它们后,C2编译器会执行编译逃离(CompilationBailout),而不再继续代码生成。编译逃离大概是所有术语中最容易理解的,它表示编译器在遇到了困难,例如待编译的方法过于复杂,或者编译器自身出现问题等无法继续编译的情况时可以拒绝编译。

代码生成流程

在应用了一系列优化后,理想图仍然还是机器相关的一种IR。代码生成阶段会将理想图转化为更加机器相关的形式,直到最终生成机器代码。代码生成通常是优化编译器的最后一步。C2在Compile::Code_Gen中实现了代码生成,如代码清单9-12所示:

代码清单9-12 Compile::Code_Gen

void Compile::Code_Gen() {
... /* 省略失败检查和日志记录 */
// 指令选择{
TracePhase tp("matcher", &timers[_t_matcher]);
matcher.match();
}
// 全局代码提出
PhaseCFG cfg(node_arena(), root(), matcher);
{
TracePhase tp("scheduler", &timers[_t_scheduler]);
cfg.do_global_code_motion();
}
// 寄存器分配
PhaseChaitin regalloc(unique(), cfg, matcher, false);
_regalloc = ®alloc;
{
TracePhase tp("regalloc", &timers[_t_registerAllocation]);
_regalloc->Register_Allocate();
}
...
// 窥孔优化
if( OptoPeephole ) {
TracePhase tp("peephole", &timers[_t_peephole]);
PhasePeephole peep( _regalloc, cfg);
peep.do_transform();
}
// 特殊节点展开
if (Matcher::require_postalloc_expand) {
TracePhase tp("postallocexpand",&timers[_t_postalloc_expand]);cfg.postalloc_expand(_regalloc);
}
// 生成机器代码
{
TraceTime tp("output", &timers[_t_output], CITime);
Output();
}
}

该阶段主要包括指令选择、全局代码提出、寄存器分配、窥孔优化。指令选择(Instruction Selection)使用BURS技术将机器无关指令翻译成机器相关指令。

 设置机器代码

C1和C2都遗留了一个未决问题:生成机器代码后如何替换原始的解释执行代码?这个问题可以通过调用如代码清单9-13所示的ciEnv::register_method()解决。ciEnv::register_method()不属于C2编译器编译范畴,但是对于虚拟机比较重要,毕竟,虚拟机使用即时编译器的目的是希望产出更高运行时性能的代码而不只是希望看到编译器的逻辑复杂和精湛构造。

代码清单9-13 设置机器代码

void ciEnv::register_method(...) {
{ ...// 创建nmethod
nm = nmethod::new_nmethod(...);
// 清空code buffer
code_buffer->free_blob();
if (nm != NULL) {
...
// 普通编译和OSR编译使用不同的方法设置机器代码
if (entry_bci == InvocationEntryBci) {
method->set_code(method, nm);
} else {
method->method_holder()->add_osr_nmethod(nm);
}
nm->make_in_use();
}
} ...
}

本文给大家讲解的内容是深入解析java虚拟机:C2编译器,编译流程

  1. 下篇文章给大家讲解的是深入解析java虚拟机:C2编译器,构造理想图;

  2. 觉得文章不错的朋友可以转发此文关注小编;

  3. 感谢大家的支持!