JVM笔记7 后端编译

后端编译

如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation, IR) 的话,前后是相对于字节码文件说的。

  • 提前编译(Ahead Of Time, AOT)
  • 即时编译(Just In Time,JIT)

hotspot

  • 为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个(或三个) 不同的即时编译器?
  • 程序何时使用解释器执行? 何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码? 如何编译本地代码?
  • 如何从外部观察到即时编译器的编译过程和编译结果?

解释器与编译器

解释器与编译器两者各有优势:
当程序需要迅速启动和执行的时候, 解释器可以首先发挥作用, 省去编译的时间, 立即运行。
当程序启动后, 随着时间的推移, 编译器逐渐发挥作用, 把越来越多的代码编译成本地代码, 这样可以减少解释器的中间损耗, 获得更高的执行效率。
当程序运行环境中内存资源限制较大, 可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在) , 反之可以使用编译执行来提升效率。
同时, 解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色) , 让编译器根据概率选择一些不能保证所有情况都正确, 但大多数时候都能提升运行速度的优化手段, 当激进优化的假设不成立, 如加载了新类以后, 类型继承结构出现变化、 出现“罕见陷阱”(Uncommon Trap) 时可以通过逆优化(Deoptimization) 退回到解释状态继续执行, 因此在整个Java虚拟机执行架构里, 解释器与编译器经常是相辅相成地配合工作, 其交互关系如图11-1所示。
在这里插入图片描述

HotSpot虚拟机的编译器:

  • “客户端编译器”(Client Compiler),也称为C1编译器
  • “服务端编译器”(Server Compiler),也称为C2编译器
  • Graal编译器,JDK 10时才出现,长期目标是代替C2

编译器和解释器的运行模式

在分层编译(Tiered Compilation) 的工作模式出现以前, HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作, 程序使用哪个编译器, 只取决于虚拟机运行的模式, HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式, 用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。
无论采用的编译器是客户端编译器还是服务端编译器, 解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode) , 用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode) , 这时候编译器完全不介入工作, 全部代码都使用解释方式执行。 另外, 也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode) , 这时候将优先采用编译方式执行程序, 但是解释器仍然要在编译无法进行的情况下介入执行过程。 可以通过虚拟机的“-version”命令的输出结果显示出这三种模式, 内容如代码清单11-1所示。

$java -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)
$java -Xint -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)
$java -Xcomp -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)

由于即时编译器编译本地代码需要占用程序运行时间, 通常要编译出优化程度越高的代码, 所花费的时间便会越长; 而且想要编译出优化程度更高的代码, 解释器可能还要替编译器收集性能监控信息, 这对解释执行阶段的速度也有所影响。 为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能[2], 分层编译的概念其实很早就已经提出, 但直到JDK 6时期才被初步实现, 后来一直处于改进阶段, 最终在JDK 7的服务端模式虚拟机中作为默认编译策略被开启。 分层编译根据编译器编译、 优化的规模与耗时, 划分出不同的编译层次, 其中包括:
·第0层。 程序纯解释执行, 并且解释器不开启性能监控功能(Profiling) 。
·第1层。 使用客户端编译器将字节码编译为本地代码来运行, 进行简单可靠的稳定优化, 不开启性能监控功能。
·第2层。 仍然使用客户端编译器执行, 仅开启方法及回边次数统计等有限的性能监控功能。
·第3层。 仍然使用客户端编译器执行, 开启全部性能监控, 除了第2层的统计信息外, 还会收集如分支跳转、 虚方法调用版本等全部的统计信息。
·第4层。 使用服务端编译器将字节码编译为本地代码, 相比起客户端编译器, 服务端编译器会启用更多编译耗时更长的优化, 还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的, 根据不同的运行参数和版本, 虚拟机可以调整分层的数量。 各层次编译之间的交互、 转换关系如图11-2所示。
实施分层编译后, 解释器、 客户端编译器和服务端编译器就会同时工作, 热点代码都可能会被多次编译, 用客户端编译器获取更高的编译速度, 用服务端编译器来获取更好的编译质量, 在解释执行的时候也无须额外承担收集性能监控信息的任务, 而在服务端编译器采用高复杂度的优化算法时, 客户端编译器可先采用简单优化来为它争取更多的编译时间。
在这里插入图片描述

编译对象和触发条件

编译对象

