JVM之晚期(运行期)优化(十一)

部分商用虚拟机最初是通过解释器进行解释执行,当虚拟机发现某个方法或某段代码运行比较频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时虚拟机就会启用编译器把字节码编译为本地代码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器。
HotSpot虚拟机内的即时编译器
首先要解决以下几个问题:
1、为什么HotSpot虚拟机要使用解释器与编译器并存的架构?
2、为什么HotSpot要实现两个不同的即时编译器?
3、程序何时使用解释器执行?何时使用编译器执行?
4、哪些程序代码会被编译成本地代码?如何编译为本地代码?
5、如何从外部观察即时编译器的编译过程和编译结果?
1、解释器与编译器并存架构
许多主流的商用虚拟机如HotSpot和J9等,都同时包含解释器与编译器。
解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,获得更高的执行效率。
2、C1、C2即时编译器
HotSpot内置两个即时编译器:Client Compiler(C1)和Server Compiler(C2)
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot还会逐渐启用分层编译的策略,根据编译器编译、优化的规模与耗时,划分出不同的编译层次:
》第0层:程序解释执行,解释器不开启性能监控功能,可触发第一层编译。
》第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
》第2层(或2层以上):也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
编译对象与触发条件
热点代码有两类:

被多次调用的方法(方法被调用的多了,方法体代码执行的次数自然就多)
被多次执行的循环体(虽然一个方法只被调用一次或几次,但方法内循环次数较多的循环体也可以被认为是热点代码)
对于第一种情况,由于是由方法调用触发的编译,因此把整个方法作为编译对象,这种方式也是标准的JIT编译方式。
后面一种情况,尽管编译动作是由循环体所触发的,但编译器依然会整个方法作为编译对象。这种编译方式因为编译发生在方法执行过程中,因此形象的称之为栈上替换(方法栈帧还在栈上,方法就被替换了)
判断一段代码是不是热点代码,需不需要触发即时编译,这样的行为称为热点探测。热点探测并不一定要知道方法具体被调用几次,目前主流的判定方式有两种:
》基于采样的热探测:虚拟机会周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。该方法简单高效,还可以很容易获得方法调用关系,缺点是很难精确确认一个方法的热度,容易受到线程阻塞或者外界因素的影响而扰乱热点探测。
》基于计数器的热点探测:虚拟机为每个方法(甚至是代码块)建立计数器,统计方法执行的次数,如果执行的次数超过一定的阈值就认为是热点代码。这种方式虽然实现起来麻烦,而且不能直接获取方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
HotSpot虚拟机采用第二种方式,它为每个方法建立两个计数器:方法调用计数器和回边计数器,在确定虚拟机运行参数的情况下,这两个计数器的阈值都是确定的。
      方法计数器:默认阈值client模式下1500次,Server模式下10000次。当一个方法被调用时,首先会检查是否存在被JIT编译过的版本,如果存在优先使用编译后本地代码来执行。如果不存在已经被编译过的版本,则将此方法调用计数器加1,然后判断方法计数器与回边计数器值之和是否超过了方法调用计数器的阈值。如果超过阈值,则会向即时编译器提交一个该方法的代码编译请求。如果不做任何设置,执行引擎并不会等待编译器编译完成,而是继续进入解释器解释执行方式,直到请求被编译完成,方法的调用入口地址将会被系统自动改写成新的。不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一段时间内,方法被调用的次数。如果超过一定时间内,方法调用次数不足以触发编译,那么方法的计数器就会减半。
      回边计数器:统计一个方法中循环体执行的次数。虽然虚拟机也提供一个类似的方法调用计数器阈值,但是并没有使用,而是使用设置另一个参数,它有它的计算公式。
当解释器遇到一条回边指令时,先检查是否已经存在被编译过的代码,如果有则优先执行已经编译的版本,否则回边计数器值加1,然后判断方法计数器和回边计数器值之和是否超过回边计数器的阈值。当超过阈值,则会提交OSR编译请求,并把回边计数器值降低一些,以便继续在解释器中执行循环体。(这个是循环体执行的绝对次数)

编译过程:
无论是方法调用产生的即使编译,还是OSR编译,虚拟机在代码编译器未完成之前,都将会继续解释方式执行。
在编译过程中,C1与C2编译器是不同的。对于C1来说,它是一个快速简单的三段式编译器,主要是局部性的优化,放弃了许多耗时较长的全局优化手段。
      第一阶段:将字节码构建一种高级中间代码表示(HIR)
      第二阶段:从HIR代码中产生低级中间代码表示(LIR)
      第三阶段:使用线性扫描算法,在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
C2编译器是专门面向服务器端典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器。几乎能达到GNU C++编译器使用-O2参数优化的强度,它会执行所有的经典优化动作,如:无用代码消除、循环展开、循环外提 等,还可能根据解释器或者C1编译器提供的监控信息,进行一些比较激进的优化:守护内联、分支频率预测等,虽然C2编译器花费的时间比较长,但是其编译的代码优化效果比较高,可以通过减少运行时间来抵消编译时所花费的时间。所以很多非服务端应用选择使用Server模式的虚拟机运行。
      编译优化技术
编译执行本地代码比解释方式更快,除了虚拟机解释执行字节码需要消耗额外的时间外,还有一个重要的原因是虚拟机团队几乎把对代码优化的所有措施都自重在即时编译器上了。因此一般来说即时编译器产生的本地代码会被javac产生的字节码更加优秀。
原始代码,仅仅有java代码表示而已

static class B{
    int value;
    final int get(){
       return value;
   }
}
public void foo(){
    y=b.get();
    //... do stuff
    z=b.get();
    sum=y+z;
}

这些优化技术是建立在某种中间表示或机器码之上,不是建立在java源码之上。
第一步:方法内联:重要性高于其他优化措施,主要目的有两个:
一是去除方法的调用成本(如建立栈帧)
二是为其他优化建立良好基础,方法内联膨胀之后,可以方便在更大范围上采用后续的优化手段,从而取得更好的优化效果。

public void foo(){
    y=b.value;
    //..do stuff
    z=b.value;
    sum=y+z;
}

第二步:冗余访问消除 如果中间//..do stuff的值不会改变b.value的值,那么就可以把z=b.value替换为z=y。

public void foo(){
    y=b.value;
    //..do stuff
    z=y;
    sum=y+z;
}

第三步:复写传播 这段程序的逻辑中并没有必要使用一个额外的变量“z”,他与变量“y”是完全相等的,因此可以使用y来代替z

public void foo(){
    y=b.value;
    //...do stuff
    sum=y+y;
}

第四步:无用代码清除 无用代码可能是永远不会执行或者完全没有意义的代码如y=y;

public void foo(){
    y=b.value;
    //...do stuff
    sum=y+y;
}

几种比较有代表性的优化技术
》语言无关的经典优化技术之一:公共子表达式消除
》语言相关的经典优化技术之一:数组范围检查消除
》最重要的优化技术之一:方法内联
》最前沿的优化技术之一:逃逸分析
公共子表达式消除:如果一个表达式E已经计算过,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。没有必要计算,直接用E代替。如果限于程序的基本块内,称为局部公共子表达式消除,如果范围是多个基本块,那么就称为全局公共子表达式消除。 int d=(c*b)*12+a=(a+b*c); int d=E*12+a+(a+E);
int d=E*13+a*2
数组边界检查消除
java是动态安全的语言,对数组的访问不是直接内存指针操作,而是每次数组读写都带有一次隐含的条件判断,是否越界,进行边界检查。比较常见的是在循环中使用循环变量访问数组,如果编译器通过数据流分析就可以判断循环变量取值不会越界,那么整个循环中就会数组上下边界检查去除,可以节省很多次的条件判断。
还有另一种避免思路:隐式异常处理

if(foo!=null){
    return foo.value;
}else{
    throw new NullPointException();
}

在使用隐式异常优化后,虚拟机会把上面伪代码转为下面伪代码

try{
  return foo.value;
}else{
  uncommon_trap;
}

虚拟机注册一个Segment Fault信号的异常处理器,这样foo不为空减少一次判空开销,但如果为空抛出异常,就会很慢,因此这种只适合为空情况少的,还有虚拟机会根据运行时收集的信息选择运行方案,是不是进行隐式优化。
方法内联
使用内联可以去除调用成本外,最重要是为优化建立基础,因为如果不做内联,很多优化都不会进行比如

public static void foo(Object o){   
    if(obj!=null){
        System.out.println("do something");
    }
}
public static void testInline(String [] args){
    Object obj=null;
    foo(obj);
}

不进行内联是很难发现testInline方法内部代码无用。
在方法内联方面,即时编译器做了一些特别的努力。否则按照经典编译原理的优化理论,大多数java方法都无法优化。
主要是除了invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令调用的静态方法才是在编译时期进行解析的。除了上述四种方法之外,其他的方法都是运行时进行方法接收者的多态选择。编译期无法确定那个版本。
所以虚拟机团队引入了一种“类型继承关系分析”的技术,这是一种基于整个应用程序的 类型分析技术,它用于目前已加载的类中,某个接口是否有多于一中实现,某个类是否存在子类、子类是否抽象类等信息。
      编译器进行内联时,如果是非虚方法,直接内联就可以。如果遇到虚方法,就会向cHAr查询此方法在当前程序下是否存在多个版本,如果只有一个版本,那就进行内联(激进优化),需要预留一个逃生门,称为守护内联。如果存在多个版本则使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存:未发生方法调用之前,内联缓存状态为空,当第一次调用发生之后,缓存记录方法接收者的版本信息,如果每此调用接收者都一致,那么就一直用下去,否则取消内联。
逃逸分析
java中比较前沿的技术,不是靠直接优化代码。而是为其他优化手段来提供依据的分析技术。
逃逸分析技术基本行为是分析对象动态作用域:一个对象在方法中定义,可能被外部方法调用(作为参数),称为方法逃逸。甚至可能被外部线程访问到(赋值给类变量或其他线程可访问的实例变量)称为线程逃逸。
如果可以证明一个对象不会逃逸到方法或者线程之外,就可以做一些优化。
》栈上分配:对象在栈上分配
》同步消除:变量的同步措施消除
》标量替换:对聚合变量(如对象)的成员访问可以转为直接原始类型访问,这样就可能不要创建这个对象,而是多个变量成员。
java与C/C++编译器对比:
劣势:
1、即时编译器由于时间限制,不敢随便引入大规模优化技术
2、java是动态的类型安全语言,因此需要虚拟机来频繁验证不会违反语义或者访问非结构化内存。
3、java中使用虚方法比较多,内联难度大于C/C++编译器
4、java语言可动态扩展,运行时会发生类型继承关系变化,编译器无法看到全局面貌,因此很多全局优化难以进行。
5、java分配对象在堆上,而C/C++则堆上和栈上都可以,减少内存回收压力,还有用户代码回收内存比垃圾收集器效率更高。
但是也有一些优势:比如运行时的优化措施:比如调用频率预测等

猜你喜欢

转载自blog.csdn.net/qq_26564827/article/details/80607153