(九)晚期(编译器)优化

晚期(编译器)优化

本章主要内容:

  1. 了解虚拟机热点探测方法
  2. 了解HotSpot的即时编译器触发条件
  3. 从虚拟机外部观察和分析JIT编译的数据和结果
  4. 了解几种常见的编译期优化技术

概述

什么是JIT编译(Just In Time Compiler)?

Java程序最初是由解释器解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些“热点代码”编译成机器码,提高运行效率。

HotSpot虚拟机的即时编译器

为何HotSpot虚拟机要使用解释器和编译器并存的架构?

解释器和编译器并存

  1. 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行;在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
  2. 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。
  3. 解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段。

为何HotSpot虚拟机要实现两个不同的即使编译器?

HotSpot编译器中出现两个编译器Client Compiler称为C1编译器,Server Compiler称为C2编译器。JVM默认采用解释器与其中一个编译器直接配合的方法工作,程序使用哪个编译器,取决于虚拟机运行的模式(Client模式还是Server模式)。

用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

程序何时使用解释器?何时使用编译器?

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译的策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  1. 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  2. 第1层,也成为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要时将加入性能监控的逻辑。
  3. 第2层(或2层以上):也成为C2编译,也是将字节码编译为本地代码,但是会启动一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

哪些程序代码会被编译成本地代码?

在运行过程中会被即时编译器编译的“热点代码”有两类:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

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

如何判断是不是热点代码?

判断一段代码是不是为热点代码,是不是需要触发即时编译,这样的行为称为热点探测,但进行热点探测也是不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方法有两种:

  1. 基于采样的热点探测:虚拟机会周期性地检查各种线程的栈顶,如果发现某个或者某些方法经常出现在栈顶,那这个方法就是“热点方法”。
    优点:实现简单、高效,还可以很容易地获取方法调用方法。
    缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  2. 基于计数器的热点探测:虚拟机会为每个方法尽力计数器,统计方法的执行次数,如果执行次数超过一定的阙值就认为它是热点方法。
    优点:统计结构相对来说更加精确与严谨。
    缺点:实现起来麻烦,需要为每个方法及建立并维护计数器,而且不能直接获取到方法的调用关系。

在HotSpot中使用的是第二种方法,基于计数器的热点探测法,因此它为每个方法准备了两类计数器:

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

方法调用计数器

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

回边计数器

如何从外部观察即使编译器的编译过程和结果?

编译优化技术

常见的编译期优化技术:

  • 语言无关的经典优化技术之一:公共子表达式消除。
  • 语言相关的经典优化技术之一:数组范围检查消除。
  • 最重要的优化技术之一:方法内联。
  • 最前沿的优化技术之一:逃逸分析。

首先要明确的是,JVM的这些优化的技术基本都是用于即时编译器上,都是建立在某种中间表示或者机器码上的,绝不是建立在JAVA源码之上,也就是说,至少是发生class文件之后。

公共子表达式消除?

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

数组边界检查消除?

Java语言是一门动态安全的语言,数组边界检查是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以商量的事情。比如,数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标3没有越界,执行的时候就无须判断了。数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作

类似的安全检查还有很多,譬如空指针异常,除数为零,这种大量的安全检查代码,也是导致JAVA要比C/C++做更多事情的原因,成为一种隐性开销,要消除这些隐性开销,还得和C/C++一样,使用应用层异常(详细可参考操作系统中的异常章节)来处理,譬如:

1
2
3
4
5
6
//JVM中空指针处理的伪代码
if (foo != null) {
return foo.value;
else {
throw new NullPointException();
}

1
2
3
4
5
6
//优化后的伪代码
if (foo != null) {
return foo.value;
else {
throw new NullPointException();
}

方法内联?

方法内联,是指JVM在运行时将调用次数达到一定阈值的方法调用替换为方法体本身,从而消除调用成本,并为接下来进一步的代码性能优化提供基础,是JVM的一个重要优化手段之一。

何时进行方法内联?

方法内联是由JIT编译器在运行时完成的。JIT根据以下信息决定是否进行内联:

  1. 被调用方法是否足够hot。这个取决于该方法被调用的次数,次数阈值默认值为10,000。即运行时被调用次数超过10,000的方法,可以被认为是hot。
  2. 被调用方法大小是否合适。对于过大的方法,JIT认为它是不适合做内联的。这个方法大小阈值由-XX:FreqInlineSize指定,不建议修改。即大于这个阈值size的方法,不考虑进行内联
  3. 被调用方法运行时其实现是否可以唯一确定。即必须是非虚方法

java的内联的复杂性?如何解决?

虚方法在JAVA占大比,方法内联无法判断方法具体的版本,会使方法内联功能大打折扣。
为此,引入类型继承方法分析,分析虚方法是否有唯一确定的实现版本,如果有,则选其进行方法内联,不过该手段属于激进优化,一旦在运行的过程中继承关系发生变化,则需要回到解释状态执行。这也是解释器作为逃生门的一个作用。

逃逸分析?分析结果对优化有什么意义?

逃逸分析:分析对象的动态作用域。不是代码优化手段,而是为其它手段提供依据。

  • 方法逃逸:一个对象被外部方法引用,如传参。
  • 线程逃逸:对象被外部线程访问到,如赋值给类变量。

如果证明一个对象不会逃逸,那么可以进行一些高效的优化:

  • 栈上分配:一般对象都在堆上分配,各个线程共享,GC回收内存需要耗费时间。如果一个对象确定不会逃逸出方法,比如局部变量,那么分配在栈上就很舒服,可以随栈帧出栈而销毁。GC压力减小。
  • 同步消除:如果一个对象确定不是线程逃逸,那么就不会被其它线程访问,就不存在竞争,完全可以消除同步处理。
  • 标量替换:如果一个对象确定不会被外部访问,那么真正执行的时候就不需要创建这个对象,改为在栈上创建对象拆散后的标量。

JAVA与C/C++编译器对比

java即时编译器的劣势:

  1. 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,能提供的优化手段也严重受制于编译成本。
  2. jvm会有大量的安全检查代码,这些都是隐性开销
  3. jvm中虚方法占比高,优化难度也会因此增加
  4. java是动态扩展语言,在运行时新加载的类可能会使继承关系发生变化,因此全局性的优化技术往往无法很好的工作(激进优化)。
  5. java对象分配在堆上,局部变量在栈上,而C/C++可自由的分配堆和栈,也不需要使用GC去回收内存

java即时编译器的优势:

  1. 开发效率换取性能效率
  2. C/C++中的别名分离难度要高
  3. java的动态编译性使其可以实施以运行期性能监控为基础的优化措施(C/C++所有的优化都在编译器完成)

本文标题:(九)晚期(编译器)优化

文章作者:Sun

发布时间:2019年12月12日 - 15:12

最后更新:2019年12月12日 - 15:12

原始链接:https://sunyi720.github.io/2019/12/12/JVM/(九)晚期(编译器)优化/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。