JVM虚拟机执行模式与编译优化过程

发布于 2019-08-14  107 次阅读


解释器和编译器

HotSpot虚拟机中内置了两个即时编译器,分别为Client CompilerServer Compiler,或者简称为C1编译器和C2编译器。目前主流的HotSpot虚拟机,默认采用解释器和其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用-client-server参数去强制指定虚拟机运行在Client模式或者Server模式。

解释器和编译器搭配使用的方式在虚拟机中称为混合模式(Mixed Mode)。
用户可以使用参数-Xint强制虚拟机运行于解释模式(Interpreted Mode),这时编译器完全不介入工作,全部的代码都使用解释方式执行。
也可以使用参数-Xcomp强制虚拟机运行于编译模式(Compiled Mode),这时将优先采用编译的方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程.(在最新的Sun HotSpot中,已经去掉了-Xcomp参数)

可以通过虚拟机的-version目录显示这三种模式,例如

C:\Users\HAPPY>java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

C:\Users\HAPPY>java -Xint -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, interpreted mode)

C:\Users\HAPPY>java -Xcomp -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, compiled mode)

(注意每条信息的结尾显示的是模式)

编译对象与触发条件

热点代码类型

在运行过程中,会被即时编译器编译的热点代码有两类,即

  • 被多次调用的方法
  • 被多次执行的循环体

对于第一种情况,由于是由方法调用触发的编译,编译器自然会把整个方法都作为编译对象,这也是虚拟机中标准的JIT编译方式。

对于第二种情况,尽管编译动作是由循环体所触发的,但是编译器仍然会以整个方法(而不是单独的循环体)作为编译对象。(因为这种情况是为了解决一个方法制备调用过一次或者少量的几次,但方法体内部存在循环次数较多的循环体的问题)。这种编译方式因为编译发生在方法执行过程中,所以形象地称之为栈上替换(On Stack Replacement),简称OSR编译,即方法栈帧还在栈上,方法就被替换了。

热点代码的判定

判断一段代码是不是热点代码,是否需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection)。
目前主要的热点探测判定方式有两种:

  1. 基于采样的热点探测:用这种方法的虚拟机会周期性检测各个线程的栈顶,如果某个方法经常出现在栈顶,那这个方法就是热点方法
  2. 基于计数器的热点探测: 用这种方法的虚拟机会为每个方法甚至是代码块建立计数器,统计方法的执行次数,如果一个方法的执行次数超过一定的阈值就认为是热点方法

在HotSpot虚拟机中使用的是第二种---基于计数器的热点探测方法,因此它为每个方法准备了两类计数器,方法调用计数器和回边计数器。

还有例如基于轨迹(Trace)的热点探测,像在FireFox的TraceMonkey和Dalvik中的新的JIT编译器都采用了这种热点探测方式。

编译过程

在默认情况下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过-XX: -BackgroundCompilation来禁止后台编译,在禁止后台编译以后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

在后台编译的过程中,Server CompilerClient Compiler这两个编译器的编译过程是不一样的。

Client Compiler

对于Client Compiler来说,它是一个简单快速的三段式编译器,主要在于局部性的优化,放弃了很多耗时较长的全局优化手段。

  • 第一个阶段,将字节码构造成高级中间代码表示(High-Level Intermediate Representation, HIR). HIR使用静态单分配(SSA)的形式来代表代码值,以便更容易实现优化。
  • 第二个阶段,从HIR中产生低级中间代码表示(Low-Level Intermediate Representation, LIR),再作一些优化。
  • 最后一个阶段,使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Pepehole)优化,然后产生机器代码。
Client Compiler架构

Server Compiler

Server Compiler为服务端性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,它会执行所有经典的优化动作以及一些和JAVA语言特性相关的优化技术。
Server Compiler的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构例如RISC上的大寄存器集合。

基于全局图着色的寄存器分配

1、如果变量(在指令/语句序列中,或称程序“基本块”)不再被use(def也是use),则它dead
2、否则变量live(活着)
3、如果2个变量在一个block/program中都是live,则不能赋以相同的寄存器,在对应的RIG(register inference graph)中,在这2个变量节点之间连接一条边

