JVM学习笔记(3)---程序编译与代码优化之后端编译与优化

后端编译与优化

概述
如果我们把字节码看作是程序语言的一种中间表示形式的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
前面分析了 JVM 的前端编译器 Javac,本文分析后端编译器:即时编译器(JIT 编译器)和提前编译器(AOT 编译器)。
其实二者都不是 JVM 必需的组成部分。但是,后端编译器编译性能的好坏、代码优化质量的高低,却是衡量一款商用 JVM 优秀与否的关键指标之一,也是其核心所在。

即时编译器

目前主流的两款商用 JVM(HotSpot、OpenJ9)中,Java 程序最初都是通过解释器(Interpreter)解释执行的,当 JVM 发现某个方法或代码块的执行特别频繁,就会认为它们是“热点代码(Hot Spot Code)”。为了提高热点代码的执行效率,JVM 会在运行时把这部分代码编译成本地机器码,并用各种手段去优化代码。运行时完成这个任务的后端编译器被称为即时编译器。

  • 为何HotSpot虚拟机要使用解释器与即时翻译器并存的架构?
    当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。
    程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存
    解释器与编译器经常是相辅相成地配合工作
    在这里插入图片描述

  • 为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
    实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

  • 程序何时使用解释器执行?何时使用编译器执行?
    编译对象和触发条件
    ​ ​ ​ ​ ​ ​ 热点代码有两类,一个是被多次调用的方法还有就是被多次执行的循环体,被多次执行的循环体是为了解决一个方法只被调用过1次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样的循环体代码也被重复执行多次,所以也认为这是热点代码。
    这两种情况编译的目标对象都是整个方法体。在描述热点代码的时候,提到了个词——多次执行,怎么定义多次呢?要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”,其实进行热点探测并不一定要直到方法具体被调用了多少次,目前主流的热点探测判定方式有两种:基于采样的热点探测基于计数器的热点探测
    基于采样的热点探测:虚拟机周期性检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那该方法就是热点方法。实现简单高效,容易获取方法调用关系,缺点是很难精确的确认一个方法的热度,容易因为收到线程阻塞或别的因素影响扰乱热点探测。
    基于计数器的热点探测:为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为是热点方法。统计结果相对严谨,但是为每个方法维护计数器,实现比较麻烦,而且不能直接获取到方法的调用关系。
    ​ ​ ​ ​ ​ ​ 计数器有两种,一个是方法调用计数器,另一个是回边(在循环边界往回跳转)计数器。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
    方法调用计数器
    ​ ​ ​ ​ 当一个方法被调用时,会先检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的调用计数器加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个该方法的代码编译请求。在下次进行方法调用的时候,重复此流程。
    在这里插入图片描述
    在向即时编译器提交编译请求之后,执行引擎并不会进行阻塞,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成,这样做很明显不会造成程序运行中的阻塞。并且,我们可以判断,即时编译由一个后台线程操作进行。
    ​ ​ ​ ​ ​ ​ 在方法调用计数器中还有两个特别重要的概念:方法调用计数器的热度衰减与半衰周期。
    ​ ​ ​ ​ ​ 如果不做任何设置,方法调用计数器统计的并不是方法调用的绝对次数,而是一个相对的执行频率。也就是说,如果在一定的时间内,方法调用的次数不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程就是方法调用计数器的热度衰减。而这段时间,就是此方法统计的半衰周期。
    ​ ​ ​ ​ ​ ​ 进行热度衰减的动作是在垃圾收集的时候顺便进行的。我们可以通过调节虚拟机参数-XX:CounterHalfLifeTime指定是否进行热度衰减,或者调整它的半衰周期。
    回边计数器
    ​ ​ ​ ​ ​ ​ 当解释器遇到一条回边指令(编译原理的相关知识,可以粗略理解为循环)时,会先检查将要执行的代码片段是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的回边计数器加1,然后判断方法调用计数器与回边计数器之和是否超过回边调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个OSR编译请求,并且会把回边计数器的值降低一些,以便继续在解释器中执行循环。在下次进行方法调用的时候,重复此流程。
    在这里插入图片描述
    ​ ​ 与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
    0:程序纯解释,解释器不开启性能监控功能
    1:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的优化,不开启性能监控功能。
    2:使用客户端编译器,仅开启方法及回边(从循环边界往回跳转)次数统计等有限的性能监控。
    3:客户端编译器,全面开启性能监控,还会收集分支跳转,虚方法调用版本等全部统计信息。
    4:使用服务器编译器,会根据监控信息进行一些不可靠的激进优化。
    层次转换图如下所示:
    在这里插入图片描述

  • 哪些程序代码会被编译为本地代码?如何编译本地代码?
    被多次调用的方法。·被多次执行的循环体。
    编译的目标对象都是整个方法体,而不会是单独的循环体。
    第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。
    而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

  • 如何从外部观察到即时编译器的编译过程和编译结果?
    在允许后台编译的场景下,在编译完全前,JVM仍会按照解释的方式执行。可是如果关闭(-XX:-BackgroundCompilation),则会被阻塞等待编译完成,再运行,并执行编译器输出的本地代码。
    客户端:它是个简单快速的三段式编译器,它放弃了许多耗时较长的全局优化,关注局部优化 。
    在这里插入图片描述
    部分运行参数需要FastDebug或SlowDebug优化级别的HotSpot虚拟机才能够支持,Product级别的虚拟机无法使用这部分参数。
    即一般人观察不到
    自己动手编译,或者非官方编译版本