在运行过程中会被即时编译器编译的目标是“热点代码”, 这里所指的热点代
码主要有两类, 包括:
·被多次调用的方法。
·被多次执行的循环体。
但是编译对象都是整个方法。对于后一种情况, 尽管编译动作是由循环体所触发的, 热点只是方法的一部分, 但编译器依然必须以整个方法作为编译对象, 只是执行入口(从方法第几条字节码指令开始执行) 会稍有不同, 编译时会传入执行入口点字节码序号(Byte Code Index, BCI) 。 这种编译方式因为编译发生在方法执行的过程中, 因此被很形象地称为“栈上替换”(On Stack Replacement, OSR) , 即方法的栈帧还在栈上, 方法就被替换了。

触发条件

多次执行。并不一定是一个确定的次数。
判断某段代码是不是热点代码的手段称为:“热点探测”(HotSpot Code Detection) ,
热点探测并不一定要知道方法具体被调用了多少次,有两种方式
·基于采样的热点探测(Sample Based Hot Spot Code Detection) 。周期性地检查各个线程的调用栈顶, 如果发现某个(或某些) 方法经常出现在栈顶, 那这个方法就是“热点方法”。 基于采样的热点探测的好处是实现简单高效, 还可以很容易地获取方法调用关系(将调用堆栈展开即可) , 缺点是很难精确地确认一个方法的热度, 容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
·基于计数器的热点探测(Counter Based Hot Spot Code Detection) 。 采用这种方法的虚拟机会为每个方法(甚至是代码块) 建立计数器, 统计方法的执行次数, 如果执行次数超过一定的阈值就认为它是“热点方法”。 这种统计方法实现起来要麻烦一些, 需要为每个方法建立并维护计数器, 而且不能直接获取到方法的调用关系。 但是它的统计结果相对来说更加精确严谨。
为了实现热点计数, HotSpot为每个方法准备了两类计数器: 方法调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter, “回边”的意思就是指在循环边界往回跳转) 。 当虚拟机运行参数确定的前提下, 这两个计数器都有一个明确的阈值, 计数器阈值一旦溢出, 就会触发即时编译。
我们首先来看看方法调用计数器。 顾名思义, 这个计数器就是用于统计方法被调用的次数, 它的默认阈值在客户端模式下是1500次, 在服务端模式下是10000次, 这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。 当一个方法被调用时, 虚拟机会先检查该方法是否存在被即时编译过的版本, 如果存在, 则优先使用编译后的本地代码来执行。 如果不存在已被编译过的版本, 则将该方法的调用计数器值加一, 然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。 一旦已超过阈值的话, 将会向即时编译器提交一个该方法的代码编译请求。
如果没有做过任何设置, 执行引擎默认不会同步等待编译请求完成, 而是继续进入解释器按照解释方式执行字节码, 直到提交的请求被即时编译器编译完成。 当编译工作完成后, 这个方法的调用入口地址就会被系统自动改写成新值, 下一次调用该方法时就会使用已编译的版本了, 整个即时编译的交互过程如图11-3所示。
在默认设置下, 方法调用计数器统计的并不是方法被调用的绝对次数, 而是一个相对的执行频率, 即一段时间之内方法被调用的次数。 当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译, 那该方法的调用计数器就会被减少一半, 这个过程被称为方法调用计数器热度的衰减(Counter Decay) , 而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) ,进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的, 可以使用虚拟机参数-XX: -UseCounterDecay来关闭热度衰减, 让方法计数器统计方法调用的绝对次数, 这样只要系统运行时间足够长, 程序中绝大部分方法都会被编译成本地代码。 另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间, 单位是秒。在这里插入图片描述
回边计数器, 它的作用是统计一个方法中循环体代码执行的次数[3], 在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge) ”, 很显然建立回边计数器统计的目的是为了触发栈上的替换编译。关于回边计数器的阈值, 虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX: BackEdgeThreshold供用户设置, 但是当前的HotSpot虚拟机实际上并未使用此参数, 我们必须设置另外一个参数-XX: OnStackReplacePercentage来间接调整回边计数器的阈值, 其计算公式有如下两种。
·虚拟机运行在客户端模式下, 回边计数器阈值计算公式为: 方法调用计数器阈值(-XX:CompileThreshold) 乘以OSR比率(-XX: OnStackReplacePercentage) 除以100。 其中-XX:OnStackReplacePercentage默认值为933, 如果都取默认值, 那客户端模式虚拟机的回边计数器的阈值为13995。
·虚拟机运行在服务端模式下, 回边计数器阈值的计算公式为: 方法调用计数器阈值(-XX:CompileThreshold) 乘以(OSR比率(-XX: OnStackReplacePercentage) 减去解释器监控比率(-XX:InterpreterProfilePercentage) 的差值) 除以100。 其中-XX: OnStack ReplacePercentage默认值为140, -XX: InterpreterProfilePercentage默认值为33, 如果都取默认值, 那服务端模式虚拟机回边计数器的阈值为10700。
当解释器遇到一条回边指令时, 会先查找将要执行的代码片段是否有已经编译好的版本, 如果有的话, 它将会优先执行已编译的代码, 否则就把回边计数器的值加一, 然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。 当超过阈值的时候, 将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些, 以便继续在解释器中执行循环, 等待编译器输出编译结果, 整个执行过程如图11-4所示。
在这里插入图片描述
与方法计数器不同, 回边计数器没有计数热度衰减的过程, 因此这个计数器统计的就是该方法循环执行的绝对次数。 当计数器溢出的时候, 它还会把方法计数器的值也调整到溢出状态, 这样下次再进入该方法的时候就会执行标准编译过程。
最后还要提醒一点, 图11-2和图11-3都仅仅是描述了客户端模式虚拟机的即时编译方式, 对于服务端模式虚拟机来说, 执行情况会比上面描述还要复杂一些。

