JVM-6.即时编译器

2023-02-17,,,

一、即时编译器
二、运行模式
三、基本原理
四、编译优化技术
五、Java与C/C++的编译器对比
六、参考
 
 
 
一、即时编译器
1、在部分虚拟机(如Hotspot、IBM J9)中,Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器(Just In Time Compiler,JIT编译器)。
 
2、JIT不是必需的,却最能体现虚拟机的技术水平。本文提及的编译器,指Hotspot的JIT。
 
 
 
二、运行模式
为什么要解释器和编译器共存
1、时间:当程序需要迅速启动和执行的时候,使用解释器,省去编译的时间,快速执行;程序运行后,编译器逐渐发挥作用,越来越多的代码被编译成了本地机器码,效率更高。
2、内存:在内存限制较大的环境下,解释执行可以节约内存;反之,编译执行可以提升效率。
3、逃生门:编译器有时会选择大概率能提升速度的方式进行优化,称为激进优化;如果激进优化的假设不成立,速度没有提高,则可以逆优化退回到解释状态执行(部分没有解释器的虚拟机也会用没进行激进优化的C1担任逃生门)。
 
C1和C2:Hotspot内置了两个即时编译器,分别为Client Compiler和Server Compiler,简称C1或C2;C1编译器进行的优化较少,C2则较多。
 
运行模式
1、默认情况下,是混合模式(Mixed Mode),解释器与编译器搭配使用。
2、使用-Xint,强制使用解释模式,编译器完全不介入工作。
3、使用-Xcomp,强制使用编译模式,但是解释器在编译无法进行时,仍会介入。
通过使用java -version可以查看3种模式;在混合模式或编译模式下,Hotspot默认根据运行模式选择编译器;也可以通过-client或-server参数强制指定模式。
 
分层编译(在JDK1.7的Server模式下是默认的编译策略)
代码编译需要占用程序运行时间,如果要优化,占用的时间更长;此外,为了能够优化,解释器可能还要为编译器收集性能监控信息。为了在程序启动运行时间和运行效率达到平衡,Hotspot使用了分层编译策略(个人猜测与解释模式不兼容,但是与混合模式和编译模式兼容)。
分层编译根据编译、优化的耗时和规模,划分编译层次:
第0层:程序解释执行,解释器不开启性能监控功能;可触发第1层编译。
第1层:C1编译,进行简单可靠的优化,如有必要加入性能监控(即性能监控不再由解释器承担)。
第2层:C2编译,启用编译耗时较长的优化,甚至会根据性能监控信息进行不可靠优化。
在分层编译模式下,C1和C2会同时工作,许多代码可能会经过多次编译,可能解释执行也可能通过C1/C2编译后执行。
 
 
 
三、基本原理
编译对象
1、被多次调用的方法:编译对象是该方法。
2、被多次执行的循环体:一个方法调用的次数较少,但是循环次数很多,循环体的代码也会当做热点代码。编译动作由循环体出发,但编译对象仍是整个方法;这种编译发生在方法执行过程中,因此称作栈上替换(即方法栈帧还在栈上,方法就被替换了),即OSR编译。
 
热点探测
1、基于采样的热点探测:周期检查各个线程的栈顶,如果某个方法经常出现在栈顶,那么就是热点方法。优点:简单、高效,容易获得方法的调用关系;缺点:不够精确,容易受到线程阻塞等因素的影响。
2、基于计数器的热点探测:为每个方法(甚至是代码块)建立计数器,统计执行次数;优缺点与采样法对应。具体细节略。
 
后台编译
默认后台编译,即达到编译条件后,在编译器完成之前,都仍然按照解释方式执行,而编译动作在后台的编译线程中进行。如果关闭后台编译,则执行线程等待编译完成后执行编译器输出的代码。
 
编译过程
Client Compiler:三段式编译器,关注点在局部优化,放弃了耗时较长的全局性优化。
1、字节码到HIR:平台独立的前端实现,HIR指高级中间代码;在字节码到HIR前,会进行基础优化,包括方法内联,常量传播等。
2、HIR到LIR:平台相关的后端实现,LIR指低级中间代码;在HIR到LIR之前,会进行优化,如空值检查消除、范围检查消除等。
3、LIR到机器码:平台相关的后端使用线性扫描算法实现,包括寄存器分配、窥孔优化、产生机器码。
 
