深入理解java虚拟机第三版读书笔记11

以下是第十一章 后端编译与优化的内容

把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码可以视为整个编译过程的后端。

最近几年提前编译也开始兴起,我们在这章把它和即时编译共称为后端编译。

即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

解释器与编译器

目前主流的商用Java虚拟机内部都同时包含解释器与编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。

深入理解java虚拟机第三版读书笔记11

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称
为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器,第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。

解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”,用户也可以使用参数-Xint强制虚拟机运行于“解释模式”。另外,也可以使用参数-Xcomp强制虚拟机运行于“编译模式”,这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡, HotSpot虚拟机在编译子系统中加入了分层编译的功能。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
+ 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
+ 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
+ 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
+ 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
+ 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

编译对象与触发条件

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

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

要知道某段代码是不是热点代码,称为“热点探测”,有两种方法:

  • 基于采样的热点探测:周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。
  • 基于计数器的热点探测:为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”,更精确。

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

方法调用计数器默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。

在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期,进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器统计一个方法中循环体代码执行的次数,阈值使用-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。

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

编译过程

在默认条件下,编译的过程中,虚拟机仍然将按照解释方式继续执行代码,编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,编译的时候将会一直阻塞等待,直到编译过程完成再开始执行代码。

服务端编译器和客户端编译器的编译过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

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

客户端编译器工作流程

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除(不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联、分支频率预测等。

提前编译器

Android中的ART(Android Runtime)就是提前编译的。

提前编译的优劣得失

字节膨胀:提前编译的本地二进制码的体积会明显大于字节码的体积。

动态扩展:提前编译通常要求程序是封闭的,不能在外部动态加载新的字节码。

提前编译有两种分支:

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

第一条是传统的提前编译应用形式。编译期间需要消耗很多资源和时间用来优化,例如“过程间分析”,将它节省下来给运行能明显提高效率。

第二条则是给即时编译器做缓存加速,去改善Java程序的启动时间。这种提前编译被称为动态提前编译(Dynamic
AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。

即时编译器的优势有:

  • 性能分析制导优化:在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,有助于在运行期动态优化。
  • 激进预测性优化:提前编译做的优化较为保守,需要考虑程序实际运行的效果不能有差错,而即时编译可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行。
  • 链接时优化:Java语言天生就是动态链接的,如果要连接的代码在运行前在不同的机器、编译器上进行编译,最后连接可能会出现边界隔阂。

编译器优化技术

优化技术概览

参考地址:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex

编译器策略
+ 延迟编译
+ 分层编译
+ 栈上替换
+ 延迟优化
+ 程序依赖图表示
+ 静态单赋值表示

基于性能监控的优化技术
+ 乐观空值断言
+ 乐观类型断言
+ 乐观类型增强
+ 乐观数组长度加强
+ 裁剪未被选择的分支
+ 乐观的多态内联
+ 分支频率预测
+ 调用频率预测

基于证据的优化技术
+ 精确类型推断
+ 内存值推断
+ 内存值跟踪
+ 常量折叠
+ 重组
+ 操作符退化
+ 空值检查消除
+ 类型检测退化
+ 类型检测消除
+ 代数化简
+ 公共子表达式消除

数据流敏感重写
+ 条件常量传播
+ 基于流承载的类型缩减转换
+ 无用代码消除

语言相关的优化技术
+ 类型继承关系分析
+ 去虚拟机化
+ 符号常量传播
+ 自动装箱消除
+ 逃逸分析
+ 锁消除
+ 锁膨胀
+ 消除反射

内存及代码位置变换
+ 表达式提升
+ 表达式下沉
+ 冗余存储消除
+ 相邻存储合并
+ 交汇点分离

循环变换
+ 循环展开
+ 循环剥离
+ 安全点消除
+ 迭代范围分离
+ 范围检查消除
+ 循环向量化

全局代码调整
+ 内联
+ 全局代码外提
+ 基于热度的代码布局
+ Switch 调整

控制流图变换

  • 本地代码编排
  • 本地代码封包
  • 延迟槽填充
  • 着色图寄存器分配
  • 线性扫描寄存器分配
  • 复写聚合
  • 常量分裂
  • 复写移除
  • 地址模式匹配

下面介绍四种优化技术:
+ 最重要的优化技术之一:方法内联。
+ 最前沿的优化技术之一:逃逸分析。
+ 语言无关的经典优化技术之一:公共子表达式消除。
+ 语言相关的经典优化技术之一:数组边界检查消除。

方法内联

内联为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。

内联可能会遇到虚方法无法确定的问题。为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(CHA)的技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,编译器在进行内联时,如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联。不过由于后面可能会加载其他类,这属于激进优化。如果在后面的加载过程加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存的方式来缩减方法调用的开销。

这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。内联缓存是一个建立在目标方法正常入口
之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存,其开销相当于真正查找虚方法表来进行方法分派。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

  • 栈上分配:栈上分配变量可以避免对这个变量进行垃圾回收,从而提高效率,(同时,栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)栈上分配支持方法逃逸,但不能支持线程逃逸。
  • 标量替换:不能分解的,例如int、long等数值类型及reference类型等称为标量。相对的,可以继续分解称为聚合量,例如Java中的对象。如果把一个Java对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单,但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

公共子表达式消除

如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。

数组边界检查消除

有些代码在编译的时候就可以确定运行的时候数组下标一定不会越界,那么运行时就可以不做检查。

原创文章,作者:彭晨涛,如若转载,请注明出处:https://www.codetool.top/article/%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3java%e8%99%9a%e6%8b%9f%e6%9c%ba%e7%ac%ac%e4%b8%89%e7%89%88%e8%af%bb%e4%b9%a6%e7%ac%94%e8%ae%b011/