编译过程

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

客户端编译器

客户端编译器一个相对简单快速的三段式编译器, 主要的关注点在于局部性的优化, 而放弃了许多耗时较长的全局优化手段。

  • 一个平台独立的前端将字节码构造成一种高级中间代码表示(High-LevelIntermediate Representation, HIR, 即与目标机器指令集无关的中间表示) 。 HIR使用静态单分配(Static Single Assignment, SSA) 的形式来代表代码值, 这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。 在此之前编译器已经会在字节码上完成一部分基础优化, 如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
  • 一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level IntermediateRepresentation, LIR, 即与目标机器指令集相关的中间表示) , 而在此之前会在HIR上完成另外一些优化, 如空值检查消除、 范围检查消除等, 以便让HIR达到更高效的代码表示形式。
  • 最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation) 在LIR上分配寄存器, 并在LIR上做窥孔(Peephole) 优化, 然后产生机器代码。
    * 在这里插入图片描述

服务端编译器

而服务端编译器则是专门面向服务端的典型应用场景, 并为服务端的性能配置针对性调整过的编译器, 也是一个能容忍很高优化复杂度的高级编译器, 几乎能达到GNU C++编译器使用-O2参数时的优化强度。 它会执行大部分经典的优化动作, 如: 无用代码消除(Dead Code Elimination) 、 循环展开(Loop Unrolling) 、 循环表达式外提(Loop Expression Hoisting) 、 消除公共子表达式(Common
Subexpression Elimination) 、 常量传播(Constant Propagation) 、 基本块重排序(Basic Block Reordering) 等, 还会实施一些与Java语言特性密切相关的优化技术, 如范围检查消除(Range Check Elimination) 、 空值检查消除(Null Check Elimination, 不过并非所有的空值检查消除都是依赖编译器优化的, 有一些是代码运行过程中自动优化了) 等。 另外, 还可能根据解释器或客户端编译器提供的
性能监控信息, 进行一些不稳定的预测性激进优化, 如守护内联(Guarded Inlining) 、 分支频率预测(Branch Frequency Prediction) 等。
服务端编译采用的寄存器分配器是一个全局图着色分配器, 它可以充分利用某些处理器架构(如RISC) 上的大寄存器集合。 以即时编译的标准来看, 服务端编译器无疑是比较缓慢的, 但它的编译速度依然远远超过传统的静态优化编译器, 而且它相对于客户端编译器编译输出的代码质量有很大提高, 可以大幅减少本地代码的执行时间, 从而抵消掉额外的编译时间开销, 所以也有很多非服务端的应用选择使用服务端模式的HotSpot虚拟机来运行。

查看即时编译的结果

HotSpot虚拟机还是提供了一些参数用来输出即时编译和某些优化措施的运行状况, 以满足调试和调优的需要。

提前编译器