编译器优化技术

主要介绍四种有代表性的优化技术

  • 最重要的优化技术之一:方法内联
  • 最前沿的优化技术之一:逃逸分析
  • 语言无关的经典优化技术之一:公共子表达式消除
  • 语言相关的经典优化技术之一:数组边界检查消除

方法内联
先举个例子,优化前的原始代码如下:

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

对上面的代码应用方法内联优化后如下:

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

方法内联的主要目的有两个,一是去除方法调用的成本,二是为其他优化建立良好的基础。方法内敛膨胀之后可以便于在更大范围上进行后续的优化,可以取得更好的优化效果,各编译器一般会把内联优化放在优化序列的最前面。
方法内联就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免真实的方法调用。
对于java方法来说,难点在于很多方法是虚方法,在运行前不知道调用的多态选择,为了解决这个问题,java虚拟机引入了一个名为类型继承关系分析(CHA),编译器会根据不同的情况采用不同的方法:
①如果是非虚的方法,直接进行内联即可。
②如果是虚方法且这个方法在当前程序状态下只有一个目标版本可以选择,可以通过假设进行“守护内联”。因为在后面可能加载到了新的类型会改变CHA结论,所以这种内联属于激进预测性优化,必须预留好“逃生门”。
③如果是虚方法且有多个版本可以选择,将用“内联缓存”的方式来缩减方法调用的开销,可以理解为记录下每次不同版本的方法调用,调用一次后下一次只要判断方法所采取的是什么版本就可以立刻进行内联。
逃逸分析
逃逸分析与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
其基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸,方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果可以证明一个对象不会发生方法逃逸和线程逃逸(这意味着,别的方法和线程无法通过任何途径访问到这个对象),那么,虚拟机可能会为这个变量进行一些高效的优化,优化手段有以下几种:

  • 栈上分配:在我们的常识中,几乎所有的对象都是在Java堆上分配和创建的,java堆中的对象是线程共享的,只要持有这个对象的引用,就可以访问堆中存储的这个对象的数据。虚拟机的垃圾收集,可以回收堆中不再使用的对象,但是,不管是回收还是筛选可回收对象,或者是回收和整理内存,都是需要消耗时间的。
    如果可以确定一个对象不会发生方法逃逸,那么可以让这个对象直接在栈上分配,这样对象占用的内存就可以随着栈帧出栈而销毁。在很多应用中,不会逃逸的局部变量所占的比例很大,如果可以使用栈上分配,那么大量的对象就会随着方法的结束而自动销毁了,这样垃圾收集系统的压力会小很多。
  • 标量替换:
    标量:一个数据无法再分解为更小的数据来表示了,Java虚拟机中的原始数据类型byte、short、int、long、boolean、char、float、double 以及reference类型等,都不能再进一步分解了,这些就可以称为标量。
    聚合量:如果一个数据可以继续分解,就称为聚合量。对象就是最典型的聚合量。
    如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复到原始类型来访问,就叫标量替换。
  • 同步消除:如果逃逸分析能够确定一个变量不会发生线程逃逸,那么这个变量的读写就不会发生线程竞争,对这个变量实施的同步措施就可以消除了。

