JVM 进阶 — 即时编译入门

前言

我们都知道Java是一个跨平台的语言,他的跨平台和C++的源码跨平台不同,Java是字节码跨平台,即Java编译器把Java源代码编译成 .class 的二进制文件,将.class文件部署到不同的JVM实例上解释执行。这种解释执行的方式显然没有直接机器码执行效率来的高。因此Java为了获取更高的执行效率,通过将热点代码编译成机器码,并存储起来反复执行来优化执行效率,那即时编译(JIT)是怎么工作的,工作流程又是怎样的?慢慢往下看。

什么是即时编译(JIT)

就像我们上面说的,Java是先用Java编译器先将Java代码编译成.class文件,然后在JVM上解释执行.class文件。而即时编译是一项用于提升应用程序运行效率的技术。通常而言,代码会先被Java虚拟机解释执行,之后反复执行的热点代码会被即时编译成为机器码,直接运行在底层硬件之上。

解释执行方便但是速度比较慢。机器码执行编译麻烦,但是执行速度快。那么怎么调和这个矛盾呢?这里的思想和冷热表类似,我们可以用冷热代码来处理,热点代码我们使用即时编译器编译成机器码,非热点代码解释执行就好,这样我们尽可能地提高执行效率。

代码状态收集—Profile

Java会把热点代码编译成机器码执行,那什么才算是热点代码呢?我们可以类比到生活中,对于一个互联网公司怎样确定某些用户才算是重点用户?他们又是怎么判断的呢?很简单,通过埋点的方式收集用户行为数据,然后通过分析这些收集来的信息生成用户画像。其实对于代码行为分析的方式也是这样的一套逻辑。JVM会在代码运行期间收集一些数据来反应代码的执行情况,这里收集的数据我们称为程序的 profile 。

这是一种能反应程序运行状态的数据,其中最基础的包括方法的调用次数以及循环回边的执行次数。此外还包括,跳转次数和不跳转次数,以及非私有示例方法的调用指令强制类型转换 checkcast 指令,类型测试 instanceof 指令引用类型的数据存储 aastore 指令类型的 profile(receiver type profile)。这些数据收集能更好的优化代码,但也会带来额外编译性能开销。

profile 收集的优缺点如下:

  • 收集程序数据,更好的编译优化代码。
  • 编译性能下降(full profiling 相较于 no profiling 性能下降 30%)

可能有人会说,花费这么多代价收集这些profile不值得,的确如果只是简单的优化那的确不值得。但是即时编译器可以根据大量profile做出代码行为的猜测,从而做出比较激进的优化,这就很划算了。

分层编译

HotSpot虚拟机包含多个即时编译器C1、C2和Graal,其中,Graal是一个实验性质的即时编译器,可以通过参数-XX:+UnlockExperimentalVMOptions-XX:+UseJVMCICompiler 启用,并且替换C2,在Java 7以前,我们需要根据程序,我们采用编译效率较快的C1,对应的参数 -client。对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的C2,对应参数-server

C1编译速度快,C2执行速度快。

Java7引入分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了C1的启动性能和C2的峰值性能优势。分层编译将Java虚拟机的执行状态分为了五个层次。以下用C1代码来指代C1生成的机器码,C2代码来指代由C2生成的机器码,这以下的五个层级分别是:

  1. 解释执行。
  2. 执行不带 profiling 的 C1 代码。
  3. 执行仅带方法调用次数已经循环回边执行次数 profiling 的 C1 代码。
  4. 执行带有所有的profiling的C1代码。
  5. 执行 C2 代码。

通过情况下,C2代码的执行效率要比 C1 代码的高出30%以上,这是因为profiling 越多,其额外的性能开销越大。JVM中提供很多profile,其中 JDK 附带的 hprof。这些profiler 大多数通过注入(instrumentation)或者 JVWTI 事件来实现的。Java 虚拟机也内置了 profiling。在5个层次的执行状态中,1层和4层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么Java虚拟机是不会再次发出该方法的编译请求。

上面列举了4个不同的编译路径。通常情况下,热点方法会被3层的C1编译,然后再被4层的C2编译。如果方法的字节码数目比较少,而且3层的profiling没有可收集数据。那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java虚拟机会在3层编译之后,直接选择用1层的C1编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用4层的C2编译。在C1忙碌的情况下,Java虚拟机在解释执行过程中对程序进行profiling,而后直接由4层的C2编译。在C2忙碌的情况下,方法会被2层的C1编译,然后再被3层的C1编译,以减少方法的3层的执行时间。

