JVM系列七(JIT 即时编译器).
一、概述
即时编译器(just in time compiler),也称为 jit 编译器,它的主要工作是把热点代码编译成与本地平台相关的机器码,并进行各种层次的优化,从而提高代码执行的效率。
那么什么是热点代码呢?我们知道虚拟机通过解释器(interpreter)来执行字节码文件,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(hot spot code)。
即时编译器编译性能的好坏、代码优化程度的高低是衡量一款商用虚拟机优秀与否的关键指标之一,它也是虚拟机最核心且最能体现技术水平的部分。
然而,程序员在开发过程中,压根不会感知到即时编译器的存在,也参与不了即时编译器的过程,所以我们对即时编译器的学习更多的是了解,明白怎么写代码才能更好的被即时编译器优化。
二、工作流程
hotspot 虚拟机包含解释器和编译器。它们是怎么搭配工作的呢?当程序启动的时候,解释器首先发挥作用,它能直接运行字节码文件;随着时间的推移,越来越多的热点代码被编译器编译成机器码,从而获取更高的执行效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,当编译器的激进优化手段不成立时,如加载了新类后类型继承结构出现变化等,可以通过逆优化(deoptimization)退回到解释状态继续由解释器执行。
编译器又分为两种,c1 编译器(client compiler)和 c2 编译器(server compiler),hotspot 虚拟机会选择哪个编译器是由虚拟机运行于 client 模式还是 server 模式决定的。
默认情况下,虚拟机采用解释器和一种编译器搭配的方式工作,但是在分层编译策略下,c1 编译器和 c2 编译器将会同时工作,分层编译根据编译器编译、优化的规模和耗时,划分出不同的编译层次:
- 第0层:程序解释执行,解释器不开启性能监控功能,触发 c1 编译。
- 第1层:c1 编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要解释器将开始性能监控。
- 第2层:c2 编译,将字节码编译成本地代码,启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
tips:
- 使用 “-client” 强制虚拟机运行于 client 模式。
- 使用 “-server” 强制虚拟机运行于 server 模式。
- 使用 “-xint” 强制虚拟机只使用解释器执行程序,编译器不工作。
- 使用 “-xcomp” 强制虚拟机只使用编译器执行程序,解释器作为编译器的“逃生门”。
- 使用 “-xx:+tieredcompilation” 开启分层编译。虚拟机 server 模式下默认开启。
三、热点代码探测
热点代码分为两种:被多次调用的方法、被多次执行的循环体。多次是一个很泛的概念,那么到底什么时候才能把热点代码编译成机器码呢?hotspot 虚拟机采用的是计数器的方式,它为每个方法(甚至是代码块)建立计数器,统计执行次数,如果执行次数达到一定的阈值,就把这部分代码编译成机器码。
探测“被多次调用的方法”的计数器称为方法调用计数器(invocation counter),它统计的是一个方法调用的相对次数,即同一段时间内方法被调用的次数,当超过一定的时间限度,如果该方法的计数仍然不足以让它提交给编译器编译,那么该方法的计数就会被减少一半,这个过程称为方法调用计数器热度的衰减(counter decay),这段时间就被称为此方法统计的半衰周期(counter half life time)。方法调用计数器的相关 jvm 参数如下:
- -xx:compilethreshold 设置方法调用计数器的阈值,client 模式下默认是 1500 次, server 模式下默认是 10000 次
- -xx:usecounterdecay 设置 true/false 来开启/关闭热度衰减,默认开启
- -xx:counterhalflifetime 设置半衰期的周期,单位是秒(debug 虚拟机支持)
探测“被多次执行的循环体”的计数器称为回边计数器(back edge counter),它统计的是该方法循环执行的绝对次数,没有计数热度衰减的过程。回边计数器的相关 jvm 参数如下:
- -xx:onstackreplacepercentage osr比率,client 模式下默认是 933,server 模式下默认是 140;
- -xx:interpreterprofilepercentage 解释器监控比率,默认值是 33
- client 模式的回边计数器阈值 = compilethreshold * onstackreplacepercentage/100,默认是 13995 次
- server 模式的回边计数器阈值 = compilethreshold * (onstackreplacepercentage - interpreterprofilepercentage)/100,默认是 10700 次
四、优化技术
hotspot 的优化技术非常全面,实现起来也比较复杂,但是对于理解它们来说却显得没那么困难,我们将列举几项最有代表性的优化技术。
1. 方法内联
方法内联的重要性要优于其他优化措施,它的主要目的有两个,一是去除方法调用的成本,二是为其他优化建立良好的基础。
方法内联的行为很简单,就是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。
2. 公共子表达式消除
如果一个表达式 e 已经计算过了,并且从先前的计算到现在 e 中所有变量的值都没有发生变化,那么 e 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 e 就可以了。我们来举个例子来模拟下它的优化过程:
public static void main(string[] args) { int a = 1; int b = 1; int c = 1; int d = (c * b) * 12 + a + (a + b * c); // 1. 提取公共子表达式 int e = c * b; d = e * 12 + a + (a + e); // 2. 代数化简 d = e * 13 + a * 2; }
3. 数组边界检查消除
当我们尝试对数组越界访问的时候,java 会向我们抛一个 java.lang.arrayindexoutofboundsexception,这对软件开发者来说是一件很好的事情,即使没有专门编写防御代码,也可以避免大部分的溢出攻击,但是对虚拟机来说,意味着每一次的数组访问都带有一次隐含的条件判定操作,即数组边界检查,那么有没有办法消除这种检查呢?
虚拟机一般是在即时编译期间通过数据流分析来确定是否可以消除这种检查,比如 foo[3] 的访问,只有在编译的时候确定 3 不会超过 foo.length - 1 的值,就可以判断该次数组访问没有越界,就可以把数组边界检查消除。
4. 逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或者线程之外,则可以为这个变量进行一些高效的优化:
1) 栈上分配
如果确定一个对象不会逃逸出方法之外,假如能使用栈上分配这个对象,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。然而遗憾的是,目前的 hotspot 虚拟机还没有实现这项优化。
2)同步消除
如果确定一个对象不会被其他线程访问到,那么这个变量就不存在线程间的争抢,对这个变量实施的同步措施也可以消除掉。
3)标量替换
标量:无法被进一步分解的数据,比如原始数据类型(int、long以及 reference 类型等)
聚合量:可以被持续分解的数据,典型的就是 java 中对象,它们还可以被分解成成员变量等。
标量替换指的是如果把一个 java 对象拆散分解,根据程序访问的情况,将其使用到的成员变量恢复到原始类型来访问。
如果能确定一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。
tips:
- -xx:+doescapeanalysis 手动开启/关闭逃逸分析,默认开启,c2 编译器有效
- -xx:+printescapeanalysis 查看逃逸分析的结果(debug 虚拟机支持)
- -xx:+eliminateallocations 手动开启/关闭标量替换,默认开启
- -xx:+printeliminateallocations 查看标量替换情况(debug 虚拟机支持)
- -xx:+eliminatelocks 手动开启/关闭同步消除,默认开启