下列通过实例Java代码的变化过程来模拟逃逸分析是如何工作的,向读者展示逃逸分析能够实现的效果。初始代码如下

//完全未优化的代码
public int test(int x){
    int xx=x + 2;
    Point p=new Point(xx,42);
    return p.getX();
}

此处省略了Point类的代码,这就是一个包含x和y坐标的POJO类型,读者应该很容易想象出它的样子。
第一步,将Point的构造函数和getX()方法进行内联优化:

//步骤1:构造函数内联后的样子
public int test(int x){
    int xx =x+2;
    Point p=point_memory_alloc();   //在堆中分配p对象的示意方法
    p.x=xx;       //Point构造函数被内联后的样子
    p.y=42;      
    return p.x;      //Point::getX()被内联后的样子
}

第二步,经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸,这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免Point对象实例被实际创建,优化后的结果如下所示:

//步骤2:标量替换后的样子
public int test(int x){
     int xx =x+2;
     int px=xx;
     int py=42;
     return px;
     }

第三步,通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:

//步骤3:做无效代码消除后的样子
public int test(int x){
    return x+2;
    }

从测试结果来看,实施逃逸分析后的程序在MicroBenchmarks中往往能得到不错的成绩,但是在实际的应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或分析过程耗时但却无法有效判别出非逃逸对象而导致性能下降,所以曾经在很长的一段时间里,即使是服务端编译器,也默认不开启逃逸分析,甚至在某些版本中还曾经完全禁止了这项优化,一直到JDK7时这项优化才成为服务端编译器默认开启的选项。如果有需要在Java代码运行时,可通过JVM参数可指定是否开启逃逸分析,-XX:+DoEscapeAnalysis : 表示开启逃逸分析;-XX:-DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis。
公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,他的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。举个简单的例子来说明他的优化过程,假设存在如下代码:

int d = (c*b)*12+a+(a+b*c);

如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码如下所示,是完全遵照Java源码的写法直译而成的。

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

当这段代码进入到虚拟机即时编译器后,他将进行如下优化:编译器检测到”cb“与”bc“是一样的表达式,而且在计算期间b与c的值是不变的。因此,这条表达式就可能被视为:

int d = E*12+a+(a+E);

这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简(Algebraic Simplification),把表达式变为:

int d = E*13+a*2;

表达式进行变换之后,再计算起来就可以节省一些时间了。
数组边界检查消除
数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样在本质上是裸指针操作。如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下文的范围检查,即检查i必须满足>=0&&i<foo.length这个条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException。这对软件开发者来说是一件很好的事情,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏的检查则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如foo[3],只要在编译器根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无需判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0, fo.length]之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
将这个数组边界检查的例子放在更高的角度来看,大量的安全检查令编写Java程序比编写C/C++程序容易的多,如数组越界会得到ArrayIndexOutOfBoundsException异常,空指针访问会得到NullPointException,除数为零会得到ArithmeticException等,在C/C++程序中出现类似的问题,一不小心就会出现Segment Fault信号或者Window编程中常见的“xxx内存不能为Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致了相同的程序,Java要比C/C++做更多的事情(各种检查判断),这些事情就成为一种隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提到编译器完成的思路之外,另外还有一种避免思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种思路。举个例子,例如程序中访问一个对象(假设对象叫foo)的某个属性(假设属性叫value),那以Java伪代码来表示虚拟机访问foo.value的过程如下。

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

在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。

try{
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

虚拟机会注册一个Segment Fault信号的异常处理(伪代码中的uncommon_trap()),这样当foo不为空的时候,对value的访问是不会额外消耗一次对foo判空的开销的。代价就是当foo真的为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空的话,这样的优化反而会让程序更慢,还好HotSpot虚拟机足够“聪明”,它会根据运行期收集到的Profile信息自动选择最优方案。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值