Server Compiler
1、执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等
2、实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除等
3、还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测(Branch Frequency Prediction)等。
C2的其他特点
1、几乎能达到GNU C++编译器使用-O2参数时的优化强度。
2、速度远远超过静态优化编译器,比C1慢但执行时间短,可以抵消编译的开销,因此非服务端应用也有选择Server模式运行的。
3、寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。
 
JIT的编译及分析:通过各种JVM运行参数实现,配合反编译器更强大;略
 
 
 
四、编译优化技术
编译后代码执行比字节码执行快:1、字节码解释额外的时间;2、优化
 
优化技术列表:略
 
举例(例子以java源代码展示,但JIT优化的不是源码而是字节码-机器码中间的码)

static class B {
    int value;
    final int get() {
        return value;
    }
}
public void foo() {
    B b = new B();
    int y = b.get();
    // ……do stuff……不会修改value的值
    int z = b.get();
    int sum = y + z;
}
//1、方法内联
public void foo() {
    B b = new B();
    int y = b.value;
    // ……do stuff……
    int z = b.value;
    int sum = y + z;
}
//2、冗余访问消除/公共子表达式消除
public void foo() {
    B b = new B();
    int y = b.value;
    // ……do stuff……
    int z = y;
    int sum = y + z;
}
//3、复写传播
public void foo() {
    B b = new B();
    int y = b.value;
    // ……do stuff……
    int y = y;
    int sum = y + y;
}
//4、无用代码消除
public void foo() {
    B b = new B();
    int y = b.value;
    // ……do stuff……
    int sum = y + y;
}

 

 

典型
1、语言无关的经典优化技术之一:公共子表达式消除。
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。
扩展:代数化简:int d=E*12+a+(a+E);->int d=E*13+a*2;
 
2、语言相关的经典优化技术之一:数组范围检查消除。
数组范围检查:必不可少;但每个元素读写操作隐含条件判定,性能差。
优化:(1)下标是常量,可以在编译器检查,运行期不检查;(2)数组访问发生在循环之中,且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除。
其他与语言相关的优化技术:自动装箱消除(难道不是在编译期优化?)、安全点消除、消除反射
扩展:Java中存在大量安全检查(空指针、数组越界、除数为0等),安全但低效;思路1 是尽可能把检查提前到编译期;另一种思路则是隐式异常处理。

if(foo!=null){
return foo.value;
}else{
throw new NullPointException();
}
//虚拟机优化后(隐式异常处理)
try{
return foo.value;
}catch(segment_fault){
uncommon_trap();//虚拟机注册的Segment Fault信号的异常处理器
}

代价就是当foo真的为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当foo极少为空的时候,隐式异常优化是值得的,否则会使程序变慢;Hotspot根据运行期收集的信息进行判断。

 
3、最重要的优化技术之一:方法内联。
最重要的优化措施,首先进行:(1)去除方法调用的成本(建立帧栈等);(2)为其他优化提供基础,方法内联膨胀后便于在更大范围内采取后续的优化手段
困难:虚方法内联时难以确定使用哪个版本;非虚方法只有invokespecial指令调用的私有方法、构造器、父类方法,invokestatic调用的静态方法,以及final方法。
实现方法:(1)非虚函数,直接内联;(2)虚函数,向CHA(类继承关系分析)查询该方法是否有多个实现,如果只有一个版本,则内联;但是属于激进优化,需要预留逃生门,当加载了导致继承关系发生变化的新类时,抛弃已编译代码,退回到解释执行或重新编译;这种方式称为守护内联;(3)虚函数,且不止一个版本,用内联缓存,即使用缓存记录方法接收者版本信息,每次调用时比较,一致则继续使用内联,如果不一致则取消内联。
补充说明:其实,激进优化很常见,除内联外,其他出现概率小的隐式异常、使用概率小的分支都可以被激进优化移除;如果真的出现小概率时间,再从逃生门回到解释状态执行。
 