由上面的描述可以看到,这里的寄存器分配实际上是一个全局算法。并不是只针对单个basic block的。

OK,设限制为k个寄存器,则寄存器分配问题转换为RIG的k-着色问题。是NP-complete问题。

评论:这里的寄存器分配似乎没有涉及到IR?并且是把程序代码中的变量直接映射为机器物理寄存器?对于早期的偏重于数值计算的Fortran语言来说,当然很合理。但是现代语言早已不是这个样子的了:OOP、并发、FP、VM、IR、SSA。那么关于上面的基本约束:如果2个变量同时live,则不能赋以相同寄存器。这条约束是否还有意义呢?

基于图着色模型的寄存器分配算法仅仅是个理论模型,实际所谓的最优并不可能达到,从而需要使用某些启发式。既然如此,为什么不一开始就直接使用某些实用的固定规则呢?假如这样得到的寄存器分配效率并不差多少?

Spilling:将(全局范围的)变量分配到内存而不是寄存器,这样每次用到时都需要先load最后将更新后的值store回去。这将使得一个大的program可以拆分为多个小的sub range,每个range内可使用更少的k-着色。(不过这种思路实在有点见鬼,为什么不在一开始就直接利用利用启发式这么做呢?非得等全局的图着色寄存器分配算法分析过后再rollback?)

具体可以参见

查看和分析即时编译过程

实例代码

/**
 * Created by HAPPY
 */
public class HotCompileTest {
    public static final int NUM = 20000;

    public static int doubleValue(int i) {
        for (int j = 0; j < 1000000; j++) ;
        return i * 2;
    }

    public static long calcSum() {
        long sum = 0;
        for (int i = 0; i <= 100; i++) {
            sum += doubleValue(i);
        }
        return sum;
    }

    public static void main(String[] args) {
        for (int i = 0; i < NUM; i++) {
            calcSum();
        }
    }
}

添加虚拟机参数-XX:+PrintCompilation将虚拟机在即时编译时将被编译成本地代码的方法名称打印出来。(结果中,带有%的输出说明是由回边计数器触发的OSR编译)
输出结果:(省略了在此不重要的函数)

  ......
     98   33 %     3       HotCompileTest::doubleValue @ 2 (18 bytes)
     98   34       3       HotCompileTest::doubleValue (18 bytes)
     98   35 %     4       HotCompileTest::doubleValue @ 2 (18 bytes)
     98   33 %     3       HotCompileTest::doubleValue @ -2 (18 bytes)   made not entrant
     99   36       4       HotCompileTest::doubleValue (18 bytes)
     99   34       3       HotCompileTest::doubleValue (18 bytes)   made not entrant
     99   37       3       HotCompileTest::calcSum (26 bytes)
     99   38 %     4       HotCompileTest::calcSum @ 4 (26 bytes)
    100   39       4       HotCompileTest::calcSum (26 bytes)
    101   37       3       HotCompileTest::calcSum (26 bytes)   made not entrant

此外,还可以使用参数-XX:+PrintInlining要求虚拟机输出方法内的内联信息。(在Product版的虚拟机中,还要加入-XX:+UnlockDiagnosticVMOptions参数打开虚拟机的诊断模式)

需要下载hsdis反汇编适配器插件(win64),并放置在JRE/bin/client或者/server目录下,只要和jvm.dll的路径相同就可以被虚拟机调用。
可以使用参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly使虚拟机打印编译方法的汇编代码。
如果没有hsdis插件的支持,可以用参数-XX:+PrintLIR(需要在DEBUG或FastDebug版本的Client VM)或者-XX:+PrintOptoAssembly(在Server VM)

将编译过程中的各个数据输出到文件。

  • -XX: PrintIdealGraphLevel=2 -XX:PrintIdealGraphFile=ideal.xml(Server Compiler),编译后会产生名为ideal.xml的文件。可以使用Ideal Graph Visualizer来对此信息进行分析。
  • -XX:+PrintCFGToFile(使用ClientCompiler)