JIT简介
JIT编译,全称 just-in-time compilation,也就是即时编译。通过JIT技术,能够做到Java程序执行速度的加速。那么,是怎么做到的呢?
Java是一门解释型语言(或者说是半编译,半解释型语言)。Java通过编译器javac先将源程序编译成与平台无关的Java字节码文件(.class),再由JVM解释执行字节码文件,从而做到平台无关。
但是,有利必有弊。对字节码的解释执行过程实质为:JVM先将字节码翻译为对应的机器指令,然后执行机器指令。很显然,这样经过解释执行,其执行速度必然不如直接执行二进制字节码文件。
为了提高执行速度,便引入了 JIT 技术。
Java什么时候引入JIT?
1996 年 1 月 23 日,Sun 公司发布 JDK 1.0,使用的虚拟机是 Classic VM,这个时候就可以选择使用 JIT 编译了。
JIT如何运行
当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。
JIT何时才运行?
先说说常用的HotSpot VM,HotSpot VM得名于它得混合模式执行引擎:这个执行引擎包括解释器和自适应编译器(adaptive compiler)。
默认配置下,一开始所有Java方法都由解释器执行。解释器记录着每个方法得调用次数和循环次数,并以这两个数值为指标去判断一个方法的“热度”。显然,HotSpot VM是以“方法”为单位来寻找热点代码。
等到一个方法足够“热”的时候,HotSpot VM就会启动对该方法的编译。这种在所有执行过的代码里只寻找一部分来编译的做法,就叫做自适应编译(adaptive compilation)。
为了实现自适应编译,执行引擎通常需要有多层(1.8之前,分层编译默认是关闭的,可以添加-server -XX:+TieredCompilation
参数进行开启):至少要有一层能够处理初始阶段的执行,然后再让自适应编译处理其中部分代码。
JIT编译与自适应编译都属于“动态编译”(dynamic compilation),或者叫“运行时编译”的范畴。特点是在程序运行的时候进行编译,而不是在程序开始运行之前就完成了编译;后者也叫做“静态编译”(static compilation)或者AOT编译(ahead-of-time compilation)。
为什么JVM不是一开始使用JIT呢?
前面说到JIT是为了提高字节码运行效率,为什么JVM不是一开始就把代码编译成机器码呢?
不同JVM实现存在差异:
1.HotSpot VM、J9 VM:是,这两个JVM默认用混合模式执行引擎,以解释为基础,然后对热点做编译;
2.JRockit VM:JRockit VM没有解释器,只能对所有Java方法都做JIT编译;
3.Jikes RVM、Maxine VM、Jato VM等:跟JRockit类似,只有JIT编译器而没有解释器,因而只能JIT编译执行;
4.Excelsior JET:可配置为用纯AOT编译,或者AOT+JIT编译执行。
为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎?
- 编译的时间开销
解释器的执行,抽象的看是这样的:
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
JIT编译然后再执行的话,抽象的看则是:
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。
JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。
所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。
怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
- 只被调用一次,例如类的初始化器(class initializer,()V)
- 没有循环
对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
只有对频繁执行的代码,JIT编译才能保证有正面的收益。
况且,并不是说JIT编译了的代码就一定会比解释执行快。切不可盲目认为有了JIT就可以鄙视解释器了,还是得看实现细节如何。
- 编译的空间开销
举个最简单的例子:
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)。
- 编译时机对优化的影响
有些JIT编译器非常简单,基本上不做啥优化,也倒也没啥影响。
但现代做优化的JIT编译器都非常注重使用profile信息,而profile是需要通过执行用户程序来获取的。
这样,编译得太早的话,就来不及收集足够profile信息,进而会影响优化的效果;而编译太迟的话,即便收集了很多高质量的profile,但却也已经付出了profile的额外开销,编译出来的代码再快或许也弥补不过来了。
在解释器里实现收集profile的功能,等解释执行一段时间后再触发JIT编译,这样就可以很好的平衡收集profile与编译优化这两方面。
当然,收集profile也可以在JIT编译器里做:一开始先JIT编译生成收集profile的版本的代码,等收集了到足够profile后触发重新编译,再生成出优化的、不带profile的版本。JRockit基本上就是这样做的。这方面在本回答开头放的链接里已有说明。
一段代码需要执行多少次才会触发JIT优化呢?
前面说到只有对频繁执行的代码,JIT编译才能保证有正面的收益。
那么执行多少次才算频繁呢?
通常这个值由-XX:CompileThreshold
参数进行设置:
-XX:CompileThreshold = 10000
使用client编译器时,默认为1500;
使用server编译器时,默认为10000;
client、server编译:
$ java -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) Client VM (build 25.291-b10, mixed mode, sharing)
Client VM表示我们的虚拟机类型,mixed mode表示虚拟机以混合模式工作。
client VM 使用的是C1编译器;
server VM 使用的是C2编译器;
C1:编译时间短,优化策略简单。启动快,内存占用少,编译快,针对桌面应用程序优化(比如GUI),为在客户端环境中减少启动时间而优化。
C2:编译时间长,优化策略复杂。启动慢,但是一旦运行起来后,性能将会有很大的提升,因为编译更完全,效率高,针对服务端应用优化。
C1,C2都属于JIT编译技术,是JIT的不同实现
意味着如果方法调用次数或循环次数达到这个阈值就会触发标准编译,更改CompileThreshold标志的值,将使编译器提早(或延迟)编译。
参考:https://www.zhihu.com/question/37389356/answer/73820511