《深入理解Java虚拟机——JVM高级特性与最佳实践》学习笔记——晚期(运行期)优化

时间:2022-12-27 19:12:38

晚期(运行期)优化

概述

在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为”热点代码”(Hot Spot Code),为了提供热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器)

HotSpot虚拟机内的即时编译器

在了解HotSpot虚拟机内的即时编译器的运作过程,主要是解决以下几个问题:
- 为何HotSpot虚拟机要使用解释器与编译器并存的架构?
- 为何HotSpot虚拟机要实现两个不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译为本地代码?
- 如何从外部观察即时编译器的编译过程和编译结果?

解释器与编译器

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个”逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现”罕见陷阱”时可以通过逆优化退回到解释状态继续执行

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用”-client”或”-server”参数去强制指定虚拟机运行在Client模式或Server模式

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译的策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译
- 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑
- 第2层(或以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化

实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务

编译对象与触发条件

“热点代码”有两类:
- 被多次调用的方法
- 被多次执行的循环体

对于第一种情况,由于是由方法调用触发的编译,因此编译器会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式,对于第二种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象,这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(简称OSR编译,即方法栈帧还在栈上,方法就被替换了)

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),目前主要的热点探测判定方式有两种:
- 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果某个(或某些)方法经常出现在栈顶,那这个方法就是”热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测
- 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是”热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但它的统计结果相对来说更加精确和严谨

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器和回边计数器
- 方法调用计数器:用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行,如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值,如果超过,将会向即时编译器提交一个该方法的代码编译请求,然后继续进入解释器按照解释方式执行字节码。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本,JIT编译的交互过程如下图所示

《深入理解Java虚拟机——JVM高级特性与最佳实践》学习笔记——晚期(运行期)优化

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行效率,即一段时间之内方法被调用的次数,当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒

  • 回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为”回边”,在虚拟机中可以通过参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式如下:

    • 虚拟机运行在Client模式下,回边计数器阈值计算公式为:

      方法调用计数器阈值xOSR比率/100 //如果都取默认值,那回边计数器的阈值为13995
    • 虚拟机运行在Server模式下,回边计数器阈值的计算公式为:

      方法调用计数器阈值x(OSR比率-解释器监控比率)/100 //如果都取默认值,那回边计数器的阈值为10700

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

《深入理解Java虚拟机——JVM高级特性与最佳实践》学习笔记——晚期(运行期)优化

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

编译过程

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

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

