即时编译器

当虚拟机发现某个方法或代码块运行的特别频繁,就会把这些代码认定为热点代码,运行时虚拟机会把这些代码编译成本地机器码,并进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

解释器和编译器

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,直接运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码,这样可以减少解释器的中间损耗,获得更高的执行效率。

HotSpot虚拟机内置了两个(或三个)即时编译器,其中两个编译器存在已久,分别是客户端编译器和服务端编译器,或者是C1和C2编译器。

即时编译器编译本地代码需要占用程序运行时间,通常编译出优化程度越来的代码,所花费的时间越长。想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第2层:仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层:仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层:使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

编译对象和触发条件

热点代码主要有两类,包括:

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

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种,分别是:

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

HotSpot采用的是第二种的探测方式,为了实现热点计数,HotSpot为每个方法准备了 两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思 就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

提前编译器

对提前编译的研究主要有下面两条分支。

静态翻译

第一条就是在程序运行之前,把程序代码“翻译”成机器码。

JIT 编译器的主要缺点在于:它是在「运行期」进行编译的。这就不可避免地要占用应用程序的运行资源(CPU、内存等),进而影响程序的执行性能。

而这种提前编译就是把这个编译阶段放到程序的「运行期」之前,这样就可以不占用应用程序的资源。

即时编译缓存

这个其实就是把 JIT 编译器要做的编译工作先做好,并保存下来,当触发 JIT 编译时,直接调用这里的代码就好了。本质上就是给 JIT 编译做缓存。

这种方式也被称为动态提前编译(Dynamic AOT)或者即时编译缓存(JIT Caching)。

即时编译&提前编译

从上面对提前编译器的分析来看,似乎提前编译比 JIT 编译运行效率更高。那它就没缺点了吗?当然不是,否则还要 JIT 编译器干嘛。

相比提前编译器,JIT 编译器的优势在哪里呢?

  • 性能分析制导优化

解释器或客户端编译器在运行的过程中,会不断收集性能监控信息(方法版本选择、条件判断等),这些信息可以帮助 JIT 编译器对代码进行集中优化。

这一点在静态分析时是很难做到的。

  • 激进预测性优化

也就是 JIT 编译器可以进行一些稍微“激进”的优化行为,即便这些行为失败了,也有解释器可以“兜底”。而静态优化就做不到了。

此外,提前编译还会破坏 Java 平台中立性、产生字节膨胀等问题。

编译器优化技术

最重要的优化技术之一:方法内联。

最前沿的优化技术之一:逃逸分析。

语言无关的经典优化技术之一:公共子表达式消除。

语言相关的经典优化技术之一:数组边界检查消除。

方法内联

方法内联编译器最重要的优化手段,业内戏称为“优化之母”。是其他优化手段的基础。

方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用。

1
2
3
4
5
6
7
8
9
10
public static void foo() {
if (obj != null) {
System.out.println("hello");
}
}

public static void testInline() {
Object obj = null;
foo(obj);
}

例子里testInline()方法的内部全部是无用的代码,但如果不做内联,后续即使进行了无用代码消除的优化。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化:

  • 栈上分配(Stack Allocations):在Java虚拟机中,虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

  • 标量替换(Scalar Replacement)

    • 标量:无法再分解为更小数据的数据,例如 JVM 中的原始数据类型(int、long、reference 等)。
    • 聚合量:可以继续分解的数据,例如 Java 中的对象。

    标量替换,就是根据实际访问情况,将一个对象“拆解”开,把用到的成员变量恢复为原始类型来访问。

    简单来说,就是把聚合量替换为标量。

    若一个对象不会逃逸出「方法」,且可以被拆散,那么程序真正执行时就可能不去创建这个对象,而是直接创建它的若干个被该方法使用的成员变量代替。

    将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换不允许对象逃逸出方法范围内。

  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

公共子表达式消除

如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。

若有如下代码:

1
2
3
4
5
6
7
public class Test {
public int t1() {
int a=1, b=2, c=3;
int d = (c * b) * 12 + a + (a + b * c);
return d;
}
}

Javac 编译后生成的字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int t2();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=4, locals=5, args_size=1
# ...
6: iload_3
7: iload_2
8: imul # 计算 b*c
9: bipush 12
11: imul # 计算 (c * b) * 12
12: iload_1
13: iadd # 计算 (c * b) * 12 + a
14: iload_1
15: iload_2
16: iload_3
17: imul # 计算 b*c
18: iadd # 计算 (a + b * c)
19: iadd # 计算 (c * b) * 12 + a + (a + b * c)
20: istore 4
22: iload 4
24: ireturn
# ...

Javac 编译器并未做任何优化。

这段代码进入即时编译器后,将进行如下优化:

编译器检测到 c * b 与 b * c 是一样的表达式,且在计算期间 b 和 c 的值不变,因此:

1
int d = E * 12 + a + (a + E);

此时,编译器还可能进行代数化简(Algebraic Simplification),如下:

1
int d = E * 13 + a + a;

这样计算起来就可以节省一些时间。

数组边界检查消除

假如有一个数组 array,当我们访问数组下标在 [0, array.length) 范围之外的元素时,就会抛出ArrayIndexOutOfBoundsException异常,也就是数组越界了,把这个数组边界检查的例子放在更高的视角来看,大量的安全检查使编写Java程序比编写C和C++程序容易了很多,但也造成了许多额外的开销,如果不处理好它们,就很可能成为一项“Java语言天生就比较慢”的原罪。

为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译期完成的思路之外,还有一种避开的处理思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。