4、最前沿的优化技术之一:逃逸分析。
逃逸分析:与CHA一样,不是直接优化手段,而是为其他优化提供依据。一个对象在方法中定义后,被外部方法引用(如作为参数),称为方法逃逸;如果对象可能被其他线程引用,则称为线程逃逸。如果能证明一个对象不会逃逸,则可以进行一些高级优化。
栈上分配:如果对象不会方法逃逸,将其分配在栈中,无须GC;考虑到这种对象有很多,GC压力会大大减小。
同步消除:如果对象不会线程逃逸,则其同步措施可以消除掉,毕竟线程同步比较耗时。
标量替换:标量是指数据无法分解,如基本数据类型(int/long/reference等);对象则是典型的聚合量。如果对象不会方法逃逸,且可以拆散,则可能不创建这个对象,而是创建它的若干个被这个方法使用到的成员变量,这就是标量替换。拆分后,除了可以让成员变量在栈上(除无需回收外,大概率分配到寄存器中),还可以为后续优化创造条件。
总结:逃逸分析技术还不成熟,无法保证性能收益大于消耗;但是重要的发展方向;一系列参数可以控制及查看逃逸分析相关的操作,略。
 
优化技术总结
方法内联、冗余访问消除、公共子表达式消除、复写传播、无用代码消除、代数化简、数组范围检查消除、隐式异常处理、CHA、调用频率预测、分支频率预测、逃逸分析、栈上分配、同步消除、标量替换
 
 
 
五、Java与C/C++的编译器对比
Java(编译后)运行速度的劣势
1、受限于编译成本(主要是时间),难以进行大规模优化;静态编译时间则不是主要关注点。
2、Java是动态的类型安全语言(强类型语言)(Java不是动态语言也不是动态类型语言);实现上,虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等;即便进行了优化,运行仍要消耗很多时间。
3、Java虚方法使用频率远大于C/C++,多态选择的频率也高,一些优化(如方法内联)难度高。
4、Java可动态扩展,运行时加载新类会改变程序类型的继承关系,使得全局优化难以进行;因此许多全局优化属于激进优化,编译器必须随时注意类型变化并随之撤销或重新进行一些优化。
5、GC:Java对象分配在堆上,局部变量在栈上;C/C++对象则可在堆上也可在栈上(?),私有变量分配在栈上将减轻GC压力。此外,C/C++由用户回收内存,无须筛选,Java的GC则效率较低。
 
Java运行速度的优势
1、Java别名分析简单,可以在此基础上进行优化(可以排除两个表达式不是指向一块内存)。
2、以运行期监控为基础的优化:如调用频率预测、分支频率预测、裁剪未被选择的分支等,包括存在多态选择时的内联
 
延伸:Java为什么比C++慢【参考http://www.360doc.com/content/12/0521/09/6828497_212455797.shtml】
1、解释型语言:Java是解释型语言,编译只是将源码翻译成字节码(而不是机器码),当JVM执行java程序时,类加载器需要按照类路径加载类,然后逐条解释执行。而C++是编译型语言,编译过程中直接将源码翻译成机器码,计算机可以直接执行。加载和字节码转化为机器码这两个过程,导致java程序执行比C++慢;即使使用了JIT技术,仍比C++慢很多。
2、上文优势1-5,劣势1-2
3、JIT本身会消耗时间,解释器收集性能信息也会消耗时间。
4、基于栈的指令集:Java为了可移植性,采用基于栈的指令集。频繁的栈内存访问会导致比较慢的速度,并且通常编译相同语句产生的指令数量也要多于寄存器指令集。
5、其他:Java有可能从网络加载字节类等。
 
Java和C++速度对比【当然现在很多评测结果显示Java可能比C/C++快,但是我做OJ时,Java确实慢多了】
【http://www.best-of-robotics.org/pages/publications/gherardi12java.pdf】
在解释运行的情况下,Java速度比C++慢11倍到20倍(我之前还看到过有测试出30倍的结果)。在JIT的情况下,Java比C++慢1.45到2.91倍,其中在Short-running的应用下,Java比C++慢2.72到5.61倍,在Long-running的应用下,Java比C++慢1.09到1.91倍。
 
 
 
六、参考
《JVM》第11章
 
 

JVM-6.即时编译器的相关教程结束。