热点代码 三层 C1 编译,C2 编译。

如果 C1 编译和 C2 编译效率相同,C1编译。

如果 C1 忙碌,直接 C2。

如果 C2 忙碌,2层C1编译,3层C1编译。

Java8 默认开启了分层编译。不管是开启还是关闭分层编译,-client-server用来选择即时编译器是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用C2。如果只是希望使用C1进行编译,可以使用下面的启动参数-XX:TieredStopAtLevel=1

即时编译的触发时机

Java 虚拟机是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。但是JVM不会对执行次数进行一个精准地计时,只需要一个足够大的大概的数值就能明确划分出热点代码区域。

在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数—XX:ComoileThreshold指定的阈值时(使用C1时,该值为1500,使用C2时,该值为10000),便会触发即时编译。当启动分层编译时,Java虚拟机将不会采用由参数 -XX:ComplileThreshold 指定阈值即该参数会失效,而是另外一套阈值系统,其中阈值大小是动态调整的。

我们通过计算方法的调用次数循环回边的执行次数来判断一个方法是否是热点方法。同时即时编译也是根据这两个计数器的和来触发的。但是实际上,除了以方法为单位的即时编译之外,Java虚拟机还存在另外一种以循环为单位的即时编译叫做On-Stack-Replacement(OSR)。循环回边计数器触发的就是这种类型的即时编译。OSR实际上是一种技术,**它指的是在程序执行过程中,动态地替换掉Java方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化采用的技术也可以称之为OSR。
**

基于Profiling的优化

我们提到的JVM会收集Profile对代码进行分析优化,但是收集profile本身又比较耗费性能,因此JVM通过分层编译的方式,来平衡收集profile带来的性能消耗和代码优化后的性能提升。其中分支 profile 和类型 profile 的收集将给应用程序带来不小的性能开销,正是这部分额外的 profiling,使得3层C1代码的性能比2层C1代码底30%。通常情况下,我们不会在解释执行的过程中收集分支 profile 以及类型 profile。只有在触发C1编译后,JVM 认为这部分代码有可能被C2编译,才会在该方法的C1代码中profiling。那么这些C2进行的比较激进的优化是怎样的呢?

基于分支的 profile 优化

接下来我们看一个例子,下面这段代码中包含两个条件判断。第一个条件判断将测试所输入的 boolean 值,如果为true,则将局部变量 v 设置为所输入的 int 值。如果为 false,则将所输入的 int 值经过一番运算后,再存入局部变量 v 中。第二个判断测试局部变量 v 是否和所输入的 int 值相等。如果相等,则返回 0。如果不等,则将局部变量 v 经过一番运算之后,再将之返回。显然,当所输入的 boolean 值为 true 的情况下,这段代码将返回 0;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public static int foo(boolean f, int in) {
int v;
if (f) {
v = in;
} else {
v = (int) Math.sin(in);
}

if (v == in) {
return 0;
} else {
return (int) Math.cos(v);
}
}
// 编译而成的字节码:
public static int foo(boolean, int);
Code:
0: iload_0
1: ifeq 9
4: iload_1
5: istore_2
6: goto 16
9: iload_1
10: i2d
11: invokestatic java/lang/Math.sin:(D)D
14: d2i
15: istore_2
16: iload_2
17: iload_1
18: if_icmpne 23
21: iconst_0
22: ireturn
23: iload_2
24: i2d
25: invokestatic java/lang/Math.cos:(D)D
28: d2i
29: ireturn

通过上面的代码我们可以得到如下简单的流程图。

假设应用程序调用该方法,所传入的 boolean 值皆为 true。那么,偏移量为 1 以及偏移量为 18 的条件跳转指令所对应的分支 profile 中,跳转的次数都为0。并且两个连续的if判断都可以跳转 true 分支。C2可以根据这两个分支 profile 做出假设,C2 便不再编译这两个条件跳转语句所对应的 flase 分支。那么激进的优化就可以做成下面这样:

