JVM后端编译与优化——编译器优化技术

目录

优化技术概览

编译器策略

基于性能监控的优化技术​编辑

基于证据的优化技术 

数据流敏感重写​编辑

Java语言相关的优化技术 

 内存及代码位置变换

循环变换 

全局代码调整

控制流图变换

简单的优化例子

最重要的优化技术之一:方法内联

类型继承关系分析(CHA)

最前沿的优化技术之一:逃逸分析(优化手段:栈上分配,标量替换,同步消除)

栈上分配(Stack Allocations)

标量替换(Scalar Replacement)

同步消除(Synchronization Elimination)

逃逸分析后进行优化的例子

优化开启的JVM参数(JDK7默认开启逃逸分析)

语言无关的经典优化技术之一:公共子表达式消除

语言相关的经典优化技术之一:数组边界检查消除

隐式异常处理


优化技术概览

编译器策略

基于性能监控的优化技术

基于证据的优化技术 

 

数据流敏感重写

Java语言相关的优化技术 

 内存及代码位置变换

循环变换 

全局代码调整

 

控制流图变换

 

简单的优化例子

// original code
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;
}

第二步进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉的“…do stuff…”所代表的操作不会改变b.value的值,那么就可以把“z=b.value”替换为“z=y”,因为上一 句“y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局部变量了。如果 把b.value看作一个表达式,那么也可以把这项优化看作一种公共子表达式消除(Common Subexpression Elimination)

// 冗余存储消除的代码
public void foo() {
    y = b.value;
    // ...do stuff...
    z = y;
    sum = y + z;
}

第三步进行复写传播(Copy Propagation),因为这段程序的逻辑之中没有必要使用一个额外的变 量z,它与变量y是完全相等的,因此我们可以使用y来代替z。

// 复写传播的代码
public void foo() {
    y = b.value;
    // ...do stuff...
    y = y;
    sum = y + y;
}

第四步进行无用代码消除(Dead Code Elimination),无用代码可能是永远不会被执行的代码,也 可能是完全没有意义的代码。因此它又被很形象地称为“Dead Code”

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

经过四次优化之后,代码所达到的效果是一致的,但 是前者比后者省略了许多语句,体现在字节码和机器码指令上的差距会更大,执行效率的差距也会更 高。


最重要的优化技术之一:方法内联

例子里testInline()方法的内部全部是无用的代码,但如 果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何“Dead Code”的存在。如果分开来 看,foo()和testInline()两个方法里面的操作都有可能是有意义的。

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

方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复 制”到发起调用的方法之中,避免发生真实的方法调用而已

但实际上Java虚拟机中的内联过程却远没有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。

无法内联的原因

只有使用 invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会在编译期进行解析。除了上述四种方法之外(最多再除去被final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但也是非虚方法,《Java语言规范》中明确说明了这点)。

其他的Java方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简 而言之Java语言中默认的实例方法是虚方法。

对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以将代码b.get()直接内联为b.value为例,如果不依赖上下文,是无法确定b的实际类型是什么的。假如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是 执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实 际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。

由于Java提倡使用面向对象的方式进行编程,而Java对象的方法默认就是虚方法,可以说Java间接鼓励了程序员使用大量的虚方法来实现程序逻辑。

根据上面的分析可知,内联与虚方法之间会产生“矛盾”,那是不是为了提高执行性能,就应该默认给每个方法都使用final关键字去修饰呢?

C和C++语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用virtual关键 字来修饰,但Java选择了在虚拟机中解决这个问题

类型继承关系分析(CHA)

为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。

编译器在进行内联时就会分不同情况采取不同的处理:

  • 如果是非虚方法,那么直接进行内联就可以了,这种的内联是有百分百安全保障的;
  • 如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择
    • 如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。
      • 不过由 于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。
      • 假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这 个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
    • ​​​​​​​假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。
      • 内联缓存是一个建立在目标方法正常入口之前的缓存。
      • 它的工作原理大致为:
        • 未发生方法调用之前内联缓存状态为空
        • 第一次调用发生 后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)
        • 通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。
        • 但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派。

