1、解释器与编译器
整个虚拟机执行架构中,解释器与编译器经常配合工作,如图
分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译。
- 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
- 第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。
2、编译对象与触发条件
运行过程中会被即时编译器编译的“热点代码”有两类,即:
- 被多次调用的方法。
- 被多次执行的循环体。
后一种,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)。
主要的热点探测判定方式有两种,
- 基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
- 基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式如下。
虚拟机运行在Client模式下,回边计数器阈值计算公式为:
方法调用计数器阈值(CompileThreshold)×OSR比率(OnStackReplacePercentage)/100
虚拟机运行在Server模式下,回边计数器阈值的计算公式为:
方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
3、编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
- 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion,HIR)。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
- 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
- 最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
4、编译优化技术
①方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。
许多情况下,虚拟机进行内联是一种激进优化
②公共子表达式消除
如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。
③逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示。
- 栈上分配(Stack Allocation):Java虚拟机中,在Java堆上分配创建对象的内存空间几乎是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
- 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
- 标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
5、Java与C++编译器比较
第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。
第二,Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。
第三,Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于C/C++语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于C/C++语言,也意味着即时编译器在进行一些优化(如前面提到的方法内联)时的难度要远大于C/C++的静态优化编译器。
第四,Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。
第五,Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。