解释、JIT和AOT编译浅析

4,184 阅读11分钟

一、三类执行方式定义

ART虚拟机同时具有解释执行、即时编译、提前编译功能。

解释执行:编译器不介入工作,每执行一句字节码的时候把字节码翻译成机器码并执行。

即时编译:在运行时,虚拟机将“热点代码”编译成本地机器码,并以各种手段尽可能地进行代码优化。

提前编译:将程序代码编译成机器码。

二、三种执行方式对比

解释执行:优点是启动效率快、占用内存少,缺点是整体的执行速度较慢、占用程序运行时间和运算资源。

即时编译:相比于解释器,即时编译将部分“热点代码”编译成本地代码,并进行优化,提高了执行效率。相比于提前编译,内存占用小(大部分应用仅20%的功能常用,80%可以不编译)、可优化及编译动态加载的Class文件、可进行激进预测性优化(失败后可退回到低级编译器甚至解释器上执行)、可获得热点代码集中优化和分配更好的资源。

提前编译:改善启动时间,快速达到最高性能,缺点在即时编译对比中可看出。

三、三种方式配合使用

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”,让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。

这种混合使用 AOT、解释、JIT 的策略的全部优点如下:

1)即使是大应用,安装时间也能缩短到几秒;

2)系统升级能更快地安装,因为不再需要优化这一步;

3)应用的内存占用更小,有些情况下可以降低 50%;

4)改善了性能;

5)更低的电池消耗。

四、通过Tinker热修复实例来感受各编译方式如何运行

原文链接:Android N混合编译与对热补丁影响解析

如文中提到,如下三个场景的编译方式:

1)install(应用安装)与first-boot(应用首次启动)使用的是[interpret-only],即只verify,代码解释执行即不编译任何的机器码,它的性能与Dalvik时完全一致,先让用户愉快的玩耍起来。

2)ab-ota(系统升级)与bg-dexopt(后台编译)使用的是[speed-profile],即只根据“热代码”的profile配置来编译。这也是N中混合编译的核心模式。

3)对于动态加载的代码,即forced-dexopt,它采用的是[speed]模式,即最大限度的编译机器码,它的表现与之前的AOT编译一致。

问题出在第2点,我们知道,Tinker热修复是通过dex替换方式来更新APP功能的。profile文件用于记录热点类与函数,app image用于记录已经编译好的“热代码”,若补丁修改的class已经存在于app image,那么在加载app image文件时,将dex的所有class插入到ClassTable,在类加载时,会先从ClassTable中去查找,找不到时才会走到DefineClass中,这些类都是无法通过热补丁更新的。解决方式为将需要替换的Application类通过代理方式,被代理到其他类,Application类不会再被使用到,具体参见文章。

五、三种执行方式细节

5.1 解释执行

解释执行相对简单,略过。

5.2 即时编译

即时编译涉及到的热点代码分为两部分来讲,一是什么是热点代码;二是如何判断哪些是热点代码。

热点代码主要分为两类:

1)被多次调用的方法,编译整个方法体,执行入口为方法起始位置,这个方法的调用入口地址就会被系统自动改写成该地址,下一次调用方法会从已编译版本地址开始执行;

2)被多次执行的循环体,编译整个方法体,执行入口为该方法为循环体起始位置,该循环体的调用入口地址就会被系统自动改写成该地址,下一次调用该循环体会从已编译版本地址开始执行,被称为“栈上替换”,方法栈帧还在栈上,方法就被替换了。

热点代码探测,两种方式:

1)基于采样的热点探测:周期性检查各个线程的调用栈顶,如果某个(或某些)方法经常出现在栈顶,就是“热点方法”。好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是不够精确,容易因为受到线程阻塞或别的外界因素的影响。

2)基于计数器的热点探测:为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能 直接获取到方法的调用关系。但统计结果更精确。

下面我们介绍第二种,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

当一个方法被调用时,虚拟机先检查该方法是否存在被即时编译过的 版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。

执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码。整个即时编译的交互过程下图所示。

在默认设置下,方法调用计数器不是绝对次数,而是一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期,进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的.

图1 方法调用计数器触发即时编译

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如下图所示。

图2 回边计数器触发即时编译

回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循 环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再 进入该方法的时候就会执行标准编译过程。

5.3 提前编译

现在提前编译有两条分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用。

第一种编译方式,直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。优点是可以做在编译过程中最耗时的优化措施之一“过程间分析”,获得诸如某个程序点上某个变量的值是否一定为常量、某段代码块是否永远不可能被使用、在某个点调用的某个虚方法是否只能有单一版本等的分析结论。

第二种方式,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者即时编译缓存(JIT Caching)。各种Java应用最起码会用到Java的标准类库,如java.base等模 块,如果能够将这个类库提前编译好,并进行比较高质量的优化,显然能够节约不少应用运行时的编译成本。

上述是提前编译的优势,当较于即时编译,提前编译不具备如下三点优势:

1)性能分析制导优化。在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,譬如某个程序点抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解,最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。

2)激进预测性优化,这也已经成为很多即时编译优化措施的基础。对于提前编译,即时编译的策略就可以不必保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概 率的假设进行优化,万一走到罕见分支上,可以退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。只要出错概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。譬如在Java语言中,虚拟机会通过类继承关系分析等一系列激进的猜测去做去虚拟化,以保证绝大部分有内联价值的虚方法都可以顺利内联,而C、C++难以对虚方法做内联。

3)链接时优化,Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。但如果类似的场景出现在使用提前编译的语言和程序上,譬如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。这是 因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。

总之,每一种执行方式都有它的优缺点,应该根据具体场景来选择。