在第一个阶段,一个平*立的前端将字节码构造成一种高级中间代码表示(HIR)。HIR使用静态单分配(SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现,在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成

在第二阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式

最后阶段是在平台相关的后端使用线性扫描算法,在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码,Client Compiler的大致执行过程如图所示

《深入理解Java虚拟机——JVM高级特性与最佳实践》学习笔记——晚期(运行期)优化

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

Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译器的标准来看,Server Compiler无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行

编译优化技术

优化技术概览

类型 优化技术
编译器策略 延迟编译
分层编译
栈上替换
延迟优化
程序依赖图表示
静态单赋值表示
基于性能监控的优化技术 乐观空值断言
乐观类型断言
乐观类型增强
乐观数组长度增强
裁剪未被选择的分支
乐观的多态内联
分支频率预测
调用频率预测
基于证据的优化技术 精确类型推断
内存值推断
内存值跟踪
常量折叠
重组
操作符退化
空值检查消除
类型检测退化
类型检测消除
代数化简
公共子表达式消除
数据流敏感重写 条件常量传播
基于流承载的类型缩减转换
无用代码消除
语言相关的优化技术 类型继承关系分析
去虚拟机化
符号常量传播
自动装箱消除
逃逸分析
锁消除
锁膨胀
消除反射
内存及代码位置变换 表达式提升
表达式下沉
冗余存储消除
相邻存储合并
交汇点分离
循环变换 循环展开
循环剥离
安全点消除
迭代范围分离
范围检查消除
循环向量化
全局代码调整 内联
全局代码外提
基于热度的代码布局
Switch调整
控制流图变换 本地代码编排
本地代码封包
延迟槽填充
着色图寄存器分配
线性扫描寄存器分配
复写聚合
常量分裂
复写移除
地址模式匹配
指令窥孔优化
基于确定有限状态机的代码生成

示例

举例说明其中几种优化技术是如何发挥作用的(仅使用Java代码来表示)

优化前的原始代码

    static class B{
int value;
final int get(){
return value;
}
}

public void foo(){
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}

方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。内联优化后的代码如下

    public void foo(){
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}

第二步进行冗余访问消除,把”z=b.value”替换为”z=y”,因为上一句”y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局部变量了,如果把b.value看做是一个表达式,那也可以把这项优化看成是公共子表达式消除,优化后的代码如下

    public void foo(){
y = b.value();
// ...do stuff...
z = y;
sum = y + z;
}

第三步进行复写传播,因为在这段程序的逻辑中并没有必要使用一个额外的变量”z”,它与变量”y”是完全相等的,因此可以使用”y”来代替”z”,优化后的代码如下

    public void foo(){
y = b.value();
// ...do stuff...
y = y;
sum = y + y;
}

第四步进行无用代码消除,无用代码是指可能永远不会被执行的代码,也可能是完全没有意义的代码,如”y=y”,把它消除后的代码如下

    public void foo(){
y = b.value();
// ...do stuff...
sum = y + y;
}

下文将讨论几项最有代表性的优化技术是如何运作的,分别是:
- 语言无关的经典优化技术之一:公共子表达式消除
- 语言相关的经典优化技术之一:数组范围检查消除
- 最重要的优化技术之一:方法内联
- 最前沿的优化技术之一:逃逸分析

公共子表达式消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式,对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果优化范围涵盖了多个基本块,就称为全局公共子表达式消除,如

    int d = (c *  b) * 12 + a + (a + b * c);—> int d = E * 13 + a * 2;

数组边界检查消除

数组边界检查消除是即时编译器中的一项语言相关的经典优化技术,在Java语言中访问数组元素的时候会自动进行上下界的范围检查, 相当于每次数组元素的读写都带有一次隐含的条件判定操作,这无疑是一种性能负担

数组边界检查并不是在运行期间每次都要做,如下面这种情况:数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标”3”没有越界,执行的时候就无须判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判断循环变量的取值范围永远在区间[0,foo.length]之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。方法内联的优化行为看起来很简单,不过是把目标方法的代码”复制”到发起调用的方法之中,避免发生真实的方法调用而已

但是由于Java语言提倡使用面向对象的编程方式进行编程,而Java对象的方法默认就是虚方法,因此Java间接鼓励了程序员使用大量的虚方法来完成程序逻辑

为了解决虚方法的内联问题,Java虚拟机设计团队引入了一种名为”类型继承关系分析”(CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象等信息

编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化, 需要预留一个”逃生门”,称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去,但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译

如果向CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派

逃逸分析

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

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸,甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示:

  • 栈上分配:Java虚拟机中,在Java堆上分配创建对象的内存空间几乎是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁,在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉
  • 标量替换:标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解,它们就可以称为标量,相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替,将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件

逃逸分析这项优化尚未足够成熟,仍有很大的改进余地,不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它消耗(如果要完全准确地判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响,这是一个相对高耗时的过程)

Java与C/C++的编译器对比

Java与C/C++的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比,很大程度上也决定了Java与C/C++的性能对比的结果

Java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势:

  • 第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本,如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点
  • 第二,Java语言是动态的类型安全语言,这就意味着需要有虚拟机来确保程序不会违反语言语义或访问非结构化内存,从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等,对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍要消耗不少的运行时间
  • 第三,Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于C/C++语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于C/C++语言,也意味着即时编译器在进行一些优化(如前面提到的方法内联)时的难度要远大于C/C++的静态优化编译器
  • 第四,Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化
  • 第五,Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。而C/C++的对象则有多种内存分配方式,即可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也比垃圾收集机制要高

Java语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些”拖后腿”的特性都为Java语言的开发效率做出了很大贡献

何况,还有许多优化是Java的即时编译器能做而C/C++的静态优化编译器不能做或者不好做的,例如,在C/C++中,别名分析的难度就要远高于Java。Java编译器另外一个红利是由它的动态性所带来的,由于C/C++编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测、分支频率预测、裁剪未被选择的分支等,这些都会成为Java语言独有的性能优势