为什么 JVM 不用 JIT 全程编译?

JVM在解释字节码时,有些经常使用的热代码可以通过JIT直接编译为本地机器码,其他的仍需解释执行,既然JIT编译执行要比JVM逐句逐句的解释执行快,为什么不全部用JIT编译执行?
关注者
280
被浏览
6105
之前已经回答过类似的问题,所以先放俩传送门:
HotSpot是较新的Java虚拟机技术,用来代替JIT技术,那么HotSpot和JIT是共存的吗? - RednaxelaFX 的回答
JIT编译,动态编译与自适应动态编译 - 编程语言与高级语言虚拟机杂谈(仮) - 知乎专栏
请先读读这两篇,在看我下面的补充说明。

将其中与题主这个问题相关的部分摘抄出来:
换一个JVM实现,来看看JRockit VM。这个JVM使用纯编译的执行引擎,没有解释器。但它有多层编译:第一次执行某个方法之前会用非常低的优化级别去JIT编译,然后等到某个方法足够热之后再用较高的优化级别重新编译它。
这种系统既是严格意义上的JIT编译(第一次执行某个方法前编译它),又是自适应编译(找出热点再进行编译)。

加入Crankshaft编译架构之后的V8 JavaScript引擎也是如此:有两层编译,第一次是严格的JIT编译而第二层是自适应编译。

所以说JIT编译与自适应编译可以共存。只不过HotSpot VM因为有解释器来承担第一层执行的任务,所以不使用JIT编译而已。

这样就先解决了“是不是”的问题——JVM是不是“不用JIT全程编译”?
  • HotSpot VM、J9 VM:是,这两个JVM默认用混合模式执行引擎,以解释为基础,然后对热点做编译;
    • 这两者同时还支持AOT编译执行。J9 VM对AOT编译的支持早就有了;HotSpot VM的将在JDK9的某个更新版中发布,请参考Java Goes AOT(打不开请自备工具…)
  • JRockit VM:不是,JRockit VM没有解释器,只能对所有Java方法都做JIT编译;
  • Jikes RVMMaxine VMJato VM等:跟JRockit类似,只有JIT编译器而没有解释器,因而只能JIT编译执行;
  • Excelsior JET:可配置为用纯AOT编译,或者AOT+JIT编译执行。

然后说为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

1. 编译的时间开销

解释器的执行,抽象的看是这样的:
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
而要JIT编译然后再执行的话,抽象的看则是:
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。

然而这JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。

所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。
怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
  • 只被调用一次,例如类的初始化器(class initializer,<clinit>()V)
  • 没有循环

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
只有对频繁执行的代码,JIT编译才能保证有正面的收益。

况且,并不是说JIT编译了的代码就一定会比解释执行快。切不可盲目认为有了JIT就可以鄙视解释器了,还是得看实现细节如何。
有个很经典的例子:LuaJIT 2里有一个实现得非常优化的解释器,它解释执行的速度甚至比LuaJIT 1的JIT编译后的代码的速度还要快。

2. 编译的空间开销

举个最简单的例子:
  public static int foo() {
    return 42;
  }
其字节码大小只有3字节:
  public static int foo();
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        42
         2: ireturn
而由Linux/x86-64上的HotSpot VM的Server Compiler将其编译为机器码后,则膨胀到了56字节:
  # {method} 'foo' '()I' in 'XX'
  #           [sp+0x20]  (sp of caller)
  0x00000001017b8200: sub    $0x18,%rsp
  0x00000001017b8207: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - XX::foo@-1 (line 3)
  0x00000001017b820c: mov    $0x2a,%eax
  0x00000001017b8211: add    $0x10,%rsp
  0x00000001017b8215: pop    %rbp
  0x00000001017b8216: test   %eax,-0x146021c(%rip)        # 0x0000000100358000
                                                ;   {poll_return}
  0x00000001017b821c: retq   
  0x00000001017b821d: hlt    
  0x00000001017b821e: hlt    
  0x00000001017b821f: hlt    
[Exception Handler]
[Stub Code]
  0x00000001017b8220: jmpq   0x00000001017b50a0  ;   {no_reloc}
[Deopt Handler Code]
  0x00000001017b8225: callq  0x00000001017b822a
  0x00000001017b822a: subq   $0x5,(%rsp)
  0x00000001017b822f: jmpq   0x000000010178eb00  ;   {runtime_call}
  0x00000001017b8234: hlt    
  0x00000001017b8235: hlt    
  0x00000001017b8236: hlt    
  0x00000001017b8237: hlt    

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。上面的例子比较极端一些,但还是很能反映现实状况的。

同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”(code size explosion)。

3. 编译时机对优化的影响

有些JIT编译器非常简单,基本上不做啥优化,也倒也没啥影响。
但现代做优化的JIT编译器都非常注重使用profile信息,而profile是需要通过执行用户程序来获取的。
这样,编译得太早的话,就来不及收集足够profile信息,进而会影响优化的效果;而编译太迟的话,即便收集了很多高质量的profile,但却也已经付出了profile的额外开销,编译出来的代码再快或许也弥补不过来了。

在解释器里实现收集profile的功能,等解释执行一段时间后再触发JIT编译,这样就可以很好的平衡收集profile与编译优化这两方面。

当然,收集profile也可以在JIT编译器里做:一开始先JIT编译生成收集profile的版本的代码,等收集了到足够profile后触发重新编译,再生成出优化的、不带profile的版本。JRockit基本上就是这样做的。这方面在本回答开头放的链接里已有说明。