第11章_后端编译与优化

概述

编译器在何时,何种状态下把Class文件转换成与本地基础设施相关的二进制机器码,都可以视为整个编译过程的后端

所以有提前编译器,即时编译器两种

即时编译器

java最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁,就会认为是热点代码,在运行时,则把这些代码编译成本地机器码,并且以各种手段尽可能地进行代码优化

解释器与编译器

hotspot虚拟机内部同时包含解释器与编译器,当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译时间,立即运行。随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码,减少解释器的中间损耗,获得更高执行效率

解释器节约内存,编译器提高效率

解释器也可以作为编译器激进优化时后备的逃生门,即编译器可能根据概率选大多数提高运行速度的优化手段,但不是每一次都正确的,当激进优化假设不成立时可以通过逆优化退回到解释状态继续执行

内置了两个即时编译器 C1(客户端编译器),C2(服务端编译器)

分层模式之前,采用解释器与其中一个编译器直接搭配的方式工作,虚拟机会根据自身版本和宿主机的硬件性能自动选择运行模式

一般都使用混合模式

分层编译

由于即时编译器编译本地代码需要占用程序运行时间,且要编译处优化程度高的代码,所花费时间便越长,为了在程序启动响应速度与运行效率之间取得平衡,就采用分层编译

  • 第0层,纯解释执行,而且解释器不开启性能监控
  • 第1层,使用C1将字节码编译成本地代码,不开启性能监控
  • 第2层,使用C1将字节码编译成本地代码,仅开启方法及回边统计等有限的性能监控
  • 第3层,使用C1将字节码编译成本地代码,开启全部性能监控
  • 第1层,使用C2将字节码编译成本地代码,开启全部性能监控.还会根据性能监控信息进行一些不可靠的激进优化

实施分层编译后,解释器,C1,C2同时工作,热点代码可能会多次编译,用C1获得更高的编译速度,用C2获得更好的编译质量,在解释执行时也无须额外承担收集性能监控信息的任务,而在C2采用高复杂度的优化算法时,C1可先采用简单优化为他争取更多的编译时间

编译对象与触发条件

热点代码:被多次调用的方法,被多次执行的循环体

这两种情况编译的对象都是整个方法体,而不是单独的循环体

对于后者,只是执行入口稍有不同,编译时会传入执行入口点字节码序号,以为编译发生在方法执行过程中,所以叫做栈上替换,即方法的栈帧还在栈上,方法就被替换了

热点探测

  1. 基于采样的热点探测,虚拟机会周期性的检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,就是热点代码。缺点是很难精确的确认一个方法的热度
  2. 基于计数器的热点探测,虚拟机会为每个方法建立计数器,统计方法的执行次数。相对更加精确严谨

HotSpot虚拟机实现

采用基于计数器的热点探测,准备了方法调用计数器,回边计数器(指循环边界往回跳转),计数器阈值一旦溢出,就触发即时编译。

执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成,当编译工作完成,该方法调用入口就会被系统改为新值,下一次调用该方法就会使用已编译版本

方法调用计数器

方法调用计数器不是方法被调用的绝对次数,而是相对的执行频率,即一段时间内方法被调用的次数。

当超过一段时间,方法的调用次数仍然不足以让它提交到即时编译器编译,则该方法的调用计数器就会减半。这个过程称为方法调用计数器热度的衰减。这段时间称为半衰周期IMG_20200828_0846415

回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为回边,目的是触发栈上的替换编译
当超过阈值时,将会提交一个栈上替换编译请求,并且把回边计数器值稍微降低,以便继续在解释器中执行循环,等待编译器输出编译结果
回边计数器没有计数热点衰减,统计的是该方法循环执行的绝对次数

编译过程

默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行

客户端编译器

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

  1. 一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR)
  2. 一个平台相关的后端将 HIR产生低级中间代码表示(LIR)
  3. 在平台相关的后端使用线性扫描算法,产生机器代码

服务端编译器

专门面向服务端的典型应用场景。是一个能容忍很高优化复杂度的高级编译器。执行大部分经典的优化操作和一些不稳定的预测性的激进优化(如分支频率预测等)

实战:查看及分析即使编译结果

Java虚拟机的即使编译过程对用户和程序都是完全透明的
空循环在最终的本地代码中实际上是不会执行的

提前编译器

提前编译的优劣得失

两条分支

  1. 在程序运行之前把程序代码编译成机器码的静态翻译工作.他的价值就是即时编译器的最大弱点:即时编译要占用程序运行时间和运算资源。所以在静态编译中进行耗时的优化,生成高质量代码。副作用是安装的时候很慢
  2. 把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行这些代码直接把他加载进来使用。本质上是给即时编译器做缓存加速,去改善Java程序的启动时间以及需要一段时间预热才能达到最高性能的问题。可以叫做即时编译缓存。缺点是这种提前编译方式不仅要和目标机器相关,而且还必须与HotSpot虚拟机的运行参数绑定

即时编译器的天然优势

性能分析制导优化