总结一下,根据条件跳转指令的分支 profile,即时编译器可以将从未执行的分支剪掉,以免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间,此外,“剪枝”将精简程序的数据流,从而触发更多的优化。在现实中,分支 profile 出现仅跳转或者不跳转的情况并不多见。当然,即时编译器对分支 profile 的利用也不仅限于“剪枝”。它还会根据分支 profile,计算每一条程序执行路径的概率,以便某些编译器的优化优先处理概率较高的路径。

基于类型 profile 的优化

这一个例子是关于instanceof以及方法调用类型 profile。下面这段代码将测试所传入的对象是否为 Exception 的实例,如果是,则返回它的系统的哈希值;如果不是,则返回它的哈希值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static int hash(Object in) {
if (in instanceof Exception) {
return System.identityHashCode(in);
} else {
return in.hashCode();
}
}
// 编译而成的字节码:
public static int hash(java.lang.Object);
Code:
0: aload_0
1: instanceof java/lang/Exception
4: ifeq 12
7: aload_0
8: invokestatic java/lang/System.identityHashCode:(Ljava/lang/Object;)I
11: ireturn
12: aload_0
13: invokevirtual java/lang/Object.hashCode:()I
16: ireturn

假设应用程序调用该方法时,所传入的 Object 皆为 Integer 实例,那么,偏移量为1的 instanceof 指令的类型 profile 仅仅包含 Integer,偏移量为4的分支跳转语句的分支 profile 中不跳转的次数为0,偏移量为13的方法调用指令的类型 profile 仅包含 Integer。

在虚拟机中,instanceof 测试并不简单,如果instanceof的目标类型是 final 类型,那么Java虚拟机仅需要比较测试对象的动态类型是否为final类型即可,但是如果目标对象不是 final 类型,比如我们例子中的Exception类型,那么虚拟机需要从测试对象的动态类型开始,一次测试该类,该类的父类,祖先类,该类所有直接实现或者间接实现的接口是否与目标类型一致。不过我们的例子中,instanceof 指令的类型 profile 仅包含 Integer,根据这个信息,即时编译器可以假设,在接下来的执行过程中,所输入的 Object 对象仍然是Integer实例。如果是的话,继续执行一下代码。

1
2
3
4
5
6
7
8
9
10
11
12
public final class Integer ... {
...
@Override
public int hashCode() {
return Integer.hashCode(value);
}

public static int hashCode(int value) {
return value;
}
...
}

整体的代码逻辑如下图:

和上面那个例子一样,根据 profile 的分析,可以被激进的优化成以下形式:

和基于分支 profile 优化一样,基于类型 profile 的优化同样也是做出假设,从而精简控制流以及数据流,这两者核心都是假设。对于分支 profile,即时编译器假设的是仅执行某一个分支,对于类型 profile,即时编译器假设对象的动态类型仅为类型profile中的那几个。这一切都是假设成功的情况,那么如果假设失败了,那么程序该如何执行下去?

去优化

前面的这些优化都是基于假设,那么如果假设不成立怎么办?JVM给的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行。在生成的机器码中,即时编译器在假设失败的位置上插入一个陷阱(trap)。该陷阱实际上是一条call指令,调用至 Java 虚拟机里专门负责去优化。与普通的call指令不是一样的,去优化方法将更改栈上的返回地址,并不再返回即时编译器的生成的机器码中。在上面的流程图中有很多红色的方框问号,这些问号代表一个个陷阱调用,如果流程走到这里,便将发生去优化并且切换至解释执行。去优化的过程中,需要当前机器码的执行状态转换至某一行字节码之前的状态,并且从该字节码开始执行。这需要即时编译器在编译过程中记录好这两种状态的映射。如果去优化的原因和优化无关则保留机器码,如果去优化的原因和profile激进分析有关,那就直接删除机器码,依据profile重新生成一份。

中间表达形式(IR)

前面我们介绍的优化过程都是以流程图的方式,展开即时编译器针对 profile 的优化,但是实际上不是这样的。在编译原理中,我们通常将编译器分为前端和后端。其中,前端会对所输入的程序进行此法分析、语法分析、语义分析,然后生成中间表达形式,也就是 IR(Intermediate Representation)。后段会对 IR 进行优化,然后生成目标代码。如果不考虑解释执行的话,从Jav源代码到最终的机器码实际上经过了两轮编译:源代码 – Java编译器 –> Java字节码,Java字节码 –即时编译器 –> 机器码。

