深⼊理解java虚拟机(⼗三)Java即时编译器JIT机制以及编译
优化
在部分的商⽤虚拟机中,Java 程序最初是通过解释器( Interpreter )进⾏解释执⾏的,当虚拟机发现某个⽅法或代码块的运⾏特别频繁的时候,就会把这些代码认定为“热点代码”。为了提⾼热点代码的执⾏效率,在运⾏时,即时编译器(Just In Time Compiler )会把这些代码编译成与本地平台相关的机器码,并进⾏各种层次的优化。
1、HotSpot 内的即时编译器
解释器和编译器各有各的优点:
解释器优点:当程序需要迅速启动的时候,解释器可以⾸先发挥作⽤,省去了编译的时间,⽴即执⾏。解释执⾏占⽤更⼩的内存空间。同时,当编译器进⾏的激进优化失败的时候,还可以进⾏逆优化来恢复到解释执⾏的状态。
编译器优点:在程序运⾏时,随着时间的推移,编译器逐渐发挥作⽤,把越来越多的代码编译成本地代码之后,可以获得更⾼的执⾏效率。
因此,整个虚拟机执⾏架构中,解释器与编译器经常配合⼯作,如下图所⽰。
HotSpot中内置了两个即时编译器,分别称为 Client Compiler和 Server Compiler ,或者简称为 C1 编译器和 C2 编译器。⽬前的HotSpot 编译器默认的是解释器和其中⼀个即时编译器配合的⽅式⼯作,具体是哪⼀个编译器,取决于虚拟机运⾏的模
式,HotSpot 虚拟机会根据⾃⾝版本与计算机的硬件性能⾃动选择运⾏模式,⽤户也可以使⽤ -client 和 -server 参数强制指定虚拟机运⾏在 Client 模式或者 Server 模式。这种配合使⽤的⽅式称为“混合模式”(Mixed Mode),⽤户可以使⽤参数 -Xint 强制虚拟机运⾏于 “解释模式”(Interpreted Mode),这时候编译器完全不介⼊⼯作。另外,使⽤ -Xcomp 强制虚拟机运⾏于 “编译模式”(Compiled Mode),这时候将优先采⽤编译⽅式执⾏,但是解释器仍然要在编译⽆法进⾏的情况下接⼊执⾏过程。通过虚拟机 -version 命令可以查看当前默认的运⾏模式。
2、被编译对象和触发条件
在运⾏过程中会被即时编译的“热点代码”有两类,即:
被多次调⽤的⽅法
被多次执⾏的循环体
对于第⼀种,编译器会将整个⽅法作为编译对象,这也是标准的JIT 编译⽅式。对于第⼆种是由循环体
出发的,但是编译器依然会以整个⽅法作为编译对象,因为发⽣在⽅法执⾏过程中,称为栈上替换。
判断⼀段代码是否是热点代码,是不是需要出发即时编译,这样的⾏为称为热点探测(Hot Spot Detection),探测算法有两
种,分别为。
基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进⾏检查,如果某些⽅法经
常出现在栈顶,这个⽅法就是“热点⽅法”。好处是实现简单、⾼效,很容易获取⽅法调⽤关系。缺点是很难确认⽅法的
reduce,容易受到线程阻塞或其他外因扰乱。
基于计数器的热点探测(Counter Based Hot Spot Detection):为每个⽅法(甚⾄是代码块)建⽴计数器,执⾏次数超过
阈值就认为是“热点⽅法”。优点是统计结果精确严谨。缺点是实现⿇烦,不能直接获取⽅法的调⽤关系。
HotSpot 使⽤的是第⼆种-基于技术其的热点探测,并且有两类计数器:⽅法调⽤计数器(Invocation Counter )和回边计数器(Back Edge Counter )。
这两个计数器都有⼀个确定的阈值,超过后便会触发 JIT 编译。
⾸先是⽅法调⽤计数器。Client 模式下默认阈值是 1500 次,在 Server 模式下是 10000次,这个阈值可以通过 -XX:
常用的java编译器有哪些CompileThreadhold 来⼈为设定。如果不做任何设置,⽅法调⽤计数器统计的并不是⽅法被调⽤的绝对次数,⽽是⼀个相对的执⾏频率,即⼀段时间之内的⽅法被调⽤的次数。当超过⼀定的时间限度,如果⽅法的调⽤次数仍然不⾜以让它提交给即时编译器编译,那么这个⽅法的调⽤计数器就会被减少⼀半,这个过程称为⽅法调⽤计数器热度的衰减(Counter Decay),⽽这段时间就成为此⽅法的统计的半衰周期( Counter Half Life Time)。进⾏热度衰减的动作是在虚拟机进⾏垃圾收集时顺便进⾏的,可以使⽤虚拟机参数 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。整个 JIT 编译的交互过程如下图。
第⼆个回边计数器,作⽤是统计⼀个⽅法中循环体代码执⾏的次数,在字节码中遇到控制流向后跳转的指令称为“回边”( Back Edge )。显然,建⽴回边计数器统计的⽬的就是为了触发 OSR 编译。关于这个计数器的阈值, HotSpot 提供了 -XX:BackEdgeThreshold 供⽤户设置,但是当前的虚拟机实际上使⽤了 -XX:OnStackReplacePercentage 来简介调整阈值,计算公式如下:
在 Client 模式下,公式为⽅法调⽤计数器阈值(CompileThreshold)X OSR ⽐率(OnStackReplacePercentage)/ 100 。其中 OSR ⽐率默认为 933,那么,回边计数器的阈值为 13995。
在 Server 模式下,公式为⽅法调⽤计数器阈值(Compile Threashold)X (OSR (OnStackReplacePercentage)- 解释器监控⽐率(InterpreterProfilePercent))/100
其中 onStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那么Server 模式虚拟机回边计数器阈值为 10700 。
执⾏过程,如下图。
3、编译过程
默认情况下,⽆论是⽅法调⽤产⽣的即时编译请求,还是 OSR 请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释⽅式继续执⾏,⽽编译动作则在后台的编译线程中进⾏,⽤户可以通过参数 -XX:-BackgroundCompilation 来禁⽌后台编译,这样,⼀旦达到 JIT 的编译条件,执⾏线程向虚拟机提交便已请求之后便会⼀直等待,直到编译过程完成后再开始执⾏编译器输出的本地代码。
对于 Client 模式⽽⾔
它是⼀个简单快速的三段式编译器,主要关注点在于局部的优化,放弃了许多耗时较长的全局优化⼿段。
1. 第⼀阶段,⼀个平台独⽴的前端将字节码构造成⼀种⾼级中间代码表⽰(High-Level Intermediate Representaion ,
HIR)。在此之前,编译器会在字节码上完成⼀部分基础优化,如⽅法内联,常量传播等优化。
2. 第⼆阶段,⼀个平台相关的后端从 HIR 中产⽣低级中间代码表⽰(Low-Level Intermediate Representation ,LIR),⽽在
此之前会在 HIR 上完成另外⼀些优化,如空值检查消除,范围检查消除等,让HIR 更为⾼效。
3. 第三阶段,在平台相关的后端使⽤线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,做窥孔
(Peephole)优化,然后产⽣机器码。
Client Compiler 的⼤致执⾏过程如下图所⽰:
对于 Server Compiler 模式⽽⾔
它是专门⾯向服务端的典型应⽤,并为服务端的性能配置特别调整过的编译器,也是⼀个充分优化过的⾼级编译器,⼏乎能达到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 ,不过并⾮所有的空值检查消除都是依赖编译器优化的,有⼀些是在代码运⾏过程中⾃动优化了)等。另外,还可能根据解释器或Client Compiler 提供的性能监控信息,进⾏⼀些不稳定的激进优化,如守护内联(Guarded Inlining)、分⽀频率预测(Branch Frequency Prediction)等。
Server Compiler 编译器可以充分利⽤某些处理器架构,如(RISC)上的⼤寄存器集合。从即时编译的⾓度来看, Server Compiler ⽆疑是⽐较缓慢的,但它的便以速度仍远远超过传统的静态优化编译器,⽽且它相对于 Client Compiler编译输出的代码质量有所提⾼,可以减少本地代码的执⾏时间,从⽽抵消了额外的编译时间开销,所以也有很多⾮服务端的应⽤选择使⽤
Server 模式的虚拟机运⾏。