本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。
记录日期:2022.1.3
大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。
JVM - 编译器优化机制(下)
参考《深入理解JVM虚拟机》第十章、第十一章。
JVM的编译器可以分为三个编译器:
- 前端编译器:把
*.java
文件转变为*.class
文件的过程。如JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。 - 即时编译器(JIT编译器):运行期把字节码转变为本地机器码的过程。如HotSpot VM的C1、C2编译器,Graal编译器。
- 提前编译器(AOT编译器):直接把程序编译成与目标机器指令集相关的二进制代码的过程。如JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
将前端编译器放到”前端编译“中讲,将JIT编译器、AOT编译器放到”后端编译“中讲。
后端编译
即时编译器
参考文章链接:JVM即时编译(JIT)
1、动态编译(dynamic compilation)指的是“在运行时进行编译”;与之相对的是事前编译(ahead-of-time compilation,简称AOT),也叫静态编译(static compilation)。
2、JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例。JIT编译一词后来被泛化,时常与动态编译等价;但要注意广义与狭义的JIT编译所指的区别。
3、自适应动态编译(adaptive dynamic compilation)也是一种动态编译,但它通常执行的时机比JIT编译迟,先让程序“以某种式”先运行起来,收集一些信息之后再做动态编译。这样的编译可以更加优化。
介绍
目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些热点代码编译成本地机器码,并以各种手段尽可能地进行代码优化
,运行时完成这个任务的后端编译器被称为即时编译器。
即时编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。
由于Java虚拟机规范并没有具体的约束规则去限制即使编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容。
注 :如无特殊说明,我们提到的编译器、即时编译器都是指Hotspot虚拟机内的即时编译器,虚拟机也是特指HotSpot虚拟机。
在JVM运行中的流程如下:
解释器与编译器
字节码的运行过程分为解释执行和编译执行。
- 解释执行:由解释器一行一行翻译执行。
- 编译执行:把字节码编译成机器码,直接执行机器码。
解释执行和编译执行的区别在于:
解释执行
- 优势在于没有编译的等待时间
- 性能相对差些
编译执行
- 运行效率会高很多, 一般认为比解释执行快一个数量级
- 带来了额外的开销
解释器与编译器并存
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:
-
当程序需要
迅速启动和执行
的时候,解释器可以首先发挥作用,省去编译的时间,立即执行
。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
。 -
当程序运行环境中
内存资源限制较大
(如部分嵌入式系统中),可以使用解释器执行节约内存
,反之可以使用编译执行来提升效率
。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。
编译的时间开销
解释器的执行,抽象的看是这样的:
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
而要JIT编译然后再执行的话,抽象的看则是:
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。
JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。
怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
-
只被调用一次,例如类的构造器(class initializer,())
-
没有循环
对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
只有对频繁执行的代码,JIT编译才能保证有正面的收益。
编译的空间开销
对一般的Java方法而言,**编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。**同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。
这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。
HotSpot的即时编译器
两个即时编译器(未来三个)
HotSpot虚拟机中内置了两个即时编译器:“客户端编译器”(Client Complier)和“服务端编译器”(Server Complier),简称为C1编译器
和 C2编译器
。目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。
第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器,Graal编译器目前还处于实验状态。
用Client Complier获取更高的编译速度
,用Server Complier 来获取更好的编译质量
。为什么提供多个即时编译器与为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。
查看当前jdk的运行模式
如果当前已经配置好jdk的环境后,打开cmd后查看jdk版本,输出如下:
C:\Users\xxx>java -version
java version "1.8.0_40"
Java(TM) SE Runtime Environment (build 1.8.0_40-b25)
Java HotSpot(TM) 64-Bit Server VM (build 25.40-b25, mixed mode)
- -Xint :设置JVM的执行模式为“解释模式”,这时候编译器完全不介入工作,全部代码都使用解释方式执行。
- -Xcomp : 设置JVM的执行模式为“编译模式”,这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
- -Xmixed :设置JVM的执行模式为“混合模式”,解释器与编译器搭配使用的方式。
分层编译
分层编译前
在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,查看当前jdk的运行模式的方式如上所示。
分层编译后
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译的概念其实很早就已经提出,但直到JDK 6时期才被初步实现,后来一直处于改进阶段,最终在JDK 7的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次
,其中包括:
- 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
- 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互、转换关系如图所示。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
编译对象与触发条件
热点代码
哪些代码会被编译为本地代码呢?很明显是“热点代码”。
哪些代码是“热点代码”呢?这里的热点代码主要包括两类:
- 被多次调用的方法。
- 被多次执行的循环体。
对于这两种情况,编译的目标对象都是整个方法体,而不是单独的循环体。 这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为“栈上替换”(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了。
热点探测
要知道方法或一段代码是不是热点代码,是不是需要触发即时编译,需要进行“热点探测”(Hot Spot Detection)。
目前主要的热点探测方式有以下两种:
- 基于采样的热点探测:采用这种方法的虚拟机会
周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”
。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。 - 基于计数器的热点探测:采用这种方法的虚拟机会
为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”
。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。
热点探测 - 关于HotSpot虚拟机的选择
在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器 和 回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
方法调用计数器
顾名思义,这个计数器用于统计方法被调用的次数
。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置(设置内容写在下面的引用里),执行引擎并不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
回边计数器
它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。
- 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX:OnStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。
- 虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStackReplacePercentage默认值为140,-XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。
编译过程
简单抄一下书了解,主要看总结。
客户端编译器过程
在第一个阶段,一个平***立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
服务端编译器过程
服务端编译器是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)
、循环展开 (Loop Unrolling)
、循环表达式外提(Loop Expression Hoisting)
、消除公共子表达式(Common Subexpression Elimination)
、常量传播(Constant Propagation)
、基本块重排序(Basic Block Reordering)
等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)
、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)
等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)
、分支频率预测 (Branch Frequency Prediction)
等。
服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如 RISC)上的大寄存器集合。以即时编译的标准来看,服务端编译器无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销,所以也有很多非服务端的应用选择使用服务端模式的HotSpot虚拟机来运行。
过程总结
Server Compiler和Client Compiler两个编译器的编译过程是不一样的。
对Client Compiler来说,它是一个相对简单快速的三段式编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。
而Server Compiler则是专门面向服务器端的,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器。
参数总结
参数 | 作用 |
---|---|
-Xmixed | 混合模式运行(默认) |
-Xint | 设置JVM的执行模式为解释执行模式 |
-Xcomp | JVM优先以编译模式运行,不能编译的,以解释模式运行 |
-XX:-TieredCompilation | 禁用中间编译层 |
-XX:TieredStopAtLevel | 到哪个分层停止 |
-XX:CompileThreshold=X | 指定方法调用计数器阈值(关闭分层编译时才有效) |
-XX:OnStackReplacePercentage=X | 指定回边计数器阈值(关闭分层编译时才有效) |
-XX:-UseCounterDecay | 关闭方法调用计数器热度衰减 |
-XX:CounterHalfLifeTime | 指定方法调用计数器半衰周期(秒) |
提前编译器
由于即时编译不可避免的会占用一些本该属于程序运行的时间。所以这就使得的提前编译
有了存在的必要性,不过提前编译就失去了原来的平台性中立性,动态拓展等优势,不过为了性能倒也是值得的(还是得看应用场景)。
两个方向
提前编译器(Ahead Of Time Compiler,AOT编译器),直接把程序编译成与目标机器指令集相关的二进制代码的过程。目前实现提前编译有两个方向:
- 将程序代码编译成直接机器码存于本地(类似C/C++)。(比如安卓里的ART,不过由于会占使得启动变慢,所以在Android7.0之后重新启用解释器与即时编译器,在系统空闲时后台自动进行提前编译)。
- 把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用(叫
即时编译缓存
或者动态提前编译
)。这样节约了不少运行时成本,不过实现起来确是有点困难的,因为这种提前编译不仅与目标机器绑定海域虚拟机对应的参数绑定。
两个实现方式优缺点
- 第一种实现方式,在Java中的存在价值直指即时编译的最大弱点,即时
编译要占用程序运行时间和运算资源
。例如最耗时的优化措施之一:过程见分析,必须在全程序范围内做大量耗时的计算工作,如果是在程序运行之前进行的静态编译,这些耗时操作就可以大胆的进行。 - 第二种实现方式,本质上是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热之后才能达到最高性能的问题。这种提前编译被称为
动态提前编译
或者直接叫即时编译缓存
。HotSpot运行时可以直接加载这些编译结果,实现快速程序启动速度,减少程序达到全速运行状态所需要的时间。
提前编译器的优点
提前编译器没有执行时间和资源限制的压力,能够毫无顾忌地使用重负载的优化手段。
即时编译器的优点
- 性能分析制导优化:根据不断收集的性能监控信息,可以根据当下实际情况应该如何分配资源或者热点代码集中优化等。
- 激进预测性优化:静态优化无论何时都必须保证优化后的所有程序外部可见影响(不仅仅是执行结果)与优化前必须是一致的。即时编译的策略就可以不必那么保守,如果性能监控信息能够支持它做出一些正确的可能很大但是无法保证绝对正确的预测判断,就可以进行大胆的优化,大不了退回到低级编译器甚至解释器上运行。而这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。
- 链接时优化:由于Java天生是动态连接的,所以提前编译无法做到链接后的优化。
编译器优化技术
编译器的目标虽然是做有程序代码翻译为本地代码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关建。
HotSpot采用了不少优化手段,其中有不少是经典编译器的优化手段,也有许多针对Java语言,或者说针对运行在Java虚拟机上所有语言进行的优化。
需要明确的一点是,即时编译器对这些代码优化变化交换是建立在代码的中间表示或者机器码之上,绝对不是直接在Java源码上去做的。
这里就来介绍四种具有代表性的优化技术:
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组边界检查消除。
方法内联
方法内联就是把目标方法的代码原封不动的“复制”到发起调用的方法中,避免发生真实的方法调用。
Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。
Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。
否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
方法内联的主要目的有两个:
- 去除方法调用的成本(如查找方法版本,建立栈桢等)
- 为其他优化建立良好的基础,便于在更大范围上进行后续优化手段,可以获得更好的优化效果。
举例说明
首先我们来看一段代码:
public class Test {
private static int add4(int a, int b, int c, int d) {
return add2(a, b) + add2(c, d);
}
private static int add2(int a, int b) {
return a + b;
}
}
这段代码中add4()
方法内调用了2次add2()
方法,我们知道,调用方法是需要经过压栈和出栈的操作的,而这两个操作是有一定开销的,如果上面这段代码调用次数不多,那没问题,一旦调用次数频繁,那么这时候的开销就很大,此时JVM就会自动识别这段“热点代码”,进行方法内联。
进行实际的方法内联之后:
private static int add4(int a, int b, int c, int d) {
return a + b + c + d;
}
那是不是所有方法内调用方法的方法都会进行内联呢?
答案是否定的,方法内联需要满足一些特定的条件才能起作用。
方法内联的条件
方法能否进行内联,存在两个条件:
- 方法体足够小。
- 热点方法:如果方法体小于325字节会尝试内联,可用-XX:FreqInlineSize修改大小。
- 非热点方法:如果方法体小于35字节会尝试内联,可用-XX :MaxInlineSize修改大小。
- 被调用方法运行时的实现被可以唯一确定。
- static方法、 private方法及final方法, JIT可以唯一确定具体的实现代码。
- public的实例方法,指向的实现可能是自身、父类、子类的代码,当且仅当JIT能够唯一确定方法的具体实现时,才有可能完成内联。
那么根据上面两个条件,我们就可以在后面写代码时注意以下:
- 尽量让方法体小一些
- 尽量使用final、 private、 static关键字修饰方法,避免因为多态需要对方法做额外的检查
- 一些场景下,可以通过 JVM 参数修改阈值,从而让更多方法内联
方法内联的难题
Java 虚拟机中的内联过程没有想象的那么简单。我们知道,只有使用 invokespecial 指令调用的私有方法、实例构造器和父类方法,以及使用 invokestatic 调用的静态方法,顶多再加上用 final 修饰的方法,才是在编译期进行解析的,它们称为非虚方法。除此之外, Java 中的其它方法都称为虚方法,对它们的调用需要在运行时进行方法接受者的多态选择,并且都有可能存在多于一个版本的方法接收者。对于一个虚方法,编译期做内联的时候根本无法确定应该使用哪个方法版本,需要在运行期才能确定。这也是在 Java 虚拟机中做内联比较困难的原因。
CHA技术
为了解决虚方法的内联问题,Java虚拟机首先引入的就是类型继承关系分析技术(CHA)。
主要用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息
。
这样,编译器就可以在进行内联时,分不同情况采取不同处理:
- 如果是
非虚方法
,直接进行内联。 - 如果是
虚方法
,则会向CHA查询此方法在当前状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,就可以直接将“当前应用全貌就是现在运行的样子”作为基础来进行内联,这种内联称为“守护内联”
。- 如果
此时有新的子类被加载进程序
(比如使用反射加载),就打破了这种情况,程序会预留好“退路”,就会抛弃已经编译的代码,退回到解释状态进行执行,或者重新编译。 - 如果虚拟机一直
没有加载到会令这个方法的接收者的继承关系发生变化的类
,那么内联优化的代码就可以一直使用。
- 如果
内联缓存
接上题,刚才说了:“如果是虚方法,则会向CHA查询此方法在当前状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,就可以直接将“当前应用全貌就是现在运行的样子”作为基础来进行内联”。
如果此时CHA查询出来有多个版本目标可供选择呢?
此时JIT编译器就会使用内联缓存(Inline Cache)
的方式缩减方法调用的开销。这种方法要比直接查虚方法表速度快一些。
内联缓存是建立在目标方法正常入口之前的缓存
,它的工作原理是:
在未发生方法调用前,内联缓存状态为空。
当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。
此时比较接收者的版本会出现两种情况:
- 后面每次进来调用的方法接收者的版本都是一样的,那么它就是
单态内联缓存
,通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销。 - 后面每次进来调用的方法接收者的版本出现不一致的情况,说明程序用到了虚方法的多态特性,这时候会退化成
多态内联缓存
,其开销相当于真正查找虚方法表来进行方法分派。
方法内联的隐患
方法内联在Java虚拟机中算是一种激进优化。
内联本质上是用空间换时间的玩法,也就是即时编译器在编译期间把方法调用连接起来,从而减少入栈和出栈的开销,但是经过内联之后的代码会变多,而增加的代码量取决于方法的调用次数以及方法本身的大小,在一些极端场景下可能会导致 CodeCache 的移除,导致JVM退化成解释执行模式。
方法内联的相关JVM参数
参数名 | 默认 | 说明 |
---|---|---|
-XX:+PrintInlining | - | 打印内联详情,该参数需和XX:+UnlockDiagnosticVMOptions配合使用 |
-XX:+UnlockDiagnosticVMOptions | - | 打印VM诊断相关的信息 |
-XX:MaxInlineSize=n | 35 | 如果非热点方法的字节码超过该值,则无法内联,单位字节 |
-XX:FreqInlineSize=n | 325 | 如果热点方法的字节码超过该值,则无法内联,单位字节 |
-XX:InlineSmallCode=n | 1000 | 目标编译后生成的机器码代销大于该值则无法内联,单位字节 |
-XX:MaxInlineLevel=n | 9 | 内联方法的最大调用帧数(嵌套调用的最大内联深度) |
-XX:MaxTrivialSize=n | 6 | 如果方法的字节码少于该值,则直接内联,单位字节 |
-XX:MinInliningThreshold=n | 250 | 如果目标方法的调用次数低于该值,则不去内联 |
-XX:LiveNodeCountInliningCutoff=n | 40000 | 编译过程中最大活动节点数(IR节点)的.上限,仅对C2编译器有效 |
-XX:InlineFrequencyCount=n | 100 | 如果方法的调用点(call site)的执行次数超过该值,则触发内联 |
-XX:MaxRecursiveInlineLevel=n | 1 | 递归调用大于这么多次就不内联 |
-XX:+InlineSynchronizedMethods | 开启 | 是否允许内联同步方法 |
逃逸分析
逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸
;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸
;从不逃逸
、方法逃逸
到线程逃逸
,称为对象由低到高的不同逃逸程度。
写一段存在方法逃逸的代码举例:
public class Test {
public static SomeClass someClass;
// 全局变量赋值逃逸
public void globalVariablePointerEscape(){
someClass = new SomeClass();
}
// 方法返回值逃逸
public SomeClass methodPointerEscape(){
return new SomeClass();
}
// 实例引用传递逃逸
public void instancePassPointerEscape(){
this.methodPointerEscape().pringClassName(this);
}
}
class SomeClass{
public void pringClassName(Test test){
System.out.println(test.getClass().getName());
}
}
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如下:
栈上分配
【它在HotSpot虚拟机上暂时没有实现】
首先,在栈上创建对象
肯定比在堆上创建对象
耗费的资源要少的多,因为在堆上创建内存的坏处有堆是线程共享的、堆中创建对象、垃圾回收、整理内存等…
所以,如果确定一个对象不会逃逸出线程之外,就可以让这个对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁。
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸
。
所以说不是所有的对象都在堆上被创建,也有可能在栈上被创建。
标量替换
首先我们需要明白两个概念:
- 标量:不能被进一步分解的量,包括基础数据类型(int、long等)、对象引用(refrence类型)。
- 聚合量:可以进一步分解的量。
标量替换就是如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问
。
假如逃逸分析能够证明一个对象不会被方法外部访问
,并且这个对象可以被拆散
,那么程序真正执行的时候将可能不去创建这个对象
,而改为直接创建它的若干个被这个方法使用的成员变量
来代替。
将对象拆分后,除了可以让对象的成员变量在栈上(是因为栈上存储的数据很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
标量替换的相关JVM参数
-XX:+ EliminateAllocations:开启标量替换( JDK 8默认开启)
同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。
公共子表达式消除
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术。
公共子表达式消除的含义:如果一个表达式 E
之前已经被计算过了,并且从先前的计算到现在 E
中所有变量的值都没有发生变化,那么 E
的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E
。
- 如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination)。
- 如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。
举个简单的例子来说明它的优化过程,代码如下:
int d = (c * b) * 12 + a + (a + b * c);
使用javac编译器编译过后的结果不会有任何的优化,是完全直译出来的,编译出来的字节码如下:
iload_2 // b
imul // 计算b*c
bipush 12 // 推入12
imul // 计算(c * b) * 12
iload_1 // a
iadd // 计算(c * b) * 12 + a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b * c
iadd // 计算a + b * c
iadd // 计算(c * b) * 12 + a + a + b * c
istore_4
但是,如果这段代码进入JIT编译器,将进行优化,因为上面的(c * b) 和 (b * c)是一样的表达式,而且在计算期间b 与 c的值是不变的,那么这条代码可能变为:
int d = E * 12 + a + (a + E);
这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化 ——代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为:
int d = E * 13 + a + a;
表达式进行变换之后,再计算起来就可以节省一些时间了。
数组边界检查消除
数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。
如果有一个arr[]
数组,在Java中访问数组元素arr[i]
的时候系统将会自动进行上下界的范围检查,即i必须满足“i >= 0 && i < farr.length”
的访问条件,否则将抛出一个运行时异常: java.lang.ArrayIndexOutOfBoundsException
。
这对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。
解决方案
虚拟机会注册一个Segment Fault
信号的异常处理器(务必注意这里是指进程层面的异常处理器
,并非真的Java的try-catch语句的异常处理器),这样当arr[]
数组不为空的时候,对值的访问是不会有任何额外对arr[]
数组判空的开销的,而代价就是当arr[]
真的为空时,必须转到异常处理器中恢复中断并抛出NullPointException
异常。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。
当arr[]
数组极少为空的时候,隐式异常优化是值得的;但假如foo经常为空,这样的优化反而会让程序更慢。
幸好HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。
总结(结合上下)
前端编译
把Java源码文件(.java)编译成Class文件(.class)的过程。
也即把满足Java语言规范的程序转化为满足JVM规范所要求格式的功能。
优点
- 这阶段的优化是指程序编码方面的;
- 许多Java语法新特性(“语法糖”:泛型、内部类等等),是靠前端编译器实现的,而不是依赖虚拟机;
- 编译成的Class文件可以直接给JVM解释器解释执行,省去编译时间,加快启动速度;
缺点
- 对代码运行效率几乎没有任何优化措施;
- 解释执行效率较低,所以需要结合下面的JIT编译;
前端编译器:Oracle javac、Eclipse JDT中的增量式编译器(ECJ)等;
后端编译 / 即时(JIT)编译
通过Java虚拟机(JVM)内置的即时编译器(Just In Time Compiler,JIT编译器);在运行时把Class文件字节码编译成本地机器码的过程。
优点
通过在运行时收集监控信息,把"热点代码"(Hot Spot Code)编译成与本地平台相关的机器码,并进行各种层次的优化;
可以大大提高执行效率;
缺点
收集监控信息影响程序运行;
编译过程占用程序运行时间(如使得启动速度变慢);
编译机器码占用内存;
JIT编译器:HotSpot虚拟机的C1、C2编译器等;
另外,JIT编译速度及编译结果的优劣,是衡量一个JVM性能的很重要指标;
所以对程序运行性能优化集中到这个阶段;
也就是说可以对这个阶段进行JVM调优;
静态提前编译(Ahead Of Time,AOT编译)
程序运行前,直接把Java源码文件(.java)编译成本地机器码的过程;
优点
编译不占用运行时间,可以做一些较耗时的优化,并可加快程序启动;
把编译的本地机器码保存磁盘,不占用内存,并可多次使用;
缺点
因为Java语言的动态性(如反射)带来了额外的复杂性,影响了静态编译代码的质量;
一般静态编译不如JIT编译的质量,这种方式用得比较少;
静态提前编译器(AOT编译器):JAOTC、GCJ、Excelsior JET、ART (Android Runtime)等;
前端编译 + JIT编译
到这里,我们知道目前Java体系中主要还是采用前端编译+JIT编译的方式,如JDK中的HotSpot虚拟机。
前端编译
+ JIT编译方式
的运作过程大体如下:
- 首先通过前端编译把符合Java语言规范的程序代码转化为满足JVM规范所要求Class格式。
- 然后程序启动时Class格式文件发挥作用,解释执行,省去编译时间,加快启动速度。
- 针对Class解释执行效率低的问题,在运行中收集性能监控信息,得知"热点代码"。
- JIT逐渐发挥作用,把越来越多的热点代码"编译优化成本地代码,提高执行效率。