一条分支是做与传统C、 C++编译器类似的, 在程序运行之前把程序代码编译成机器码的静态翻译工作;
另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来, 下次运行到这些代码(譬如公共库代码在被同一台机器
其他Java进程使用) 时直接把它加载进来使用。
虽然有了分层编译,但是编译依然浪费的是程序运行时间。动态编译不能执行耗时长的优化措施。
最后, 我们还要思考一个问题: 提前编译的代码输出质量, 一定会比即时编译更高吗?显然是不一定的。
提前编译因为没有执行时间和资源限制的压力, 能够毫无顾忌地使用重负载的优化手段, 这当然是一个极大的优势。
但是即时编译有程序的运行时信息,可以进行进一步的优化。

  • 性能分析制导优化(Profile-Guided Optimization, PGO) 。 如果一个条件分支的某一条路径执行特别频繁, 而其他路径鲜有问津, 那就可以把热的代码集中放到一起, 集中优化和分配更好的资源(分支预测、 寄存器、 缓存等) 给它。
  • 激进预测性优化(Aggressive Speculative Optimization) , 是很多即时编译优化措施的基础。 静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果)与优化前是等效的, 即时编译性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,即使错误,也可以退回到低级编译器或者解释器上执行。
  • 链接时优化(Link-Time Optimization, LTO)。 Java语言天生就是动态链接的, 一个个Class文件在运行期被加载到虚拟机内存当中, 然后在即时编译器里产生优化后的本地代码, C、 C++的程序要调用某个动态链接库的某个方法, 就会出现很明显的边界隔阂, 还难以优化。 因为主程序与动态链接库的代码在它们编译时是完全独立的, 两者各自编译、 优化自己的代码。 这些代码的作者、 编译的时间, 以及编译器甚至很可能都是不同的, 当出现跨链接库边界的调用时, 那些理论上应该要做的优化——譬如做对调用方法的内联, 就会执行起来相当的困难。

编译器优化技术

方法内联

最重要的优化手段,不仅消除方法调用的成本,并且代码合并后,可以进行联合的优化。
java的方法内联一个难点在于虚方法,只有在运行时才知道哪个调用哪个方法。
非虚方法直接内联即可。
守护内联(Guarded Inlining):虚方法在目前程序状态下只有一个版本,但是java可以动态加载类,可能改变这个结果,所以需要Slow Path
内联缓存(Inline Cache) :
单态内联缓存(Monomorphic Inline Cache)
超多态内联缓存(Megamorphic Inline Cache) , 其开销相当于真正查找虚方法表来进行方法分派,但是方法调用的时间依旧可以节省。

逃逸分析

逃逸分析是其他优化技术的基础。
分析对象动态作用域, 当一个对象在方法里面被定义后, 它可能被外部方法所引用, 例如作为调用参数传递到其他方法中, 这种称为方法逃逸; 甚至还有可能被外部线程访问到, 譬如赋值给可以在其他线程中访问的实例变量, 这种称为线程逃逸; 从不逃逸、 方法逃逸到线程逃逸, 称为对象由低到高的不同逃逸程度。
可能为这个对象实例采取不同程度的优化, 如:

  • 栈上分配(Stack Allocations) : java中内存对象在堆上创建,线程共享,但是需要GC,浪费资源。如果这个对象不能逃逸到线程外,则可以在栈上分配内存空间,无需GC,线程执行完后,栈内存就回收了。
  • 标量替换(Scalar Replacement) :
    • 标量,再进一步分解的数据,指的是JVM中的,如int、 long等数值类型及reference类型等。
    • 聚合量,可分数据,如java中的对象。
    • 如果把一个Java对象拆散, 根据程序访问的情况, 将其用到的成员变量恢复为原始类型来访问, 这个过程就称为标量替换。
    • 不逃逸, 并且这个对象可以被拆散, 那么程序真正执行的时候将可能不去创建这个对象, 而改为直接创建它的若干个被这个方法使用的成员变量来代替。
    • 省去创建过程,可以让对象的成员变量在栈上(栈上存储的数据, 很大机会被虚拟机分配至物理机器的高速寄存器中存储) 分配和读写之外, 还可以为后续进一步的优化手段创建条件。
    • 标量替换可视为栈上分配的特例
  • 同步消除(Synchronization Elimination) : 如果变量不会逃逸到线程外,则无需同步,省去了同步的开销。
公共子表达式消除
  • 公共子表达式消除:如果一个表达式E之前已经被计算过了, 并且从先前的计算到现在E中所有变量的值都没有发生变化, 那么E的这次出现就称为公共子表达式。 对于这种表达式, 没有必要花时间再对它重新进行计算, 只需要直接用前面计算过的表达式结果代替E。
  • 如果这种优化仅限于程序基本块内, 便可称为局部公共子表达式消除(Local Common Subexpression Elimination) ,
  • 如果这种优化的范围涵盖了多个基本块, 那就称为全局公共子表达式消除(Global Common Subexpression Elimination) 。

Graal编译器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值