记得在大学刚开始学习Java的时候,也许是为了让大家好理解,老师说Java是一门解释执行的语言。但是现在回顾这句话,这种说法可能就不是那么准确了
实际上,Java程序在启动最初是通过解释器进行解释执行的,但是当某个方法或者代码运行非常频繁的时候,虚拟机就会把这部分代码视为“热点代码”(Hot Spot Code),在运行时将这部分代码编译成平台相关的机器码,并且进行各种层次的优化,以提高热点代码的执行效率。完成这种工作的编译器就是即时编译器(Just In Time Compiler,JIT编译器)
解释器与编译器
解释器与编译器各具优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间,立即执行。当程序运行一段时间后,编译器逐渐发挥作用,将热点代码编译成本地代码,以获得更高的执行效率。对于一些内存紧张的场景(如嵌入式系统),解释器有助于节省内存。另外解释器也可以作为编译器激进优化时的一个“逃生门”,让编译器依据概率选择一些在大多数场景下能够提升运行效率的优化手段,当激进优化的假设不成立时,再回退到解释执行的状态继续执行。HotSpot虚拟机内置了两个即时编译器,分别是C1编译器(Client Compiler)和C2编译器(Server Compiler)
(1)混合模式(Mixed Mode)
虚拟机默认情况下采用解释器与其中一个编译器配合工作的方式运行,这种方式称为混合模式。采用哪个即时编译器取决于虚拟机运行的模式(Client模式或者Server模式),虚拟机会依据自身版本以及硬件性能自动选择运行模式,用户也可以通过“-client”或者“-server”参数指定
(2)解释模式(Interpreted Mode)
可通过“-Xint”参数强制虚拟机运行于解释模式,这时代码通过解释方式执行,编译器完全不介入工作。完全使用解释方式执行,通常会导致程序性能较差
(3)编译模式(Compiled Mode)
可通过“-Xcomp”参数强制虚拟机运行于编译模式,这时代码优先采用编译方式执行,但是解释器仍然需要在编译无法进行的时候介入执行。即时编译器编译本地代码需要占用cpu时间,要编译出优化程度很高的代码,则需要花费更多的时间(期间需要收集各种性能监控数据)
为了在程序启动响应速度与运行效率之间达到平衡,虚拟机采用了分层编译(Tiered Compilation)的策略:
第0层:程序解释执行,解释器不开启性能监控,可触发第1层编译
第1层:编译为本地代码,进行简单可靠的优化,如有必要将开启性能监控
第2层:编译为本地代码,会启用一些耗时较长的优化,甚至会依据性能监控数据进行一些不可靠的激进优化
分层编译的好处就是,在系统执行初期,执行频率比较高的代码先被c1编译器编译,以便尽快进入编译执行,然后随着时间的推移,执行频率较高的代码再被c2编译器编译,以达到最高的性能
编译对象与触发条件
热点代码就是被频繁执行的代码,符合这个条件的代码通常有两类:被多次调用的方法、被多次执行的循环体。判断一段代码是否是热点代码,这种行为称为热点探测(Hot Spot Detection),主要有两种方式:
(1)基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方式,虚拟机会周期性的检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那么这些方法就是热点方法。这种方式的优点是简单高效、容易获取调用关系。缺点是很难精确地确认一个方法的热度
(2)基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方式,虚拟机会为每个方法(甚至是代码块)建立计数器来统计执行次数,如果执行次数超过一定阈值,那么就认定是热点方法。这种方式的优点是更加精确严谨。缺点是实现相对繁琐,切不能直接获得调用关系
HotSpot虚拟机采用上述第二种热点探测方法。它为每个方法准备了两类计数器:
(1)方法调用计数器(Invocation Counter):
当一个方法被调用时,会首先检查该方法是否有被JIT编译过的版本,如果有,则使用被JIT编译过的版本执行。如果没有,则将此方法的调用计数器值加1,然后检查方法调用计数器与回边计数器值之和是否超过方法调用计数器阈值。如果已经超过阈值,那么将会向JIT编译器发起针对该方法进行编译的请求。之后,执行引擎不会同步等待编译结果,而是继续按照解释执行的方式执行代码。当编译完成后,该方法的调用入口地址会被改写成新地址,下一次调用将会使用该编译版本
如果不做特殊设置,那么调用计数统计的并非绝对次数,而是一个相对的频率,就是一段时间内被调用的次数。当超过一定时间限度,如果方法调用次数没有超过阈值,那么计数器值将减半,这个过程叫做方法调用计数器的热度衰减(Counter Decay),而这段时间就叫做方法调用计数统计的半衰期(Counter Half Life Time)
(2)回边计数器(Back Edge Counter):
在字节码中,遇到控制流向回跳转的指令称为“回边”(Back Edge)。简单来说,回边计数器的作用就是统计循环体被执行的次数。回边计数器用于触发OSR编译(On-Stack Replacement,关于OSR可参考:OSR是怎样的机制)
当解释器遇到一条回边指令时,会首先检查将要执行的代码是否有被编译过的版本,如果有,则使用被编译过的版本执行。如果没有,则将回边计数器值加1,然后检查方法调用计数器与回边计数器值之和是否超过回边计数器阈值。如果已经超过阈值,那么将会提交一个OSR编译请求,并且把回边计数器的值降低一些。之后,执行引擎不会同步等待编译结果,而是继续按照解释的方式执行代码
回边计数器没有计数热度衰减,当这个计数器溢出的时候,它会把方法计数器也调整到溢出状态,这样下次进入该方法的时候就会对方法进行编译
编译过程
对于C1编译器和C2编译器,编译过程有所不同
C1编译器:主要关注点在于局部性优化(而不是耗时的全局优化),是一个简单快速的三段式编译器
(1)第一阶段:首先会完成一些基础优化,如方法内联、常量传播等。之后,一个平台独立的前端,将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR)。HIR使用静态分配(Static Single Assignment,SSA)的方式代表代码值,这可以使一些在HIR构造之中和之后进行的优化更容易实现
(2)第二阶段:首先在HIR上完成另一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。之后,一个平台相关的后端,会从HIR中产生低级中间代码(Low-Level Intermediate Representation,LIR)
(2)第三阶段:在平台相关的后端上使用线性扫描法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码
C2编译器:它是一个充分优化过的高级编译器,会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等。除此之外,还会实施一些与Java语言特征密切相关的优化,如空值检查消除、范围检查消除等。另外还会依据解释器或者C1编译器提供的性能监控数据,进行一些不稳定的激进优化,如守护内联、分支频率预测等
C2编译器的编译速度虽然相对来说比较缓慢,但是依然远超传统的静态优化编译器。而且它相比C1编译器的编译质量更高,可以降低本地代码执行时间
编译优化技术
相比于解释执行,虚拟机团队把更多的精力放在了即时编译器上,因此通常来说,被即时编译器优化后的本地代码比解释执行的效率更高
我们首先通过一个简单的例子,来直观的感受一下几种优化技术是如何发挥作用的。在这个例子中,我们通过Java代码作为伪代码来模拟一下优化效果(实际的编译优化不在Java语言层面,甚至不在字节码层面,而是在某种中间代码或者机器码之上),目的仅是为了方便展示。首先来看一下原始的Java代码:
方法内联(Method Inlining)
首先要进行的优化是方法内联,它的重要性要高于其他优化。它的主要目的有两个:
一个是去除方法调用的成本(栈帧的开销,关于栈帧方面的内容,可参考本系列文章:字节码执行引擎)
另一个是为其他优化做好基础,方法内联膨胀之后,可以便于在更大范围内采取后续优化手段,从而获得更好的优化效果
因此,方法内联通常放在优化手段的最前列。经过方法内联后的伪代码如下:
冗余访问消除(Redundant Loads Elimination)
第二步进行冗余访问消除,伪代码如下:
复写传播(Copy Propagation)
第三步是复写传播,在我们代码中没有必要额外使用一个变量“y”,因此伪代码如下:
无用代码消除(Dead Code Elimination)
第四步进行无用代码消除,无用代码是指永远不会被执行的代码或者完全没有意义的代码,在我们的例子中,“x = x;”是没有意义的,,因此伪代码如下:
经过四次优化后,最终的代码比源代码精简很多(体现在机器码上差距会更明显),执行效率也会更高。编译器要实现这些优化也许会比较复杂,但是基本原理却比较简单
下面我们看几项最有代表性的优化技术是如何运作的:
(1)公共子表达式消除
公共子表达式消除是一种被广泛应用于各种编译器的经典优化技术,它的主要思想是:假设一个表达式E已经被计算过,并且从先前计算到现在,E中的变量一直都没有再变化过,那么E的这次出现就成为了公共子表达式,那么就没有必要再次对E进行计算,只要直接使用先前的值替代E就可以了。假设存在下面这段代码:
int a = (b * c) * 3 + d + (d + c * b);
编译器发现,“b * c”和 “c * b”是一样的表达式,并且在运算期间b和c的值都没有再发生变化,那么这段代码将被优化为:
int a = E * 3 + d + (d + E);
之后,编译器还有可能进行一项叫做代数化简(Algebraic Simplification)的优化,被优化后的代码如下:
int a = E * 4 + d * 2;
经过公共子表达式消除优化后的代码显然比原始代码更加简洁,效率更高
(2)数组边界检查消除
在Java语言中,假如有一个数组a[],在访问数组元素的时候,虚拟机将自动进行上下边界的检查。这个对于开发者是好事,但是对于虚拟机来说,如果执行拥有大量数组访问的代码,这肯定是一种性能负担
在一些情况下,数组边界检查在运行时,并非一次不漏的运行,例如:数组下标是一个常量,只要在编译期根据数据流分析确定这个数组下标没有越界,那么在运行时就无需再做检查了。还有一种情况是在循环中通过循环变量来访问数组元素,只要在编译期根据数据流分析确定循环变量没有越界,那么在整个循环中就完全不需要再做检查
站在更高的维度上来看,类似数组边界检查这样的安全检查还有很多(如空指针检查、除数为0检查等)。这些检查的确方便了程序开发,令程序更加安全,但确实是一种隐式开销。为了降低这种开销,除了如数组边界检查消除这种尽可能把运行时检查提前到编译期的思路外,还有一种思路是隐式异常处理(Java中空指针检查、除数为0检查采用这种思路)
举例说明,比如访问一个对象的某个属性,加入采用运行时检查,那么虚拟机的伪代码如下:
在采用隐式异常处理优化后,虚拟机的伪代码如下:
虚拟机会注册一个segment_fault的异常处理器,这样当a不为null的时候,a.value不会额外消耗一次安全检查的开销。代价是当a真的为null时,需要转入异常处理器中处理并抛出NullPointerException,这个动作必须由用户态转到内核态,等处理完成后再转回用户态,开销远比一次安全检查要大。当a极少为空的时候,这种优化是值得的,但是当a经常为空,这种优化反而更慢。但是,虚拟机会根据运行时收集到的数据自动选择更优的方案
(3)方法内联
前面已经通过例子介绍过方法内联,它是编译器最重要的优化手段之一,作用除了消除方法调用外,更重要的意义在于为后续其他优化建立良好的基础
例如下面这段代码,如果不做方法内联,根本无法发现这两个方法的代码都是没有意义的,也就无法做无用代码消除的优化
方法内联看似就是把目标方法的代码复制到发起调用的方法代码中,但实际上Java虚拟机对方法内联的处理远比看上去复杂,这个复杂主要来源于对虚方法的处理(关于分派方面的内容,可以参看本系列文章:字节码执行引擎)。对于一个虚方法,编译期做方法内联根本无法确定应该使用哪个方法版本
为了解决虚方法内联的问题,Java虚拟机团队引入了一种称为“类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术。这是一种基于整个应用程序的类型分析技术,用于确定目前已经加载的类中,某个接口是否有多于一种的实现、某个类是否有子类、子类是否为抽象类等信息
编译器在进行方法内联时,如果不是虚方法,那么可以直接进行内联,这种内联是稳定的。如果是虚方法,则会向CHA查询此方法是否有多个版本可供选择。如果只有一个版本,那么也可以进行内联,但是这种内联属于激进优化。后续程序执行过程中,如果虚拟机一直没有加载会导致该方法接受者继承关系发生变化的类,那么这个内联优化就一直可以使用下去。反之,就要放弃这个内联优化,需要退回到解释执行,或者重新编译
如果向CHA查询出多个版本,编译器则会使用内联缓存(Inline Cache)来完成方法内联。它的原理大致是:未发生方法调用前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者版本信息,并且每次调用前都比较版本信息,如果版本一致,则这个内联可以继续使用,否则会取消内联,查找虚方法表进行方法分派
可以看出,由于Java是一门面向对象的语言,Java对象的方法默认就是虚方法,因此在很多情况下,内联优化都是一种激进的优化方式。类似的激进优化方式还包括隐式异常处理、极小概率使用的分支会被移除等,当真的出现小概率事件,再退回到解释执行,或者重新编译
(4)逃逸分析
与CHA一样,逃逸分析并非直接优化手段,而是为其他优化提供依据的分析技术。逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法引用(比如作为方法参数传递到其他方法中),这种称为方法逃逸。还有可能被外部线程引用(比如赋值给类变量或在其他线程中访问该变量),这种称为线程逃逸。如果可以证明该对象不会逃逸到方法或线程外,则可能对该变量进行一些高效的优化,比如:
栈上分配(Stack Allocation)
对象在堆中分配,这是大家的共识。堆中分配的对象,可以被各个线程共享。GC可以对堆中不再使用的对象进行回收,但是GC需要额外耗费资源(关于GC方面的内容,可以参看本系列文章:垃圾收集与内存分配)
一般情况下,大多数局部对象都是不会逃逸的,对于这部分对象,如果采用栈上分配,那么对象所占的内存就可以随着方法调用的结束随栈帧的出栈而被回收(关于栈帧方面的内容,可参考本系列文章:字节码执行引擎)。这样可以大幅减轻GC负担
同步消除(Synchronization Elimination)
线程同步本身是一个相对耗时的过程。如果可以确定一个变量不会逃逸出线程,那么自然也就不再需要做同步
标量替代(Scalar Replacement)
标量是指数据已经无法分解成更小的数据来表示,Java中的原始数据类型就属于标量,相对的,对象类型就是聚合量。如果把一个对象拆散,将其使用到的成员变量恢复成原始类型的方式就是标量替换
如果一个对象不会被外部访问,并且这个对象可以被拆散,那么虚拟机可能不会真正创建这个对象,而是改为创建它的成员变量来替代。对象被标量替换后,这些成员变量除了可以进行栈上分配,还可以为后续优化创造条件
思维导图:
笔记7结束