13. 晚期(运行期)优化

       Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为"热点代码"。在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器E(Just In Time Compiler 简称 JIT编译器)。JIT编译器并不是虚拟机必需的部分。

一、HotSpot 中的即时编译器

1.  解释器与编译器

       HotSpot 虚拟机中同时包含解释器和编译器。两者各有优势:当程序需要快速的启动时和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

                         13. 晚期(运行期)优化

                                                                                    解释器与编译器的交互

       HotSpot 中内置了两个即时编译器,分别为 Cilent CompilerServer Compiler,简称 C1 编译器和 C2 编译器。目前主流的 HotSpot 虚拟机(Sun 系列 JDK1.7及之前版本)中,默认采用解释器与其中一个编译器直接配合工作,程序使用哪个虚拟机,取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用       “ -client ” 或 “ -server ”参数去强制指定虚拟机的运行模式。

       解释器与编译器搭配使用的方式在虚拟机中称为 “ 混合模式 ”。用户可以使用参数 “ -Xint ”强制虚拟机运行于解释模式,这时编译器完全不介入工作,全部代码都使用解释方式执行。使用参数 “ -Xcomp ”强制虚拟机运行于 "b编译模式",这时将优先采用编译方式执行程序,但解释器仍然需要在编译无法进行的情况下介入执行过程。

       为了在程序启动响应速度和运行效率之间达到最佳平衡,HotSpot 虚拟机采用了分层编译的策略,在 JDK1.7 的Server 模式中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与好使,划分出不同的层次:

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

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

2. 编译对象与触发条件

“ 热点代码 ” 有两类:

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

       对于第一种,编译器会将整个方法作为编译对象,这种编译方式是虚拟机中标准的 JIT 编译方式。对于另一种,编译器依然会将整个方法作为编译对象。这种编译方式发生在方法执行的过程中,也被称为栈上替换(On Stack Replacement,简称 OSR 编译)。

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测。主要的热点探测判定方式有两种:

  • 基于采样的热点探测:虚拟机会周期性的检查各个线程的栈顶,若发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。好处是实现简单、高效,很容易的获取方法调用关系。缺点是很难精确的确认一个方法的热度,容易受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测:虚拟机会为每个方法建立计数器,统计方法执行的次数,若执行次数超过一定的阈值就认为它是“热点代码”。缺点是方法实现复杂,优点是统计结果相对更加精确和严谨。

      HotSpot 采用的是第二种,它为每个方法都准备了两类计数器:方法调用计数器回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出时,就会触发 JIT 编译。

  • 方法调用计数器:用来统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次,可通过参数 -XX:CompileThreshold 来进行设定。当方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,若存在,则优先使用编译后的本地代码执行;若不存在,则将此方法的调用计数器值加1.然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。若超过,则会向即时编译器提交一个该方法的代码编译请求
  • 回边计数器:它用来统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令称为 “ 回边 ”。它建立的目的就是触发 OSR 编译。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,若有则会优先执行已编译的代码,否则将回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器的输出编译结果。

                                    13. 晚期(运行期)优化

                                                               方法调用计数器触发即时编译

                                         13. 晚期(运行期)优化

                                                                       回边计数器触发即时编译

3. 编译过程

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

       在后台执行编译的过程中,Cilent CompilerServer Compiler 的编译过程不同。对于 Cilent Compiler ,它是一个简单快速的三段式编译器,主要关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

  • 第 1 阶段:一个平台独立的前端将字节码构成一种高级中间代码表示(HIR)。HIR 使用静态单分配的形式来代表代码值。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构成 HIR 之前完成。
  • 第 2 阶段 : 一个平台相关的后端从 HIR 中产生低级中间代码表示(LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除,范围检查消除等。
  • 第 3 阶段:是在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码。

                                      13. 晚期(运行期)优化

                                                                               Cilent Compiler 架构

          Server Compiler 是专门面向服务器的典型应用。它会执行所有的经典的优化动作,如无用代码消除、循环展开、循环表达式外提,消除公共子表达式等。还会实施一些与 Java 语言特性相关的优化技术,如范围检查消除、空值检查消除等。

二、编译优化技术

下面将介绍几种最具代表性的优化技术的运行方式:

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

1. 公共子表达式消除

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

2. 数组范围检查消除

       若有一个数组 foo[],在 Java 中访问数组元素 foo[i] 的时候系统将会自动进行上下边界的范围检查。但是数组边界检查是不是必须在运行期间一次不漏的检查是可以 “ 商量 ”的事情。例如:数组下标是一个常量,如 foo[3],只要在编译期根据数据流分析来确定 foo.length 的值,并判断下标 “3” 没有越界,执行时就无须再判断了。

3. 方法内联

       它的作用是除了消除方法调用的成本外,更重要的意义是为其他优化手段建立良好的基础。它的行为不过是将目标方法的代码 “ 复制 ” 到发起调用的方法之中,避免发生真实的方法调用。

4.  逃逸分析

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