对于即时编译器来说,所输入的Java字节码剥离了很多的高级Java语法,而且采用的基于栈的计算模型非常容易建模。因此,即时编译器并不需要重新进行词法分析、语法分析以及语义分析,而是直接将Java字节码作为一种 IR。不过,Java 字节码本身不适合直接作为可供优化的IR,这是因为现代编译器一般采用的静态单赋值(Static Single Assignment, SSA)IR,这种IR特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。其中 SSA IR 对优化也能提供很大的帮助:

1
2
3
4
5
6
以下是几种常见的优化:
常量折叠:x = 3 * 4; -> x = 12;
常量传播:x = 3;y = x; -> y = 3;
强度削弱:y = x * 5; -> y = (x >> 2) * x;
死代码删除:if(2 > 3) x = 1 else x = 2; -> x = 2;
...

Sea-of-Nodes

HotSpot 里面的C2采用的是一种名为 Sea-of-Nodes 的 SSA IR。它最大的特点,便是去除了变量的概念。直接采用变量所指向的值,来进行运算。在上面这段 SSA 伪代码中,我们使用多个变量名 x1、x2、y1和y2。这在 Sea-of-Nodes 将不复存在。取而代之的对应的值。比如说 Phi(y1,y2) 变成 Phi(0, 1),后者本身也是一个值,被其他IR节点所依赖。正因如此,常量传播在 Sea-of-Nodes 中变成了一个no-op(no-opreation)。

1
2
3
4
5
6
7
public static int foo(int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += i;
}
return sum;
}

上面这段代码对应的IR图如下所示:

上图中,B0、B1、B2这些都是一个个基本块,块与块之间通过红色和蓝色的线连接,其中红色代表数据流,蓝色代表控制流。上面的图中 0 代表程序入口,21代表程序出口。被控制流边所连接的是固定节点,其他的皆属于浮动节点。浮动节点的位置并不固定,在编译过程中,编译器需要多次计算浮动节点的具体的排布位置。这个过程我们称之为节点调度(node scheduling)。

Global Value Numbering 优化

因为Sea-of-Nodesde特性很容易做到的优化技术—Global Value Numbering(GVN)。GVN 是一种发现并消除等价计算的优化技术,例如如果一段程序中出现了多次操作相同的乘数,那么即时编译器可以将这些乘法合并为一个。从而降低输出机器码的大小。如果这些乘法出现在同一条执行路径上,那么GVN还将剩下冗余的乘法操作。所以如果一个浮动节点本身不存在内存副作用(可能会引发源代码中的不可能出现的情况),那么即时编译器只需要判断浮动节点是否已存在的浮动节点的类型相同,所输入的IR节点是否一致,便可以将这两个浮动节点归并为一个。

我们可以将 GVN 理解为在 IR 图上的公共子表达式消除(Common Subexpression Eliminate,CSE),类似消消乐。。

方法内联优化

方法内联指的是:在编译过程中遇到方法调用时,将目标方法体纳入编译范围之中,并取代原方法调用的优化手段。方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化,因此,他可以算是编译优化里面最重要的一环。在C2中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时候,C2将决定是和否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。

即时编译器首先解析字节码,并生成IR图,然后在该 IR 图上进行优化。每个优化是由一个个独立的优化节点(optimization phase)串联起来的。每个优化阶段会对 IR 图进行转换。最后即时编译器根据 IR 图的节点以及调用顺序生成机器码。

具体方法内联的过程可以看下面段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
方法内联的过程
public final static boolean flag = true;
public final static int value0 = 0;
public final static int value1 = 1;

public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}

public static int bar(boolean flag) {
return flag ? value0 : value1;
}

其中可以这段代码的 IR 图如下:

在编译 foo 方法时,其对应的 IR 图中将出现对 bar 方法的调用,即图上的5号 invoke 节点。如果内联算法判定应当内联对 bar 方法的调用时,那么即时编译器将开始解析 bar 的方法的字节码,并生成下面的IR图:

然后我们将这部分IR图复制带入到原本的图中,就可以得到下面图:

