javac生成字节码,字节码可以解释执行,也可以进一步通过JIT编译执行,JIT把字节码变为机器码。
JVM采用解释器interpreter与JIT编译器compiler并存的架构:二者相互配合;当程序需要迅速启动和执行的时候,解释器首先发挥作用,省去编译的时间,立即执行。程序运行后,编译器逐渐把高频调用代码(热点代码)用JIT编译为本地代码(平台相关机器码),并进行优化,以获取更高的执行效率。JIT可以采用激进的优化方式,如果不成立可以回退到解释器执行。
HotSpot内置了两个JIT编译器:Client/Server Compiler;默认采用解释器和其中一个编译器直接配合的方式,虚拟机根据版本和宿主机器的硬件性能自动选择运行模式,进而根据运行模式决定使用哪个编译器。用户也可以用-client/server参数强制设定虚拟机的运行模式。另外,可以通过参数指定混合/解释/编译模式。-Xint:强制虚拟机运行于解释模式;-Xcomp:强制虚拟机运行于编译模式
一、JIT分层编译
client/server编译器同时工作,C1/client编译获取更高的编译速度,C2/server获取更好的编译质量。解决的问题:JIT编译为本地代码进行优化都需要占用运行时时间,程序启动响应时间和运行效率需要平衡
第0层:解释执行,解释器不开启性能监控,可触发第1层;第1层:C1编译,将字节码编译为本地代码,简单可靠优化,可加入性能监控逻辑;第2层:C2编译,启动耗时长的优化,根据性能监控信息进行激进优化。
二、JIT编译对象与触发条件 - 热点代码
热点代码包含多次调用的方法和多次执行的循环体OSR(栈上替换),方法帧还在栈上就被替换了。
热点探测用于判断代码是不是热点代码。
1. 基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那就是热点方法。
2. 基于计数器的热点探测:为每个方法建立计数器,当调用次数超过阈值就认为是热点代码;HotSpot为每个方法设备两类计数器:
1)方法调用计数器:client模式默认阈值1500;server模式10000;可通过CompileThreshold参数配置。方法被调用时,先检查该方法有没有JIT编译过的版本,有则执行本地代码;没有则将方法的调用计数器加1,判断阈值,超过阈值则向JIT提交编译请求。执行引擎不会同步等待编译请求完成,而是继续进入解释器解释执行;JIT编译完成后,方法的调用入口地址自动改写成新的。BackgroundCompilation用于配置禁止后台编译,执行线程提交编译请求后保持等待。
热度衰减:采用半衰周期内调用次数,而非绝对次数;超过半衰周期后调用次数仍未达到阈值,调用计数器减半。UseCounterDecay配置关闭热度衰减,让计数器统计绝对值;CounterHalfLifeTime配置半衰周期;
2)回边计数器:统计方法中循环体的执行次数;回边表示字节码中遇到的控制流向后跳转的指令。阈值:根据方法调用计数器阈值/OSR比率/解释器监控比率(server模式)计算得出;统计绝对次数。OnStackReplacePercentage配置OSR比率;InterpreterProfilePercentage配置解释器监控比率
三、JIT编译过程
1. Client编译
简单快速的三段式编译器,关注点在局部优化,放弃耗时长的全局优化。
1. 平台独立的前端把字节码构造为高级中间代码HIR,基础优化如方法内联/常量传播;
2. 平台相关的后端把HIR转化为低级中间代码LIR,优化如空值检查消除/范围检查消除;
3. 平台相关的后端把LIR转化为机器代码,使用线性扫描算法分配寄存器,窥孔优化;
2. Server编译
高级优化如无用代码消除,循环展开,循环表达式外提,消除公共子表达式,常量传播,基本块重排序,范围检查消除,空值检查消除。根据解释器/client编译器提供的性能监控信息,进行激进优化,如守护内联,分支频率预测。
四、编译优化技术
程序员的共识:编译执行比解释执行快,因为虚拟机几乎所有优化都在JIT的设计中。
1)方法内联:重要性高于其他优化措施,通常放在优化序列靠前的位置;内联可以去掉方法调用的成本(如建立帧),同时方法内联膨胀便于在更大范围上采取后续优化手段。难点在于java方法大多是虚方法,运行时才知道方法接受者,属于激进优化;内联缓存:在未发生方法调用前,内联缓存为空,第一次调用后,记录方法接受者的版本信息,如果后续方法接受者一致,那就使用该内联。
2)冗余访问消除:y=b.value; z=b.value,替换为z=y;
3)复写传播:y=b.value; z=b.value;z变量没有存在的必要,可以用y变量替换
4)公共子表达式消除:如果一个表达式E已经计算过了,并且所有变量值没有变化,那么E就是公共表达式,直接用之前的计算结果替换E。根据应用范围分为局部/全局。
5)数组边界检查消除:java数组访问array[i]有自动的上下界限访问检查,这种检查降低了编码出错的概率,但也有开销,优化方式比如在循环中进行数组访问,只要循环的终点最大值没有越界,那么前面数组节点的访问可以不做检查。
6)逃逸分析:分析对象动态作用域,当对象在方法中被定义,他可能通过参数传递方式被其他方法引用,称为方法逃逸,甚至可能通过赋值给类变量而被其他线程引用,称为线程逃逸。如果能证明对象不会逃逸,那么可以进行高效优化。
7)栈上分配内存:对象放在栈帧而不是堆,对象内存随着出栈销毁,减轻GC压力
8)同步消除:如果对象不被线程共享则消除耗时的同步措施
9)标量替换:标量指无法再分解的数据,如java原始类型;与之相对的是聚合量,如java对象。标量替换指:把java对象拆散,根据程序访问情况,将用到的成员变量作为原始类型访问。可以省去创建对象的开销,把对象的成员变量存在栈上,而栈上的数据通常会被存储到寄存器。
五、Java编译器 vs c/c++编译器
总体而言,java用运行效率的劣势换取开发效率的优势。JIT占用运行时时间,限制了大规模优化技术的引入;Java类型检查提高安全性的同时,也是耗时的操作;Java中大量使用虚方法,导致内联难度加大;Java由虚拟机而不是程序员负责GC,效率上也有牺牲。
JIT编译器配置:
PrintCompilation:打印被即时编译的方法名称,带%的是OSR触发;
PrintInlining:输出方法内联信息
PrintAssembly:打印生成字节码的反汇编结果(汇编代码)
PrintOptoAssembly / PrintLIR:输出中间代码