在解释器或客户端编译器运行过程中,会不断收集性能监控信息。这些信息一般在静态分析时无法得到,或者不可能存在确定的唯一解。然而在动态运行时却可以明显看出他们的偏好,可以把热点代码集中优化分配资源。

激进预测性优化

可以大胆的按照高概率的假设进行优化,万一出错,大不了回退到低价编译器甚至解释器上去执行

链接时优化

Java语言天生是动态链接的,一个个Class文件在运行期被加载到虚拟机内存中,然后在即时编译器里产生优化后的代码

编译器优化技术

编译器的目标是由程序代码翻译为本地机器码的过程,但是输出代码优化质量的高低才是决定编译器优秀与否的关键
即时编译器对代码优化变化是在代码的中间表示或机器码上的,而不是Java源码上的
优化的代码所达到效果相同,但是省略很多代码语句,体现在字节码和机器码的指令上的差距就越大

方法内联

是编译器最重要的优化手段,一般都会放在优化序列最靠前的位置
没有方法内联,多数其它优化都无法有效进行,因为分开看很多方法里面的操作可能是有意义的
优化行为理解起来就是把目标方法的代码原封不动地"复制"到发起调用的方法之中,但是Java实现挺复杂的

目的

  1. 去除方法调用的成本(如查找方法版本,建立栈帧等)
  2. 为其他优化建立良好的基础

Java实现方法内联问题

一般只有私有方法,实例构造器,父类方法,静态方法,final方法才会在编译期进行解析。其他的Java方法调用都必须在运行时进行方法接收者的多态选择,可能存在多于一个版本的方法接收者。所以默认的实例方法是虚方法。
所以方法应该根据实际类型动态分配,而实际类型必须在实际运行到这行代码才能确定,很难在编译时得到绝对准确的结论
Java对象默认的方法就是虚方法,Java间接鼓励了程序员使用大量的虚方法来实现程序逻辑

Java实现方法内联方法

使用类型继承关系分析,用于确定在目前已加载的类的接口和父类的信息。这样,编译器在进行内联时就会分不同情况采取不同的处理:

  1. 如果是非虚方法,直接内联
  2. 如果是虚方法,而且只有一个版本,就假设只有这个。不过属于激进预测性优化,必须预留号逃生门,即当假设不成立时的退路。如果之后加载了导致继承关系发生变化的新类,则必须抛弃已经编译的代码,退回到解释状态继续执行,或者重新进行编译。
  3. 如果有多个版本,则使用内联缓存。内联缓存是一个建立在目标方法正常入口之前的缓存。在未发生方法调用前,内联缓存状态为空,当第一次调用时,缓存记录下方法接受者的版本信息,并且每次进行方法调用时都比较接收者的版本,每个每次进来调用方法的接收者都是一样的,他就是一个单态内联缓存

所以在多数情况下,Java虚拟机进行的方法内联都是一种激进优化,如果真的出现小概率事件,才会使用逃生门回到解释状态重新执行。

逃逸分析

最前沿的优化技术之一,是为其他优化提供依据的分析技术。但是逃逸计算成本非常高,可能出现效果不稳定的情况

原理

分析对象动态作用域,当一个对象在方法里面被定义后,他可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种叫方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量这种叫线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,或逃逸程度比较低,可以使用以下技术

栈上分配

Java几乎都是堆上分配创建对象的内存空间,堆中对象对于各个线程都是共享可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。
如果确定一个对象不会逃逸到线程之外,那么可以让改对象在栈上分配内存。可以支持方法逃逸,不支持线程逃逸

标量替换

若一个数据已无法分解为更小的数据,就是标量。(int,long,reference等)
如果把java对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就是标量替换。
假如逃逸分析能证明一个对象不会被方法外部使用,并且该对象可以拆分,那么真正执行时可能不会创建这个对象,而是创建它的若干个成员变量。是栈上分配的特例,实现更简单,对逃逸要求更高

同步消除

线程同步是相对耗时的操作。如果一个变量不会逃逸出线程,他就没有读写竞争,就可以消除对该变量实施的同步措施

公共子表达式消除

语言无关的经典优化技术之一
如果一个表达式E在之前被计算过,并且从之前到现在E中所有变量值不变,则可以用前面计算好的结果代替E

数组边界检查消除

语言相关的经典优化技术之一
Java动态安全,访问数组元素系统会自动进行上下界的范围检查,对开发者友好,但是每次数组元素的读写都有一次隐式地条件判定操作,是一种性能负担。
如果数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围在数组长度内,就可以把循环中的上下界检查消除掉

实战:深入理解Graal编译器

范围检查,对开发者友好,但是每次数组元素的读写都有一次隐式地条件判定操作,是一种性能负担。
如果数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围在数组长度内,就可以把循环中的上下界检查消除掉

实战:深入理解Graal编译器

Graal编译器:即时编译器和提前编译器共同的最新成果。有望成为一款高质量编译效率,高输出质量,支持提前编译,即时编译。

猜你喜欢

转载自blog.csdn.net/weixin_42249196/article/details/108374475