运行期优化
运行期优化主要靠编译执行,虽然编译执行有“编译”两个字,但要认识到编译执行是在运行期而不是编译期进行的。
该篇重点难点是JIT编译器的概念,其优化原理(编译技术)可适当了解。
1. JIT编译器
当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为热点代码(Hot Spot Code)。为了提高热点代码的执行效率,运行时虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器叫做即时编译器(Just In Time Compiler,JIT编译器)。
Java虚拟机规范并没有规定Java虚拟机一定要有JIT编译器,更没有规定具体实现的细节,但JIT编译器性能的好坏,代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,也是虚拟机中最核心、最能体现虚拟机技术水平的部分。
2. 解释器与编译器
主流的虚拟机很多都采用解释器与编译器并存的架构。当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间,立即执行;程序运行后,随着时间推移,编译器逐渐发挥作用,把越来越多代码编译成本地代码,可以获取更高的执行效率。当程序运行环境中内存限制较大,可以使用解释执行节省内存,反之使用编译执行提升效率。另外,当激进优化的假设不成立时,可通过逆优化退回到解释状态继续执行。
三大商用虚拟机中的JRockit内部没有解释器,它主要面向服务端的应用,这类应用一般不重点关注启动时间。
HotSpot虚拟机中内置了两个JIT编译器,分别称为Client Compiler和Server Compiler,或简称为C1编译器和C2编译器(也叫Opto编译器)。为了在程序启动响应速度和运行效率之间取得平衡,HotSpot虚拟机采用分层编译(Tiered Compilation)策略。分层编译根据编译器编译、优化的规模和耗时,划分出不同编译层次,包括:
- 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
- 第1层,也称为C1编译,将字节码编译成本地代码,进行简单的、可靠的优化,如有必要加入性能监控逻辑。
- 第2层(或以上),也称为C2编译,也是将字节码编译成本地代码,但会开启一些编译耗时较长的优化,甚至根据性能监控信息开启一些不可靠的激进优化。
添加 -Xint 参数使JVM以解释模式运行,所有的字节码将被直接执行,而不会编译成本地码。-Xcomp则会以编译模式进行。java -version 显示的mixed mode表示Java程序执行时可能是解释执行,也可能是编译执行。
3. 编译对象与触发条件
被JIT编译器编译的代码有两类:
- 被多次调用的方法
- 被多次执行的循环体
对于第一种情况,由于是方法调用触发的编译,编译器会把整个方法当成编译的对象(JIT标准编译方式);对于第二种情况,编译器仍会以整个方法作为编译对象,但由于发生在循环体执行的过程中,因此形象地称为栈上替换(On Stack Reaplacement,OSR)。
判断代码是不是热点代码,是否需要出发即时编译,称为热点探测(Hot Spot Detection),主要有两种方式:
- 基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期性检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那个方法就是热点方法。优点:实现简单、高效,容易获取方法调用关系,缺点:难以精确确认一个方法的热度,易受线程阻塞和外界因素干扰。
- 基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至代码块)建立计数器,统计方法的执行次数,执行次数超过一定的阈值就是热点方法。优点:统计结果精确严谨,缺点:实现麻烦,为每个方法维护一个计数器,不能直接获取方法调用关系。
Hot Spot虚拟机使用基于计数器的热点探测,为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)
显然,方法调用计数器统计的是方法调用的次数,方法调用计数器触发即时编译的流程图如下:
缺省情况下,当超过一定的时间限度,方法调用次数不足以触发即时编译,则方法计数会减少一半,称为计数器的热度衰减(Counter Decay),这段时间称为半衰周期(Counter Half Life Time)
回边计数器统计一个方法中循环体代码执行的次数,回边指的是在字节码中遇到控制流向后跳转的指令,其触发流程与方法调用计数器类似。但回边计数器没有热度衰减。
4. 编译过程
编译未完成时,仍以解释执行的方式去执行。Client Compiler的编译过程分为三个阶段:
Server Compiler专门面向服务端的应用而特别调整,较为复杂,会执行所有经典的优化动作,虽然编译速度比Client Compiler慢,但比静态优化编译器要快得多。
5. 编译技术
5.1 公共子表达式消除
如果一个表达式E已经计算过,并且从先前的计算到现在的E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,直接使用前面计算的结果代替即可。举个例子,有以下的计算式:1
int s = (c * b) * 12 + a + (a + b * c);
可优化为1
int s = E * 12 + a + (a + E);
5.2 数组边界检查消除
Java是一门动态安全的语言,对数组的读写访问不像C/C++那样本质是裸针操作。在访问数组的时候,系统会自动进行上下界检查,即隐式的条件判断,对有大量的数组访问操作的代码来说,这会一定程度上影响到性能,但又不能不检查。但实际上,不是每次访问数组都必须要检查一遍的,比如:数组下标是个常量,编译期根据数据流分析判断是否越界,运行期就不用判断了。又如数组访问发生在循环中,使用循环变量进行访问,同样通过数据流分析判断循环变量是否在数组大小范围内,就可以消除检查。
5.3 方法内联
方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本外,还为其他优化手段建立基础。方法内联的优化行为看似很简单,不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用。但实际上,除了之前说过的非虚方法,其他方法在调用时都要进行方法接收者的多态选择,并且可能存在多个方法版本,特别是动态分派的重写方法,只能在运行期判断方法接收者是子类还是父类。
为了解决虚方法的方法内联问题,Java虚拟机团队引入了一种名为“类型继承分析”(Class Hierarchy Analysis,CHA)的技术,用于确定在目前已加载的类中,某接口是否有多于一种的实现,某个类是否存在子类等信息。
编译器在进行内联时,非虚方法直接内联,虚方法则向CHA查询是否有多个方法版本,如果查询结果只有一个,则可以进行内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联(Guarded Inlining),如果后续的程序加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回解释执行或重新编译。如果CHA查询结果有多个结果可以选择,编译器会使用内联缓存来完成方法内联,工作原理为:未发生方法调用前,内联缓存为空,当第一次方法调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较方法接收者的版本,如果方法接受者的版本一样,那这个内联可以一直用下去,否则取消内联,查找虚方法表进行方法分派。
5.4 逃逸分析
逃逸分析(Escape Analysis)的基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为参数传递到其他方法,称为方法逃逸;甚至可能被外部线程访问,譬如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问这个对象,则可对这个对象做些优化:
- 栈上分配(Stack Allocation):让对象在栈上分配而不是在堆上,对象所占的内存虽栈帧出栈而释放。
- 同步消除(Synchronization Elimination):一个变量不会逃逸出线程,那这个变量的读写就不会有竞争,对这个变量实施的同步措施就可以消除掉。
- 标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,如:Java虚拟机中的原始类型(int、long等基本数据类型以及引用类型)。相对的,如果一个数据可以继续分解,那么它就被称作聚合量(Aggregate),如Java中的对象。将一个Java对象拆散,根据程序访问的情况,将使用到的成员变量恢复原始类型来访问就叫标量替换。
Copyright © 2018, GDUT CSCW back-end Kanarien, All Rights Reserved