2020/03/26 JIT at a Glance

JIT 是什么?

JIT(Just-In-Time Compilation) 即时编译,是一段代码在第一次将要被执行之前,进行的编译。属于动态编译(运行时编译)的范畴,与之相对的是 AOT(ahead-of-time Compilation) 也叫静态编译。

java 文件经过 javac 编译成字节码文件,再由 JVM 的解释器 interpreter 解释执行。当 JVM 发现某段代码执行地非常频繁,就会认为这段代码是热点代码 (hot spot code) 。JVM 就会将这些代码编译成机器指令,这样就可以直接调用机器指令执行,而不用再进行解释执行。这个将热点代码编译成机器指令的过程就是 JIT 编译,是通过 JIT 编译器来完成的。

什么样的代码算热点代码?

  1. 被多次调用的代码,如果调用次数到达阈值,就会被加入到编译队列中等待编译,这种编译叫标准编译。
  2. 循环体,如果方法中有很长的循环,当计数器到达阈值,整个方法也会被编译,这种编译叫栈上替换 (On Stack Replacement, OSR) 。

为什么 JIT 不是针对所有代码,而只是热点代码?
因为 JIT 也是有成本的。

  1. 首先是时间成本,JIT 编译可能非常耗时,并且不是 100% 能够获取运行速度提升,即便获得运行速度提升,也不一定能抵消 JIT 编译的时间消耗,而且有些代码可能就只执行一次,所以相对来说还是解释执行的成本更低。
  2. 其次是空间成本,将字节码编译成机器码会占用更多的空间。

所以只有热点代码才值得进行 JIT 编译。

JIT 编译器

HotSpotVM 中,解释器和 JIT 编译器都属于执行引擎。JIT 编译器有两种,一个是 Client Compiler ,简称 C1 ,另一个是 Server Compiler ,简称 C2 。JVM 会根据运行模式选择 C1 或 C2 搭配解释器一起使用。默认使用的是 Server Compiler ,通过 -client-server 选项可以指定使用哪种编译器。C1 编译器主要针对客户端程序设计,优化程度小,启动速度快。C2 编译器主要针对长时间执行的程序,更加关注代码的执行时间,所以会对代码进行更大程度的优化,因此启动时间比 C1 长。为了利用各自的优势,达到启动时间和执行效率之间的平衡,从 jdk1.6 开始引入了分层编译 (tiered compilation) ,并在 jdk1.7 作为默认策略。
分层编译是如何分层的?

  1. 第 0 层,解释器解释执行。可以触发第 1 层编译。
  2. 第 1 层,C1 编译,进行简单的优化,将字节码编译成机器码。可以触发第 2 层编译。
  3. 第 2 层及以上,C2 编译,进行耗时较长的优化,可能会进行激进的优化。

解释器和 JIT 是如何配合工作的?
HotSpotVM 为每个方法准备了两个计数器:Invocation Counter 和 Back Edge Counter 。解释器首先解释执行字节码,每执行一次某个方法,对应的计数器加 1 ,当达到阈值,触发 C1 编译,C1 将字节码编译成机器指令,只进行简单的优化。随着执行次数增多,优化会升级,触发 C2 编译,在 C2 编译过程中 JVM 可能会进行一些激进优化(大多数情况下能够提升运行速度的优化),但不一定成功。当出现罕见陷阱 (Uncommon Trap) 即优化失败时,通过逆优化 (deopimization) 进行回退,继续由解释器解释执行。编译过程需要花费时间,程序在运行期间不会等待编译完成,编译是异步进行的,编译完成后会替换代码,再下一次执行时使用。

Java 为什么要有 JIT ,为什么不在 javac 阶段就进行优化?
这是由于 javac 本身就是这么设计的,至于为什么这么设计,一是因为 Java 有一些动态特性导致在 javac 阶段进行优化难度很大,所以干脆不进行优化。举个例子:
有一个类 Foo 有两个方法 m1 和 m2 。

class Foo {
    public String m1() {
        return m2();
    }
    public String m2() {
        return null;
    }
}

Foo foo = new Foo();
foo.m1();

对于这样的类,我们能不能在 javac 编译时将 m1 优化成 return null 呢?不能。
因为我们无法确定会不会在其他时刻会出现一个 Bar 类。

class Bar extends Foo {
    public String m2(){
        return "bar"
    }
}

Foo foo = new Bar();
foo.m1();

此时 m1() 就不应该为 null 了。显然在 javac 编译时想要确定这个问题是十分困难的。二是将优化放在 JIT ,可以让其他运行在 JVM 上的其他语言也可以享受到优化的收益。

有 JIT 比没有 JIT 要快么?
其实不一定,还要具体问题具体分析。如果没有 JIT ,那么执行过程是 字节码->解释器解释执行->结果,有 JIT 的过程是 字节码->JIT 编译->执行编译后的代码->结果。经过 JIT 编译之后的代码执行要比解释器解释执行要快,但是 JIT 编译也是需要耗时的,并不一定 JIT 编译的时间要肯定小于解释执行的时间。

JIT 进行了哪些优化?

  1. 公共子表达式消除
    如果一个表达式 E 已经计算得到结果 R ,并且再后续再次出现 E 时它的参数没有发生变化,则可以直接使用 R 代替 E 。
  2. 方法内联
    方法内联是在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。比如 getter/setter 方法,如果没有内联,在调用 getter/setter 方法时,程序需要保存当前方法的执行位置,并创建 getter/setter 的栈帧入栈,执行完调用后再出栈,恢复到当前方法继续执行。如果有内联,会将方法调用优化成字段访问,从而提高程序运行速度。内联发生在 C2 阶段,由 jit compiler 解析字节码生成 IR 图,并在 IR 图上进行优化,如果进行方法内联,就将 IR 图中的对应节点替换成目标方法的 IR 图,然后对新的 IR 图进行进一步优化。
  3. 逃逸分析