深入理解JVM_12_晚期(运行期)优化

引:之前说过Java中的JIT即时编译器在运行期的优化对于程序运行来说更重要,那我们就来看看这个即时编译器。本文提及的编译器、即时编译器都是指HotSpot虚拟机内的即时编译器,虚拟机也特指HotSpot虚拟机。

什么是即时编译器

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

HotSpot虚拟机内的即时编译器

为什么要使用解释器与编译器并存的架构

  1. 解释器与编译器各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率,当程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行来提高效率。
  2. 解释器可以作为编译器激进优化时的一个“逃生门”,可以通过逆优化退回到解释器状态继续执行。

HotSpot虚拟机内置编译器

  1. Client Compiler(C1编译器):使用“-client” 参数去强制指定虚拟机运行在Client模式。
  2. Server Compiler(C2编译器):使用“-server” 参数去强制指定虚拟机运行在Server模式。

虚拟机默认采用解释器与编译器搭配使用的方式(混合模式)。为了在程序响应速度和运行效率之间达到最佳平衡,HotSpot虚拟机会逐渐启用分层编译策略:

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

编译对象与触发条件

编译对象(热点代码)

  1. 被多次调用的方法(JIT编译方式)
  2. 被多次执行的循环体(OSR编译方式)

热点探测

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

在HotSpot虚拟机中使用的是基于计数器的热点探测方法。因此它为每个方法准备了两类计数器:方法调用计数器(用于探测方法)和回边计数器(用于探测循环体)。这两个计数器都有一个确定的阈值,当计数器超过了阈值,就会提交编译请求。

编译过程

在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机代码编译器还未完成之前,都依然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。

Client Compiler

它是个三段式编译器,主要关注点在局部性的优化,放弃了许多耗时较长的全局优化手段。下面是三个阶段

  1. 字节码->高级中间代码(HIR):使用静态单分配的形式来代表代码值,其中会完成方法内联、常量传播等优化。
  2. HIR->低级中间代码(LIR):会在HIR上完成空值检查消除、范围检查消除等优化,以便让HIR达到更高效的代码的表示形式。
  3. LIR->机器代码:在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化。

Server Compiler

它是专门面向服务端的典型应用并为服务器端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器。它会执行所有经典的优化工作。它的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合。

查看及分析即时编译结果

这一块我没有去实践,但是这里写一个结论:在Java中空循环不能用作程序延时的手段,因为空循环会被优化消除。

编译优化技术

Java程序员有一个共识:以编译方式执行本地代码比解释方式更快。这主要是因为虚拟机设计团队几乎把对代码的所有优化措施都集中在编译器之中了。关于HotSpot的优化技术列表可以参考《深入理解Java虚拟机》一书,这里也根据书上举的几个例子来看看其中的优化技术。

公共子表达式消除

含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有的变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E即可。可以看看下面的优化历程:

1
2
3
4
5
6
7
8
// 源代码
int d = (c * b) * 12 + a + (a + b * c);

// 编译器检测到“c * b”和“b * c”是一样的表达式,这条表达式就变成下面这样了
int d = E * 12 + a + (a + E);

// 编译器还可能进行代数简化,把表达式变为:
int d = E * 13 + a * 2;

大家肯定能发现,最后的表达式计算起来就可以节省时间了。

数组边界检查消除

含义:虚拟机执行子系统每次数组的读写都带有一次隐含的条件判断操作,这对于拥有大量 数组访问的程序代码,无疑是一种性能负担。解决思路除了将数组边界检查优化尽可能把运行期检查提到编译期完成之外,还有另一种思路——隐式异常处理(try - catch)。可以看看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 源代码
if (foo != null) {
return foo.value
} else {
throw new NullPointException();
}

// 编译器转换代码
try {
return foo.value;
} catch(segment_fault) {
uncommon_trap();
}

这样可以避免每次去做非空检查。

方法内联

它除了消除方法调用成本之外,它更重要的是位其他优化手段建立良好的基础,如果不做内联,就发现不了无用代码。。内联具有两种情况:

  1. 对于非虚方法:直接内联
  2. 对于虚方法:会使用“类型继承关系分析”(CHA)技术,虚拟机如果遇到虚方法就会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,就可以进行内联,不过这种内联属于激进优化,需要余留一个“逃生门”,称为守护内联;如果查出有多个版本的目标方法可供选择,则编译器还会使用内联缓存来完成方法内联。

逃逸分析

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

如果能证明一个对象不会逃逸到方法或者线程之外,则可能为这个变量进行一些高效的优化:

  1. 栈上分配:让对象直接在栈上分配内存,这样大量对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
  2. 同步消除:对变量实施的同步措施可以消除掉
  3. 标量替换:不能再分解的量称为标量(如:数值类型),可以继续分解的称为聚合量(如:对象),我们可以直接将对象拆分成标量存在栈上。

总结

通过对于JIT编译的学习,我么可以知道哪些代码编译器是可以帮我们优化的,以及哪些代码是需要自己调节以便更合适编译器的优化。这样我们才可以写出更高效的代码。

参考

  1. 《深入理解Java虚拟机》