聊聊那些编译器优化

什么是编译器优化

在了解了即时编译,提前编译后。大家已经有了一个认知:编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不是能不能由程序代码翻译成机器码,翻译的质量才是决定编译器优秀与否的关键。本节将介绍几种HotSpot虚拟机的即时编译器在生成代码时采用的代码优化技术。以小见大,见微知著,希望大家从这篇文章中对编译器代码优化有一个大概的认知。

四种经典编译器优化

方法内联(优化陷阱)

方法内联的就是把目标方法的代码原封不动的“复制”到发起调用的方法中,避免发生真实的方法调用。它的主要目的有两个:

1. 去除方法调用的成本(如查找方法版本,建立栈桢等)

2. 为其他优化建立良好的基础,便于在更大范围上进行后续优化手段,可以获得更好的优化效果。

对于一个虚方法,编译器静态的去做内联的时候很难确定应该使用哪个方法版本。而在Java中,只有使用invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会在编译器进行解析,最多再算上被final修饰的方法(虽然他是使用invokevirtual指令调用的)。其他的Java方法调用都必须运行时进行方法接收者的多态选择,它们都可能存在多于一个版本的方法接收者,简而言之,Java语言中默认的实例方法是虚方法。

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

如果是非虚方法,那么只进行内联,这种内联是百分之百安全的;如果是虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本,如果查询只有一个版本,那么就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。

如果是多个版本的,编译器会使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。内联缓存是一个建立在目标方法正常人口之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是种单态内联缓存(Monomorphic Inline Cache)。 通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况, 就说明程序用到了虚方法的多态特性, 这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行

在Java虚拟机中运行方法内联多数情况下是一种激进优化(简单来说就是方法的合并,但是有一些虚方法,不知道其运行时实际运行的是哪个方法(也就是多态)。导致出现了一些问题。此时可以由C1或解释执行帮忙处理)。

逃逸分析

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义之后,它可能被外部方法所引用,例如作为调用参数传入到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

根据逃逸程度可以进行不同程度的优化:

1. 栈上分配(Stack Allocation):(确认不会逃逸就把对象分配在栈中)在Java虚拟机中,一般是在Java堆上分配创建内存的空间,Java堆中的对象对于各个线程都是共享和可见的,但回收和整理堆内存,都需要消耗大量资源。所以对一个不会逃逸出线程的对象在栈上分配就是一个不错的主意,对象所占用的内存会随着栈桢的出栈而销毁,大量对象会随着方法的结束而自动销毁,垃圾收集子系统的压力会下降不少。栈上分配支持方法逃逸,不支持线程逃逸

2. 标量替换(Scalar Replacement):(确认不会逃逸就把对象的成员变量编变为帧局部变量表中的局部变量)把Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问(User.age=>int age),这个过程就称为标量替换。假如逃逸分析证明一个对象不会被方法外部访问,并且可以将这个对象拆散,那么程序真正执行的时候就不会去创建该对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替,而且这些成员变量还可以分配在栈上。不过他不允许对象逃逸出方法范围

3. 同步消除(Synchronization Elimination):(确认不会逃逸就把对象锁消除掉)线程同步本身是一个相当耗时的过程,如果逃逸分析·能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以安全的消除掉。

不过现在逃逸分析仍然不成熟,原因是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。

公共子表达式消除

公共子表达式消除是一项非常经典、应用普遍的编译器优化技术。其含义为:如果一个表达式E之前就被计算过了,并且先前的计算到现在E中所有变量的值都没有改变过,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间在对它重新进行计算,只需要直接用前面计算过的表达式结果替代E。

例如之前计算过a*b的值。那么当发现现在需要计算b*a的值。那直接就用直接计算好的就行。

数组边界检查消除

由于Java语言是一门动态安全检查的语言,对于数组foo[],访问数组元素foo[i]的时候系统会自动进行上下界范围检查,即i必须满足i>=0 && i<foo.length的访问条件,否则将抛出运行时异常。这样每一次读写都要进行一次检查无疑是一种负担。

若是foo[i]中的i不是固定的值,那么每次判定是否越界就是必须的,但是有时数组边界检查不是必须继续进行的,此时就可以省略。例如数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下表“3”没有越界,执行时的时候就无需判断了。更加典型情况是,对于数组访问发生在循环中,并且使用循环变量对数组进行访问。如果编译器只要通过数据流分析就可以判定循环遍历取值范围永远在[0, foo.length)之内,那就可以把数组边界检查消除。

猜你喜欢

转载自blog.csdn.net/weixin_47184173/article/details/113648736