您的位置 首页 java

Java 程序编译和优化之晚期优化

IT技术研习社,专注互联网技术研究与分享,喜欢的朋友可以点击【关注】;把经验传递给有梦想的人;

接上一篇文章,这次我们来讲讲晚期优化。

2.晚期优化(运行期优化)

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

即时编译器不是虚拟机必须的部分,Java 虚拟机规范并没有规定虚拟机内部必须要有即时编译器存在,更没有限定或指导即时编译器应该如何实现。但是 JIT 编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。

2.1 HotSpot 虚拟机内的即时编译器

由于 Java 虚拟机规范中没有限定即时编译器如何实现,所以本节的内容完全取决于虚拟机的具体实现。我们这里拿 HotSpot 来说明,不过后面的内容涉及具体实现细节的内容很少,主流虚拟机中 JIT 的实现又有颇多相似之处,因此对理解其它虚拟机的实现也有很高的参考价值。

解释器与编译器

尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。

解释器与编译器两者各有优势:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码之后,可以获得更高的执行效率。
  • 当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释器执行来节约内存,反之可以使用编译执行来提升效率。

同时,解释器还可以作为编译器激进优化时的一个「逃生门」,当编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新的类后类型继承结构出现变化、出现「罕见陷阱」时可以通过逆优化退回到解释状态继续执行。

编译对象与触发条件

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

  • 被多次调用的方法;
  • 被多次执行的循环体。

这两种被多次重复执行的代码,称之为「热点代码」。

  • 对于被多次调用的方法,方法体内的代码自然会被执行多次,理所当然的就是热点代码。
  • 而对于多次执行的循环体则是为了解决一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也是热点代码。

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

我们反复提到多次,可是多少次算多次呢?虚拟机如何统计一个方法或一段代码被执行过多少次呢?回答了这两个问题,也就回答了即时编译器的触发条件。

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

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

HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

在确定虚拟机运行参数的情况下,这两个计数器都有一个确定的阈值,当计数器超过阈值就会触发 JIT 编译。

方法调用计数器

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

如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行 字节码 ,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期。

进行热度衰减的动作是在虚拟机进行 GC 时顺便进行的,可以设置虚拟机参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。此外还可以设置虚拟机参数调整半衰期的时间。

回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。建立回边计数器统计的目的是为了触发 OSR 编译。

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

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

2.2 编译优化技术

我们都知道,以编译方式执行本地代码比解释执行方式更快,一方面是因为节约了虚拟机解释执行字节码额外消耗的时间;另一方面是因为虚拟机设计团队几乎把所有对代码的优化措施都集中到了即时编译器中。这一小节我们来介绍下 HotSpot 虚拟机的即时编译器在编译代码时采用的优化技术。

优化技术概览

代码优化技术有很多,实现这些优化也很有难度,但是大部分还是比较好理解的。为了便于介绍,我们先从一段简单的代码开始,看看虚拟机会做哪些代码优化。

static class B {
 int value;
 final int get() {
 return value;
 }
}
public void foo() {
 y = b.get();
 z = b.get();
 sum = y + z;
}
 

首先需要明确的是,这些代码优化是建立在代码的某种中间表示或者机器码上的,绝不是建立在 Java 源码上。这里之所使用 Java 代码来介绍是为了方便演示。

上面这段代码看起来简单,但是有许多可以优化的地方。

第一步是进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施。方法内联的目的主要有两个,一是去除方法调用的成本(比如建立栈帧),二是为其它优化建立良好的基础,方法内联膨胀之后可以便于更大范围上采取后续的优化手段,从而获得更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最前面。内联优化后的代码如下:

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

第二步进行冗余消除,代码中「z = b.value;」可以被替换成「z = y」。这样就不用再去访问对象 b 的局部变量。如果把 b.value 看做是一个表达式,那也可以把这项优化工作看成是公共子表达式消除。优化后的代码如下:

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

第三步进行复写传播,因为这段代码里没有必要使用一个额外的变量 z,它与变量 y 是完全等价的,因此可以使用 y 来代替 z。复写传播后的代码如下:

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

第四步进行无用代码消除。无用代码可能是永远不会执行的代码,也可能是完全没有意义的代码。因此,又被形象的成为「Dead Code」。上述代码中 y = y 是没有意义的,因此进行无用代码消除后的代码是这样的:

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

经过这四次优化后,最新优化后的代码和优化前的代码所达到的效果是一致的,但是优化后的代码执行效率会更高。编译器的这些优化技术实现起来是很复杂的,但是想要理解它们还是很容易的。接下来我们再讲讲如下几项最有代表性的优化技术是如何运作的,它们分别是:

  • 公共子表达式消除;
  • 数组边界检查消除;
  • 方法内联;
  • 逃逸分析。

公共子表达式消除

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

数组边界检查消除

如果有一个数组 array[],在 Java 中访问数组元素 array[i] 的时候,系统会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < array.length,否则会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException,这就是数组边界检查。

对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这是一种不小的性能开销。为了安全,数组边界检查是必须做的,但是数组边界检查并不一定每次都要进行。比如在循环的时候访问数组,如果编译器只要通过数据流分析就知道循环变量是不是在区间 [0, array.length] 之内,那在整个循环中就可以把数组的上下界检查消除。

方法内联

方法内联前面已经通过代码分析介绍过,这里就不再赘述了。

逃逸分析

逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

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

  1. 栈上分配:如果确定一个对象不会逃逸到方法之外,那么就可以在栈上分配内存,对象所占的内存空间就可以随栈帧出栈而销毁。通常,不会逃逸的局部对象所占的比例很大,如果能栈上分配就会大大减轻 GC 的压力。
  2. 同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,因而变量的同步措施也就可以消除了。
  3. 标量 替换:标量是指一个数据无法再拆分成更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步拆分,所以它们就是标量。相反,一个数据可以继续分解,那它就称作聚合量,Java 中的对象就是聚合量。如果把一个 Java 对象拆散,根据访问情况将其使用到的成员变量恢复成原始类型来访问,就叫标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。对象被拆分后,除了可以让对象的成员变量在栈上分配和读写,还可以为后续进一步的优化手段创造条件。

IT技术研习社,专注互联网技术研究与分享,喜欢的朋友可以点击【关注】;把经验传递给有梦想的人;

文章来源:智云一二三科技

文章标题:Java 程序编译和优化之晚期优化

文章地址:https://www.zhihuclub.com/190379.shtml

关于作者: 智云科技

热门文章

网站地图