为什么 JVM 不用 JIT 全程编译

从知乎扣出来的内容 https://www.zhihu.com/question/37389356

作者:RednaxelaFX
链接:https://www.zhihu.com/question/37389356/answer/73820511
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

什么有些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基本上就是这样做的。这方面在本回答开头放的链接里已有说明。
posted on 2017-08-16 16:33  devilwind  阅读(666)  评论(0编辑  收藏  举报