在完成这一步操作后,即时编译器还需要进行额外以下三步操作(图与图之间的连接操作):

  1. 被调用方法的传入参数节点,被替换成调用者方法进行方法调用时所传入的参数对应的节点。在例子中,我们将 bar 方法 IR 图的1号 P(0) 节点替换为 foo 方法 IR 图中的3号 LoadFiled 节点。(处理入口)。
  2. 在调用者方法的IR图中,所有指向原方法调用节点的数据依赖将重新指向被调用方法的返回节点。如果被调用方法存在多个返回节点,则生成一个 Phi 节点,将这些返回值聚合起来,并作为原方法调用节点的替换对象。(处理出口)。
  3. 如果被调用方法将抛出某种类型的异常,而调用者方法恰好有该异常类型的处理器,并且该类型异常的路径覆盖这一方法调用,那么即时编译器需要将被调用方法抛出异常的路径,与调用者方法的异常处理器相连接。(处理异常)。

再经过优化处理后,所有常量被消除后可以得到下面的优化结果:

再进一步优化(去除死代码),可以得到下面极简IR图:

前面我们提到了方法的静态绑定和动态绑定,当然在方法内联的过程中也会有这样的问题,当然方法内联也有各种不同的去虚化的优化手段。

逃逸分析

逃逸分析是一种确定指针动态范围的静态分析,他可以分析在程序的哪些地方可以访问到指针引用,在JVM的即时编译的语境下,逃逸分析将判断新建的对象是否存在逃逸,即时编译器判断对象是否逃逸的依据,一是对象是否存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知的代码中。前者很好理解:一旦对象被存入堆中,其他线程便能获得到该对象的引用。即时编译器也因此无法追踪所有使用该对象的位置。对于后者,JVM的编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。通常,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。那么基于逃逸分析我们能做哪些优化呢?

锁消除

我们都知道在锁对象要在多个锁之间共享,让多个线程对一个锁对象进行竞争这才有意义。那么如果我们可以证明锁对象是不逃逸的,也就是每一个线程创建一个锁对象并对其进行加锁,这明显是没有意义的。因此我们只要判断某个锁对象是不逃逸的,也压根就没有锁竞争,那么其加锁操作也是没有意义的了。

1
synchronized (new Object()) {}

栈上分配标量替换

我们都知道 Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java虚拟机需要对分配的内存空间进行管理,并且在对象不再被引用时回收其内存。那我们经常提到的栈上分配又是怎么一回事呢?这里如果我们可以用逃逸分析证明某些新建的对象不逃逸,那么JVM就完全可以把这个对象分配到栈上。而且在方法结束时,通过弹出当前方法栈来自动回收所分配的内存空间。这样一来我们便无须借助垃圾收集器来处理这些对象了。那问题来了,我们真的可以在栈上分配对象么?可以,但是需要大量的修改堆上分配对象的代码逻辑,因此HotSpot 虚拟机并没有采用栈上分配,而是使用标量替换这一技术来实现。所谓的标量就是仅能存储一个值的变量,比如Java代码中的局部变量,与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。因此我们有了标量替换,我们就可以把对一个对象的字段访问拆分成多个标量的访问,这样就间接实现了栈上分配。

我们还可以针对局部代码进行逃逸分析,进行一些代码优化的操作。

总结

这一小节我们介绍了入门了即时编译,即时编译是通过将部分热点代码进行优化编译成机器码的技术,可以极大的提升热点代码的执行效率。即时编译器是通过收集 profile 的方式对代码进行分析,然后通过分层编译对代码进行优化编译。在profile的优化中,我们主要介绍了两种方式,一种是基于流程分支的profile优化,另外一种是基于类型的profile优化,当然这种优化都是比较激进的,是通过统计猜测进行的优化,一旦猜错了。JVM会进行去优化操作并切回到解释执行。前面介绍的知识点只是我们抽象总结出来的,JVM真正的优化操作是通过 IR 实现的,即代码的中间表达形式。我们这里介绍了 Sea-of-Nodes,这是一种IR,是C2编译使用的IR,Sea-of-Nodes 中包含了很多的块和将他们连接起来的数据流和控制流,并且去除了变量的概念,有了Sea-of-Nodes我们就会有更多的优化想象空间了。因为我们的Sea-of-Nodes是由很多代码块构成的,我们可以消除功能类似的代码,这就是 Global Value Numbering 优化,我们还可以通过把方法嵌套编译在一起从而可以触发更多的优化操作。最后我们介绍了逃逸分析,逃逸分析简单来说就是分析一个对象的访问范围。通过逃逸分析,我们可以进行锁消除和通过标量替换实现栈上分配。

学习资料