一般来说,Java代码会先被HotSpot VM解释执行,之后将统计出的热点代码通过即时编译器C1、C2或Graal编译成机器码,直接运行在底层硬件之上。解释器在之前的文章中已经介绍了不少,但是编译器还没有介绍,关于Java涉及到的编译器如下图所示。
前端编译器就是将遵循Java语言规范的Java源代码编译为遵循Java虚拟机规范的字节码,其中《深入解析Java编译器:源码剖析与实例详解》一书中已经详细介绍过Javac的实现,这里不再过多介绍。
对于HotSpot VM来说,即时编译器主要有C1、C2和Graal。这些编译器会根据相关的统计信息区分出热点代码,然后将热点代码直接编译为可在硬件上运行的机器码。C2和Graal(实验性质的编译器,可用来替换C2)的实现比较复杂,大家有兴趣的可自行研究,我们后面将会对C1编译器进行详细介绍。
AOT(ahead-of-time)编译器试图通过在执行之前编译应用程序代码来尽可能的降低运行时开销。AOT编译器在Java中使用的并不多,所以不做过多介绍。
Java在运行过程中,使用最多的就是解释器和C1、C2编译器。那么解释执行和编译执行有哪些优点呢?
- 解释器优点:当程序需要迅速启动的时候,解释器可以首先发挥作用,省去了编译的时间,立即执行。解释执行占用更小的内存空间。同时,当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。另外还有一个优点就是,解释器相对来说实现比较简单,所以对一些新功能特性的支持要比在编译器中实现快一些。
- 编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。
解释器相对编译器来说,性能会稍差一些(虽然编译执行过程中已经采取了许多的优化手段,如之前介绍的栈顶缓存、将多个字节码指令合并的超级指令和常量池缓存等),所以会在解释执行过程中还是会使用编译来获取更高的性能。因此,在HotSpot VM运行过程中,解释器与编译器经常配合工作,它们之间会来回切换,如下图所示。
HotSpot VM中内置的两个即时编译器Client Compiler和Server Compiler,也就是我们上面说的C1和C2 编译器。之前我们在研究解释器的运行原理时,通常会给虚拟机配置选项-Xint来强制HotSpot VM在解释模式下运行,此时编译器不会介入工作;如果我们想要HotSpot VM强制运行于编译模式时,可配置-Xcomp选项,这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。如果我们想要研究某一款编译器,还可以指定-client和-server选项强制HotSpot VM通过C1或C2来编译运行。
我们可以通过虚拟机java -version 命令查看当前默认的运行模式。例如我在JDK8版本上运行命令后的输出结果如下:
mazhi@mazhi:~$ java -version java version "1.8.0_192" Java(TM) SE Runtime Environment (build 1.8.0_192-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.192-b12, mixed mode)
其中的mixed mode指出了默认的运行模式为混合模式,也就是会解释执行,会编译执行,也会在C1和C2中选择编译执行。
下面我们就来介绍一下C1和C2编译器。内容基本照搬R大中此文章的内容https://hllvm-group.iteye.com/group/topic/39806,只是做了一些配图和修改。
1、C1编译器
我们可以将C1编译器大概分为3个编译阶段,其主要的关注点是局部优化,没有对程序执行太多的全局优化,因为全局优化比较耗时。C1编译器的大概执行过程如下图所示。
下面简单介绍一下这几个阶段。
(1)将字节码转换为HIR
一个平*立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion , HIR)。C1的高层IR名为HIR(High-level Intermediate Representation),是一种比较传统的IR,有基本块构成的控制流图(control-flow graph,CFG)和基本块内的SSA形式的数据依赖图。
- HIR的控制流图是双向链接的,也就是说每个基本块都有指向前驱节点(predecessor)和指向后继节点(successor)的指针。
- HIR的数据依赖图则是单向链接的,只有use-def链,而不显式维护def-use链。每个数据节点持有指向它的参数的指针,而不知道它自己的值被什么节点使用。这使得C1很容易做前向数据流分析(forward dataflow analysis),而不那么方便做后向数据流分析(backward dataflow analysis)。它需要用一个单独的趟来得到def-use信息。
C1在将字节码转换为HIR的过程中,会在字节码上完成一部分基础优化,如方法内联、常量传播等。C1做的优化还是比较简单,主要有这么一些:
- 消除冗余的空指针检查(null check elimination)
- 消除条件表达式(conditional expression elimination,CEE)
- 合并基本块
- 基于必经节点的全局值标号(dominator-based GVN)
最新版的HotSpot C1新加了
- 消除数组边界检查(range check elimination,RCE)
- 比较简单的循环不变量外提(loop invariant code motion,LICM)
C1在方法内联上只用了比较简单的策略:它能内联可以静态判定实现者的、字节码大小不大于MaxInlineSize(= 35字节)的方法。这包括静态方法、private的实例方法以及final的虚方法。同时它也可以依赖类层次分析(class hierarchy analysis,CHA)来内联只有单一实现者的虚方法。对于无法内联的虚方法调用,C1和C2都会生成单态内联方法调用缓存(monomorphic inline-cache)来加速虚方法调用。代码里对应的是CompiledIC。
值标号主要用于消除冗余表达式的计算,并常跟表达式简化(algebraic simplification)和常量折叠(constant folding)并用。在这方面,C1在解析字节码时只做局部值标号(local value numbering,LVN),也就是只在基本块内做值标号。而C2此时做的是全局值标号(global value numbering,GVN),也就是在方法内可以跨基本块边界做值标号——Ideal Graph根本就没有基本块边界,值标号很自然就是全局的。
另外值得一提的是,C1解析字节码得到的IR包含整个方法的逻辑,而C2则可能只包含方法的一部分逻辑——没执行过的或者很少执行的分支、涉及尚未加载或已卸载的类的地方、许多抛异常的地方等等,这些部分C2假定不会发生所以都不编译;一旦在运行时真的执行到了这些地方,代码就会通过逆优化(deoptimization)回到解释器去继续执行。这样可以让C2的IR更小、类型更精准,以便容忍后续的更耗时的优化。
(2)将HIR变换为LIR
一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation ,LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除,范围检查消除等,让HIR 更为高效。C1的低层IR叫LIR(Low-level Intermediate Representation)。它虽然叫做低层IR,但语义相对其它优化编译器的LIR来说还是比较高级(也就是说比较抽象,没那么贴近目标机器指令),只是能够显式刻画寄存器的使用而已。HIR与LIR之间有固定的对应关系。LIR不是SSA形式的,在从HIR转换过来的时候需要退出SSA形式。
(3)将LIR转换为本地代码
在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,做窥孔(Peephole)优化,然后产生机器码。 C1使用几乎线性时间开销的线性扫描寄存器分配(linear scan register allocation,LSRA)。它具体使用的变种近似与原始LSRA和后来的second chance binpacking的混合形态。
最后就到代码生成(code generation)了。这里C1和C2都是像套用模板似的把低层IR映射为机器码。
C1的LIR跟目标机器码还有一定距离,所以不一定能一对一映射过去。简单的运算,如算数运算,通常是一对一映射过去的;而一些比较复杂的操作,例如分配新对象、类型检查之类的则有大块的机器码模板。
2、C2编译器
C2专门面向服务端的典型应用,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器。C2编译器的大概执行过程如下图所示。
图片来源:https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html
C2的高层IR名为Ideal Graph,是一种比较少见的sea-of-nodes形式的IR,属于PDG(program dependence graph,程序依赖图),在同一层IR里显式记述了控制流、数据流与内存副作用依赖,而没有显式的基本块结构。其数据流的部分也是SSA形式的。
Ideal Graph的所有依赖都通过显式的双向链接来维护,无论是前向还是后向分析做起来都很方便。而且,由于没有显式的基本块结构,它在优化过程中不维护代码调度顺序(schedule),所以使许多原本只适用在局部(基本块内)的简单优化变得可以在全局(跨基本块)适用,使C2能维持相对简单的结构来达到更好的优化效果。
C1与C2都在解析(parse)字节码的时候通过抽象解释把Java字节码转换为SSA形式的IR。
C2转换成SSA形式的过程隐含了复写传播(copy propagation)优化。 C1和C2都在解析字节码的过程中做方法内联(method inlining)以及值标号(value numbering)优化。
C2在方法内联上则更为激进:除了C1能内联的之外,C2还能使用profile信息来内联无法静态判定实现者的方法——假如profiling发现某个虚方法调用点实际只调用到了1、2个实现者,那么C2就可以把这1、2个实现者给内联进来。
C2则是做迭代式优化。这包括:
- 迭代式全局值标号(iterative GVN)
- 条件常量传播(conditional constant propagation,CCP)
- 循环优化(Ideal Loop),包括消除数组边界检查(RCE)、循环不变量外提(LICM)、循环展开(loop unrolling)、循环剥离(loop peeling)、基于superword的循环再合并、循环向量化等等
- 逃逸分析(escape analysis)与标量替换(scalar replacement)、锁消除( lock elision)搭配使用
- Java代码模式优化,例如字符串拼接优化(string concatenation optimization)、消除自动装箱(autoboxing elimination)等
- 增量式方法内联(incremental inlining)
大致数了一下,不保证全部优化都列举出来了。
它是专门面向服务端的典型应用,并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++ 编译器使用-O2 参数时的优化强度,它会执行所有的经典的优化动作,如
- 无用代码消除(Dead Code Elimination)
- 循环展开(Loop Unrolling)
- 循环表达式外提(Loop Expression Hoisting)
- 消除公共子表达式(Common Subexpression Elimination)
- 常量传播(Constant Propagation)
- 基本块重排序(Basic Block Reordering)
等等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination ,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化 了)等。另外,还可能根据Interpreter或Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如 守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
C2的低层IR叫Mach Node Graph。它非常贴近目标机器指令,几乎能一对一的直接映射到机器码上。从Ideal Graph转换到Mach Node Graph需要做指令选择(instruction selection),还有在代码调度(scheduling)之后重新创建带有显式基本块结构的控制流图。
C2的指令选择通过树改写的方式实现,这种系统叫做bottom-up rewrite system,BURS。
C2则使用了更耗时的、比较传统的Chaitin-Briggs式图着色寄存器分配(graph-coloring register allocation)。
在寄存器分配后,C1与C2都会做一趟窥孔优化(peephole optimization)来小范围的优化代码序列。这算是平台相关优化的一部分。
最后就到代码生成(code generation)了。这里C1和C2都是像套用模板似的把低层IR映射为机器码。
C2的Mach Node跟目标机器码已经非常接近了,大部分能直接映射为机器码;有少量需要大块机器码模板的情况。
本人对C2不是很了解,所以如果有机会也会研究一下,然后再回来内化这一部分知识。
公众号 深入剖析Java虚拟机HotSpot 已经更新虚拟机源代码剖析相关文章到60+,欢迎关注,如果有任何问题,可加作者微信mazhimazh,拉你入虚拟机群交流