Java即时编译和提前编译
无论是即时编译或者是提前编译,都不是Java虚拟机必须的部分,Java虚拟机规范中从没有规定过虚拟机内部必须要包含这些编译器,更没有限定或者指导这些编译器应该如何去实现。
但是后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商用虚拟机优秀与否的关键指标之一。
一、即时编译器
Java程序最初都是通过解释器来进行解释执行的,当虚拟机发现某个方法或者代码块运行的特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器就被称为即时编译器。
解释器与编译器
解释器和编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐步发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。同时解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,hotspot虚拟机也会采用不进行激进优化的客户端编译器充当“逃生门的角色”),让编译器根据概率选择一些不能保证所有情况都正确,但是大多数时候能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化,出现“罕见陷阱”时可以通过逆优化退回到解释状态执行。
hotspot虚拟机中内置了俩个(或者三个)即时编译器,其中有俩个编译器存在已久,分别被称为“客户端编译器”和“服务端编译器”,或者简称为C1编译器和C2编译器,第三个是在JDK10才出现的,长期目标时代替C2的Graal编译器。
在分层编译的工作模式出现以前,hotspot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,hotspot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。
无论采用的编译器是客户端编译器还是服务端编译器,解释器和编译器搭配使用的方式在虚拟机器中被称为"混合模式"(mixed mode),用户可以使用-Xint强制虚拟机运行于“解释模式”(interpreted mode),-Xcomp 运行于“编译模式”(compiled mode)。
由于即时编译本地代码需要占用程序运行时间,同时为了编译出优化程度更高的代码,解释器要配合收集性能监控信息,这对解释器的速度也会有影响。
为了在程序启动响应速度与运行效率之间达到最佳的平衡,hotspot虚拟机在编译子系统中加入了分层编译的功能,其在jdk6初步实现,jdk7服务端模式虚拟机中作为默认编译策略开启。
分层编译
分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:
- 第0层。程序纯解释执行,并且解释器不开启性能监控。
- 第1层。使用客户端编译器将字节码编译为本地代码执行,进行简单可靠的稳定优化,不开启性能监控。
- 第2层。使用客户端编译执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层。使用客户端编译器执行,开启全部性能监控,除了第二层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部统计信息。
- 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
上面的层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。
实施分层变体后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被如此编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可采用简单优化来为它争取更多的编译时间。
编译对象与触发条件
即时编译器编译的目标是“热点代码”:
- 被多次调用的方法
- 被多次执行的循环体,一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体。
对于这俩种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。对于第二种,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口会稍有不同,编译时会执行入口字节码序号。这种编译方式因为编译发生在方法执行的过程中,因为被很形象的称为“栈上替换”,即方法的栈帧还在栈上,方法就被替换了。
热点探测
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”,目前主流的热点探测判定方式有俩种,分别是:
- 基于采样的热点探测,采用这种方法的虚拟机会周期性的检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法,其好处是实现简单高效,还可以很容易的获取方法调用关系(调用栈展开),缺点是精度不高,容易因为受到线程阻塞或者别的外界因素影响而扰乱热点探测。
- 基于计数器的热点探测,采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法,这种统计方式实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
第一种采样方式在J9虚拟机使用,hotspot则使用的第二种,为了实现热点计数,hotspot为每个方法准备了俩类计数器,方法调用计数器和回边计数器
方法调用计数器用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个参数可以通过-XX:ComplileThreshold来认为设置。
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已编译的版本,则将该方法的调用计数值加一,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。一旦已超过,将向即时编译器提交一个该方法的代码编译请求。执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。默认方法调用计数器统计的不是方法调用的绝对次数,而是一个相对的频率,当超过一定的时间限度,方法调用计数器没有超过阈值,则调用计数器减半,这个过程称为方法调用计数器的衰减,这个时间段称为半衰周期(-XX:CounterHalfLifeTime 单位秒)。可以使用虚拟机参数-XX: -UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数。
回边计数器,是统计一个方法种循环体代码执行的次数,其为了触发栈上的替换编译。相关参数:-XX: BackEdgeThreshold以及-XX:OnStackReplacePercentage ,阈值计算公式:
客户端模式: 方法调用计数器阈值 * OSR 比率 / 100 默认 13995.
服务端模式:方法调用计数器阈值 * (OSR 比率 - 解释器监控比率(-XX:InterpreterPecentage 默认33))除100 默认 10700.
当解释器遇到一条回边指令,会先查找将要执行的代码片段是否有已编译好的版本,如果有,则执行编译好的代码,否则回边计数器加一,然后判断方法调用计数器和回边计数器之和是否超过回边计数器阈值。如果超过将会提交一个栈上替换编译的请求,并且把回边计数器的值稍微调低一点,以便能继续在解释器中执行循环,等待编译器编译出结果。回边计数器没有衰减的过程。
编译过程
在默认情况下,无论是方法调用的标准编译请求还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
可以通过-XX: -BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。
客户端编译:
是一个相对简单快速的三段式编译器,主要关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段
第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR),HIR使用静态单分配的形式来代替代码值,在此之前编译器已经在字节码上完成了一部分基础优化,如方法内联、常量传播等优化。
第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等。
第三个阶段,平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
服务端编译:
服务端编译器是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。它会执行大部分经典的优化动作:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还有Java语言相关的优化技术:范围检查消除、空值检查消除。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联、分支频率预测等
服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合。
以即时编译的标准来看,服务端编译器是比较缓慢的,但它的编译速度依然远远超过静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消额外的编译时间开销。
二、提前编译器
提前编译产品和对其研究有着俩条明显的分支,一条分支是做与传统c、c++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码时直接把它加载进来使用。(譬如公共库代码在被一台机器其他进程使用)
关于第一条 它在Java中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。
关于提前编译的第二条,本质是给即时编译做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。
即时编译相对于提前编译的优势:
1、性能分析制导优化,它可以根据监控信息,把热的代码集中放到一起,集中优化和分配更好的资源。
2、激进预测优化,同样基于监控信息,可以进行一些比较激进的优化。
3、链接时优化,Java语言天生就是动态连接的,class文件在运行期加载到虚拟机内存中,然后在即时编译器里面产生优化后的本地代码。如果类似的场景出现在提前编译的语言和程序上(譬如c、c++要调用某个动态链接库的某个方法),就会出现明显的边界隔阂,还难以优化。这是因为主程序与动态链接库的代码在他们编译时是完全独立的,两者各自编译、优化自己的代码。