JIT:Java程序最初是由解释器解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些“热点代码”编译成机器码,提高运行效率。
1. 为什么要使用解释器和编译器并存?
- 当需要程序快速启动和执行的时候,可以使用解释器,省去编译时间,立即执行。随着时间推移,编译器逐渐发挥作用,编译为机器码,可以提高效率。
- 内存限制较大时,解释器执行可以节约内存,反之编译器执行可以提高效率。
- 解释器可以作为编译器激进优化的逃生门。
2. HotSpot的两个不同的即时编译器
- Client Compiler:C1编译器
- Server Complier:C2编译器
- interpreted mode:纯解释模式
- compiled mode:纯编译模式,无法编译是解释器还是会介入的
- mixed mode:解释器和编译器搭配
要想编译出优化程度高的代码,需要时间成本,所以虚拟机为了权衡,采用了分层编译。采用分层编译后,C1和C2同时工作,C1获得更高的编译速度,C2获得更好的编译质量。
- 第0层:程序解释执行,解释器不开启性能监控,可触发第1层编译。
- 第1层:C1编译,简单、可靠的优化,必要时加入性能监控。
- 第2层:启动一些编译耗时较长的优化,甚至根据性能监控信息进行不可靠的激进优化。
3. 编译对象和触发条件
热点代码:
- 被多次调用的方法:整个方法为编译对象,虚拟机标准的JIT编译方式。
- 被多次执行的循环体。虽然循环在方法体内,但还是以整个方法为编译对象,这种编译方式为栈上替换,因为发生在方法执行过程中,方法帧还在栈上。
热点探测:
- 基于采样的热点探测:虚拟机周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,说明是热点。优点:简单高效、容易获取方法调用关系。缺点:很难精确确认一个方法的热度,容易受到线程阻塞或别的干扰。
- 基于计数器的热点探测:虚拟机为每个方法甚至代码块建立计数器,统计方法执行次数。优点:精确。缺点:成本高。
HotSpot使用的是第二种方法。它有两类计数器:方法调用计数器和回边计数器。
方法调用计数器:统计方法被调用的次数,默认阈值Client下1500次,Server下10000次。
如果不做任何设置,方法调用计数器统计的不是方法调用的绝对次数,而是一段时间内的次数,超过一定时间后,计数器会减半。这种衰减实在GC时顺便进行的。可以用-XX:CounterDecay设置是否衰减,如果不衰减,那么随着时间的推移,总会达到次数进行编译。
回边计数器:统计一个方法中循环体执行的次数。准确的说是回边次数,空循环不会回边,只跳转到自己。
client模式阈值:方法调用计数器阈值OSR比率/100,OSR比率默认933。
server模式阈值:方法调用计数器阈值(OSR比率 - 解释器监控比率)/100,OSR比率默认140,解释器监控比率默认33。
回边计数器没有热度衰减。
4. 一些编译技术
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组范围检查消除。
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
公共子表达式消除:如果一个表达式前面已经计算过了,后面表达式的变量也没有变化过,这个表达式就是公共子表达式,就没有必要计算了。
//javac不会作任何优化,但是JIT会优化b * c
int d = (c * b) * 12 + a + +(a + b * c)
数组边界检查消除:Java在访问数组元素时会自动进行上下界的范围检查,越界则抛出ArrayIndexOutOfBoundsException,但是这也是一种性能负担。虚拟机根据情况在编译器判断是否可能越界,如果不越界执行时就不需要检查了。
类似情况还有NullPointException,除数为0异常等。
方法内联:编译器最重要的优化手段之一,消除了方法调用的成本,还为其它优化建立了基础。
- 方法内联不是代码复制那么简单,因为Java的方法(除了编译期解析的),编译期都不能确定版本,运行期才可以。采用“类型继承关系分析CHA”解决这一问题。
类型继承关系分析CHA:基于整个应用,确定目前已加载的类中,某个接口是否有多于一种实现,某个类是否有子类,子类是否为抽象类等信息。
- 编译器进行内联时,如果是非虚方法,那么直接内联。如果遇到虚方法,则查询CHA是否有多个版本,如果只有一个,那么进行内联(激进的,需要逃生门)。如果后续虚拟机没有加载其它类改变继承关系,则一直内联,否则退回解释状态,或重新编译。
- 如果CHA查询出多个版本,编译器会使用内联缓存。在未发生调用前,缓存为空,发生调用后,缓存记录下方法版本信息,以后每次调用都比较版本,如果一直,内联继续,如果不一致,取消内联。查找虚方法表。
逃逸分析:分析对象的动态作用域。不是代码优化手段,而是为其它手段提供依据。
- 方法逃逸:一个对象被外部方法引用,如传参。
- 线程逃逸:对象被外部线程访问到,如赋值给类变量。
如果证明一个对象不会逃逸,那么可以进行一些高效的优化。
栈上分配:一般对象都在堆上分配,各个线程共享,GC回收内存需要耗费时间。如果一个对象确定不会逃逸出方法,比如局部变量,那么分配在栈上就很舒服,可以随栈帧出栈而销毁。GC压力减小。
同步消除:如果一个对象确定不是线程逃逸,那么就不会被其它线程访问,就不存在竞争,完全可以消除同步。
标量替换:如果一个对象确定不会被外部访问,那么真正执行的时候就不需要创建这个对象,改为在栈上创建对象拆散后的标量。