所以说,在多数情况下Java虚拟机进行的方法内联都是一种激进优化

事实上,激进优化的应用 在高性能的Java虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。
 


最前沿的优化技术之一:逃逸分析(优化手段:栈上分配,标量替换,同步消除)

逃逸分析(Escape Analysis)与类型继承关系分析一 样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理:分析对象动态作用域,

当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸

甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸

从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径 访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实 例采取不同程度的优化。


栈上分配(Stack Allocations)

在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是 Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引 用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回 收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。

在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

标量替换(Scalar Replacement)

一个数据已经无法再分解成更小的数据来表示了Java虚拟 机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据 就可以被称为标量

相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量。

如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件

标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内

同步消除(Synchronization Elimination)

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。


逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地 判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支 执行时对此对象的影响。如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象, 那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小 的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很 自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一 个弱项。

逃逸分析后进行优化的例子

// 完全未优化的代码
public int test(int x) {
    int xx = x + 2;
    Point p = new Point(xx, 42);
    return p.getX();
}
// 添加一些常用方法
class Point{
    int X;
    int Y;
}

第一步,将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;
}

优化开启的JVM参数(JDK7默认开启逃逸分析)

JDK 7时这项优化才成为服务端编译器默认开启的选项。

如果有需要,或者确认对程序运行有益,用户也可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析, 开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果

有了逃逸分析支持之后,用户可以

使用参数-XX:+EliminateAllocations来开启标量替换,

使用+XX:+EliminateLocks来开启同步消 除,

使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。


语言无关的经典优化技术之一:公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:

如果一 个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式

对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E

如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称 为全局公共子表达式消除(Global Common Subexpression Elimination)。

// example
int d = (c * b) * 12 + a + (a + b * c);
// 未作任何优化的字节码
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

当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到c*b与b*c是一样的表达 式,而且在计算期间b与c的值是不变的。

// 将b*c或c*b视为公共表达式E
int d = E * 12 + a + (a + E);

这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化 ——代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为

int d = E * 13 + a + a;

语言相关的经典优化技术之一:数组边界检查消除

 数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优 化技术。

Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样实质上就是裸指针操作。如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界 的范围检查,即i必须满足“i>=0&&i<foo.length"的访问条件,否则将抛出一个运行时异常: java.lang.ArrayIndexOutOfBoundsException。

这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能够避免大多数的溢出攻击。

但是对于虚拟机的执行子系统来说,每次数组 元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能 负担。

 为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次 不漏地进行则是可以“商量”的事情

例如下面这个简单的情况:数组下标是一个常量,如foo[3],只要 在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无须判断了。

更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可 以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。

大量的安全检查使编写Java程序比编写C和 C++程序容易了很多,比如:

数组越界会得到ArrayIndexOutOfBoundsException异常;

空指针访问会得 到NullPointException异常;

除数为零会得到ArithmeticException异常……

在C和C++程序中出现类似的问题,一个不小心就会出现Segment Fault信号或者Windows编程中常见的“XXX内存不能为 Read/Write”之类的提示,处理不好程序就直接崩溃退出了。

但这些安全检查也导致出现相同的程序, 从而使Java比C和C++要做更多的事情(各种检查判断),这些事情就会导致一些隐式开销,如果不处理好它们,就很可能成为一项“Java语言天生就比较慢”的原罪。

隐式异常处理

为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译期完成的思路之外,还有一种避开的处理思路—— 隐式异常处理,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(),务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器),

这样当foo不为空的时候,对value的访问是不会有任何额外对foo判空的开销的,而代价就是当foo真的为空时,必须转到异常处理器中恢复中断并抛出NullPointException异常。

进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。

当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟 机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。

与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除 (Safepoint Elimination)、消除反射(Dereflection)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值