运行期优化
前言
在部分的商用虚拟机中,Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler),也就是JIT。
即时编译器不是虚拟机必须的部分,但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。
HotSpot既包含解释器也包含即时编译器,本篇文章讲述的都是关于HotSpot虚拟机的运行期优化。
解释器和编译器
解释器是一条一条的解释执行源语言。
编译器是把源代码整个编译成目标代码,执行时不再需要编译器,直接在支持目标代码的平台上运行,这样执行效率比解释执行快很多。
解释器的优势:
1. 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行;
2.当程序运行环境中内存资源限制较大,可以使用解释执行节约内存;
3.解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。
编译器的优势:
1.在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之中,可以获取更高的执行效率;
2.在程序运行环境资源比较充足的时候,使用编译器可以提高代码的执行效率;
虽然上述把编译器和解释器分开来讲,但其实在整个的虚拟机执行架构中,解释器和编译器经常配合工作,如下图所示:
HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler 和 Server Compiler,或者简称为C1编译器和C2编译器(也叫Opto编译器)。目前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或者Server模式。
编译对象和触发条件
在运行过程中会被即时编译器编译的“热点代码”有两类,即:
(1)被多次调用的方法;
(2)被多次执行的循环体;
对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式。
对于第二种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法的执行过程中,因此形象地称之为栈上替换(On Stack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)。
那么到底执行多少次才算是多次执行呢?也就是说即时编译器触发的条件是什么呢?
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:
1.基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。
优点:实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可);
缺点:很难精确的确认一个方法的热度,容易受到线程阻塞或别的外界因素的影响而扰乱热点探测;
2.基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方式的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值就认定它是“热点方法”。
优点:结果更加的精确和严谨;
缺点:实现起来比上一种方式更加麻烦,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系;
在HotSpot中使用的是第二种—基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阀值,当计数器超过阀值溢出了,就会触发JIT编译。来分别看一下:
❤ 方法调用计数器:
顾名思义,这个计数器就用于统计方法被调用的次数,它的默认阀值在Client模式下是1500次,在Server模式下是10000次,这个阀值可以通过-XX:CompileThreshold来人为设定。当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器之和是否超过调用计数器的阀值。如果已经超过阀值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,也就是一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾回收时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行的时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰期的时间,单位是秒(S)。
❤ 回边计数器:
它的作用是统计一个方法体中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器的目的就是为了触发OSR编译。HotSpot虚拟机也提供了一个类似于方法调用计数器阀值-XX:CompilerThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的虚拟机实际上并未使用此参数,因此,我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阀值,其计算公式如下:
Client模式下:方法调用计数器阀值 * OSR比率 / 100。其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机的回边计数器的阀值为13995.
Server模式下:方法调用计数器阀值 * (OSR比率 - 解释器监控比率)/ 100。其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式下虚拟机回边计数器阀值为10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器和回边计数器值之和是否超过回边计数器的阀值。当超过阀值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环体执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次进入该方法的时候就会执行标准编译过程(就是将整个方法进行即时编译)。
编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然按照解释方式继续执行,而编译动作则在后台的编译线程中进行。可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
在后台编译的过程中,Server Compiler 和Client Compiler 两个编译器的编译过程是不一样的。
对于Client Compiler 来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
Server Compiler 是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++编译器使用 -O2参数时的优化强度,它会执行所有经典的优化动作。如无用代码消除、循环展开、循环表达式外提,消除公共子表达式、常量传播等。还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除等,另外还可能根据解释器或Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。
编译优化技术
Java程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,出去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器中,因此,一般来说,即时编译器产生的本地代码会比Javac产生的字节码更加优秀。
在Sun官方的Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、在即时编译器中采用的优化技术列表,其中有不少经典编译器的优化手段,也有许多针对Java语言(准确的说是针对运行在Java虚拟机上的所有语言)本身进行的优化技术,下面列举几项具有代表性的优化技术:
❤ 语言无关的经典优化技术之一:公共子表达式消除
❤ 语言相关的经典优化技术之一:数组范围检查消除
❤ 最重要的优化技术之一:方法内联
❤ 最前沿的优化技术之一:逃逸分析
公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。举个简单的例子,假设存在如下代码:
int d = (c * b) * 12 + a + (a + b * c);
如果这段代码交给Javac编译器则不会进行任何优化。但是当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到“c * b”与“b * c”是一样的表达式,而且计算期间b与c的值是不变的。因此,这条表达式就可能被视为:
int d = E * 12 + a + (a + E);
这时,编译器还可能进行另外一种优化:代数简化,把表达式变为:
int d = E * 13 + a * 2;
这样,表达式进行变换之后,再计算起来就可以节省一些时间了。
数组边界检查消除
我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样在本质上是裸指针操作,如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候将会自动进行上下界的范围检查,即检查i>=0&&i<foo.length这个条件,否则将会抛出一个数组下标越界异常。这对开发者来说是一件好事,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击,但是对于虚拟机来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,无疑也是一种性能负担。
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以商量的。比如数组下标是一个常量,只要在编译期间根据数据流分析来确定foo.length的值,并判断下标有没有越界,执行的时候就不需要判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析尽可以判定循环变量的取值范围永远在区间[0, foo.length)之间,那整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
方法内联
最重要的优化手段之一。它的目的主要有两个:去除方法调用的成本(如建立栈帧等)、为其他优化建立了良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段。方法内联举个例子:
public final int getA() { getA()语句1; getA()语句2; getA()语句3; getA()语句4; getA()语句5 }
public static void main(String[] args) { main语句1; main语句2; int i = getA(); main语句3; main语句4 }
优化之后:
public static void main(String[] args) { main语句1; main语句2; getA()语句1; getA()语句2; getA()语句3; getA()语句4; getA()语句5; main语句3; main语句4 }
从效果上来看,无非是把getA()方法中的内容原封不动地拿到main函数中,但是这样却少了保护现场、恢复线程、建立栈帧等一系列的工作,并且代码一膨胀,原来方法A有5行代码,方法B有6行代码,方法C有7行代码,对于三个方法各自运行来说可能没有什么好优化的,但是三个方法合起来放到main函数中,就有了很大的优化空间了。
讲到这里,是否理解为什么要尽量把方法声明为final?
因为Java有多态的存在,运行时调用的是哪个方法可以根据实际的子类来确定,极大的增强了灵活性,但是这样的话,编译期间同样也无法确定应该使用的是哪个版本,所以无法被内联。但是声明为final的方法不一样,这些方法无法被重写,所以调用类A的B方法,运行时的调用的必然是类A的B方法,这样的话,这个方法就可以被内联。
逃逸分析
目前Java虚拟机中比较前沿的优化技术,它并不是直接优化代码的手段,而是为其他优化手段提供了分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中去,称为方法逃逸。甚至可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法外或者线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:
(1)栈上分配
Java虚拟机中,对象在堆上分配这个众所周知。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象和整理内存都要耗费时间。如果确定一个对象不会逃逸出方法之外,那么让这个对象在栈上分配是一个不错的主意,对象所占用的内存空间就可以随着栈帧出栈而销毁,这样垃圾收集系统的压力将会小很多。
(2)同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施就可以消除掉。
(3)标量替换
标量是指一个数据已经无法再分解成更小的数据来表示了,Java中的基本数据类型及引用类型都不能进一步分解,因此,它们可以称为标量。相对的,一个数据如果还可以继续分解,那么就称为聚合量,Java中的对象就是最典型的聚合量。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员在栈上分配和读写外,还可以为后续进一步的优化手段创建条件。
逃逸分析技术现状和未来
关于逃逸分析的论文1999年就已经发表,但直到Sun JDK1.6才实现了逃逸分析而且直到现在这项优化尚未足够成熟,仍有很大改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定能高于它的消耗。虽然在实际测试结果中,实施逃逸分析后的程序往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能有所下降。
如果有需要,并且确认对程序运行有益,可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,就可以使用参数-XX:+EliminateAllocations来开启标量替换,使用参数-XX:+EliminatLocks来开启同步消除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。
尽管目前逃逸分析技术仍不是十分成熟,但是在今后的虚拟机中,逃逸分析技术肯定会支撑起一系列实用有效的优化技术。
参考:《深入理解Java虚拟机》 周志明 编著: