《深入理解Java虚拟机》读书笔记(十)–晚期(运行期)优化(上)

《深入理解Java虚拟机》读书笔记(十)--晚期(运行期)优化(上)

一、HotSpot虚拟机内部的即时编译器

1.1 解释器和编译器

  当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。
  HotSpot内置了两个即时编译器,分别称为Client Compiler(C1编译器)和Server Compiler(C2编译器或Opto编译器)。目前主流的HotSpot虚拟机(Sun系列JDK1.7及之前的版本)中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器取决于虚拟机的运行模式(-client或-server)。
  无论采用的编译器是C1还是C2,解释器与编译器搭配使用的方式称为“混合模式”(Mixed Mode),用户可以通过参数“-Xint”强制指定虚拟机运行于纯“解释模式”(Interpreted Mode),也可以通过参数“-Xcomp”强制指定虚拟机运行于纯“编译模式”(Compiled Mode)。可通过java -version命令输出使用的何种模式:
在这里插入图片描述
  为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot启用了分层编译(Tiered Compilation)的策略。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层:程序解释执行,解释器不开启性能监控功能,可触发第一层编译
  • 第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如果有必要将加入性能监控的逻辑
  • 第2层(或2层以上):也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

1.2 编译对象和触发条件

  在运行过程中会被即时编译器编译的热点代码有两类:

  • 被多次调用的方法:对于这种情况,是由方法调用触发的编译,因此编译器会以整个方法作为编译对象,这种编译是虚拟机中标准的JIT编译方式。
  • 被多次执行的循环体:虽然编译动作是由循环体触发的,但编译器依然会编译整个方法,这种方式的编译发生在方法执行过程之中,称为栈上替换(On Stack Replacement),简称为OSR编译,即方法栈帧还在栈上,方法就被替换了。

  判断一段代码是不是热点代码,是否需要触发即时编译,这样的行为称为热点探测,目前主要有两种方式:

  • 基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,就认为这个方法是热点方法。这个方式的优点是实现简单、高效,可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是结果不够精确,且容易受到线程阻塞或外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测:虚拟机为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。这个方式的优点是结果相对来说更加精确和严谨,缺点是实现麻烦,需要为每个方法建立并维护计数器。

  HotSpot虚拟机中使用的是第二种–基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器回边计数器。在确定了虚拟机运行参数的前提下,这两个计数器都有一个明确的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

1.2.1 方法调用计数器

  用于统计方法被调用的次数,其默认阈值在Client模式下是1500次,在Servier模式下是10000次,可以通过-XX:CompileThreshold参数指定。
  当一个方法被调用时,会先检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值,如果超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
  如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成后,这个方法的调用入口会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
  当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的计数器就会减少一半,称为方法调用计数器热度的衰减,这段时间称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行GC时顺便进行的,可以使用虚拟机参数-XX:UseCounterDecay关闭热度衰减,另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

1.2.2 回边计数器

  用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,目的是为了触发OSR编译。这里间接使用的仍然是-XX:CompileThreshold的参数(提供了-XX:BackEdgeThreshold参数给用户设置,但是并未使用),还需要通过参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式如下:

  • Client模式
    回边计数器阈值=方法调用计数器阈值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/100
    其中OnStackReplacePercentage默认值是933,如果都取默认值,那么Client模式虚拟机的回边计数器阈值为13995。
  • Server模式
    回边计数器阈值=方法调用计数器阈值(CompileThreshold)X (OSR比率(OnStackReplacePercentage)-解释器监控比率(InterperterProfilePercentage))/100
    其中OnStackReplacePercentage默认值是140,InterperterProfilePercentage默认值是33,如果都取默认值,那么Server模式虚拟机回边计数器的阈值为10700。
      当解释器遇到一条回边指令时,会先查找要执行的代码片段是否已经有编译好的版本,如果有,将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值时,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。另外,回边计数器没有热度衰减。

1.3 编译过程

  无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都将按照解释方式继续执行,编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后,一旦达到即时编译的条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。在后台的编译过程中,Client模式和Server模式不一样。

  • Client Compiler:一个简单的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
  1. 第一个阶段:一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR)。一部分的基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
  2. 第二个阶段:一个平台相关的后端从HIR中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表现形式。
  3. 第三个阶段:平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
  • Server Compiler:专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,它会完成所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与Java语言密切相关的优化技术,如范围检查消除、空值检查消除等。

  以即时编译的标准来看,Server Compiler是比较缓慢的,但是它的编译速度仍然远超传统静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端应用选择使用Server模式的虚拟机运行。

二、编译优化技术

  书中列举了数十种即时编译器优化技术,这里就不贴出来了,直接还是以代码示例。需要明确的是,代码优化变换是建立在代码的某种中间表示或机器码之上的,这里以代码展示只是是为了理解这些优化手段。以下为原始代码:

static class B{ 
   
	int value;
	final int get(){ 
   
		return value;
	}
}
public void foo(){ 
   
	y = b.get();
	//...do stuff...
	z = b.get();
	sum = y + z;
}
只听到从山间传来架构君的声音:
郎船几度偷相访。有谁来对上联或下联?

2.1 方法内联

  方法内联主要有两个目的,一是去除方法调用的成本(如建立栈帧),而是为其他优化建立良好的基础。因此各种编译器一般都会把内联优化放在优化序列的最靠前位置。内联后的代码如下所示:

此代码由Java架构师必看网-架构君整理
public void foo(){ y = b.value; //...do stuff... z = b.value; sum = y + z; }

2.2 冗余访问消除

  假设代码中间注释掉的"…do stuff…“所代表的操作不会改变b.value的值,那就可以把"z=b.value"替换为"z=y”,因为上一句"y=b.value"已经保证了变量y与b.value是一致的,这样就可以不用去访问对象b的局部变量了。如果把b.value看做是一个表达式,那也可以把这项优化看成是公共子表达式消除,优化后的代码如下所示:

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

2.3 复写传播

  这段程序的逻辑中并没有必要使用一个额外的变量"z",它与变量"y"是完全相等的,因此可以使用"y"来代替"z"。复写传播之后代码如下所示:

此代码由Java架构师必看网-架构君整理
public void foo(){ y = b.value; //...dos stuff... y = y; sum = y + y; }

2.4 无用代码消除

  无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码。上述代码中,"y=y"明显是没有意义的,可以把它消除掉:

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

  经过这些优化后,代码和最初版本达到的效果是一致的,但是相对来说省略了许多的语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。

本文来源黄智霖-blog,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/31647
3

发表评论