深入理解java虚拟机笔记-后端编译与优化

一、优化技术概览

OpenJDK的官方Wiki上, HotSpot虚拟机设计团队列出了一个相对比较全面的、 即时编译器中采用的优化技术列表, 如表11-1所示, 其中有不少经典编译器的优化手段, 也有许多针对Java语言, 或者说针对运行在Java虚拟机上的所有语言进行的优化。

 

 

 

 

 

本节先对这些技术进行概览, 在后面几节中,将挑选若干最重要或最典型的优化, 与读者一起看看优化前后的代码发生了怎样的变化。 表11-1 即时编译器优化技术一览 接下来, 笔者挑选了四项有代表性的优化技术, 与大家一起观察它们是如何运作的。 它们分别是:

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

·最前沿的优化技术之一: 逃逸分析。

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

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

二、方法内联

在前面的讲解中, 我们多次提到方法内联, 说它是编译器最重要的优化手段, 甚至都可以不加上“之一”。 内联被业内戏称为优化之母, 因为除了消除方法调用的成本之外, 它更重要的意义是为其他优化手段建立良好的基础, 代码清单11-11所示的简单例子就揭示了内联对其他优化手段的巨大价值: 没有内联, 多数其他优化都无法有效进行。

方法内联的优化行为理解起来是没有任何困难的, 不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中, 避免发生真实的方法调用而已。 但实际上Java虚拟机中的内联过程却远没有想象中容易, 甚至如果不是即时编译器做了一些特殊的努力, 按照经典编译原理的优化理论, 大多数的Java方法都无法进行内联。

无法内联的原因其实在第8章中讲解Java方法解析和分派调用的时候就已经解释过: 只有使用invokespecial指令调用的私有方法、 实例构造器、 父类方法和使用invokestatic指令调用的静态方法才会在编译期进行解析。

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

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

更糟糕的情况是, 由于Java提倡使用面向对象的方式进行编程, 而Java对象的方法默认就是虚方法, 可以说Java间接鼓励了程序员使用大量的虚方法来实现程序逻辑。 根据上面的分析可知, 内联与虚方法之间会产生“矛盾”, 那是不是为了提高执行性能, 就应该默认给每个方法都使用final关键字去修饰呢? C和C++语言的确是这样做的, 默认的方法是非虚方法, 如果需要用到多态, 就用virtual关键字来修饰, 但Java选择了在虚拟机中解决这个问题。

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

这样, 编译器在进行内联时就会分不同情况采取不同的处理: 如果是非虚方法, 那么直接进行内联就可以了, 这种的内联是有百分百安全保障的; 如果遇到虚方法, 则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择, 如果查询到只有一个版本, 那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联, 这种内联被称为守护内联(Guarded Inlining) 。

不过由于Java程序是动态连接的, 说不准什么时候就会加载到新的类型从而改变CHA结论, 因此这种内联属于激进预测性优化, 必须预留好“逃生门”, 即当假设条件不成立时的“退路”(Slow Path) 。 假如在程序的后续执行过程中, 虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类, 那这个内联优化的代码就可以一直使用下去。 如果加载了导致继承关系发生变化的新类, 那么就必须抛弃已经编译的代码, 退回到解释状态进行执行, 或者重新进行编译。

假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择, 那即时编译器还将进行最后一次努力, 使用内联缓存(Inline Cache) 的方式来缩减方法调用的开销。 这种状态下方法调用是真正发生了的, 但是比起直接查虚方法表还是要快一些。

内联缓存是一个建立在目标方法正常入口之前的缓存, 它的工作原理大致为: 在未发生方法调用之前, 内联缓存状态为空, 当第一次调用发生后, 缓存记录下方法接收者的版本信息, 并且每次进行方法调用时都比较接收者的版本。

如果以后进来的每次调用的方法接收者版本都是一样的, 那么这时它就是一种单态内联缓存(Monomorphic Inline Cache) 。 通过该缓存来调用, 比用不内联的非虚方法调用, 仅多了一次类型判断的开销而已。 但如果 真的出现方法接收者不一致的情况, 就说明程序用到了虚方法的多态特性, 这时候会退化成超多态内联缓存(Megamorphic Inline Cache) , 其开销相当于真正查找虚方法表来进行方法分派。

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

三、逃逸分析

逃逸分析(Escape Analysis) 是目前Java虚拟机中比较前沿的优化技术, 它与类型继承关系分析一样, 并不是直接优化代码的手段, 而是为其他优化措施提供依据的分析技术。

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

如果能证明一个对象不会逃逸到方法或线程之外 , 或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程) , 则可能为这个对象实例采取不同程度的优化, 如:

栈上分配

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

如果确定一个对象不会逃逸出线程之外, 那让这个对象在栈上分配内存将会是一个很不错的主意, 对象所占用的内存空间就可以随栈帧出栈而销毁。 在一般应用中, 完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的, 如果能使用栈上分配, 那大量的对象就会随着方法的结束而自动销毁了, 垃圾收集子系统的压力将会下降很多。

栈上分配可以支持方法逃逸, 但不能支持线程逃逸。

标量替换

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

相对的, 如果一个数据可以继续分解, 那它就被称为聚合量(Aggregate) , Java中的对象就是典型的聚合量。 如果把一个Java对象拆散, 根据程序访问的情况, 将其用到的成员变量恢复为原始类型来访问, 这个过程就称为标量替换。

假如逃逸分析能够证明一个对象不会被方法外部访问, 并且这个对象可以被拆散, 那么程序真正执行的时候将可能不去创建这个对象, 而改为直接创建它的若干个被这个方法使用的成员变量来代替。

将对象拆分后, 除了可以让对象的成员变量在栈上(栈上存储的数据, 很大机会被虚拟机分配至物理机器的高速寄存器中存储) 分配和读写之外, 还可以为后续进一步的优化手段创建条件。

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

同步消除

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

前面介绍即时编译、 提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。 可以试想一下, 如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了, 所以目前虚拟机只能采用不那么准确, 但时间压力相对较小的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可) , 而C#也支持值类型, 可以很自然地做到标量替换(但并不会对引用类型做这种优化) 。

在灵活运用栈内存方面, 确实是Java的一个弱项。 在现在仍处于实验阶段的Valhalla项目里, 设计了新的inline关键字用于定义Java的内联类型,目的是实现与C#中值类型相对标的功能。 有了这个标识与约束, 以后逃逸分析做起来就会简单很多。

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

第一步, 将Point的构造函数和getX()方法进行内联优化:

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

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

JDK 7时这项优化才成为服务端编译器默认开启的选项。 如果有需要, 或者确认对程序运行有益,

用户也可以使用参数-XX: +DoEscapeAnalysis来手动开启逃逸分析,

开启之后可以通过参数-XX: +PrintEscapeAnalysis来查看分析结果。

有了逃逸分析支持之后, 用户可以使用参数-XX: +EliminateAllocations来开启标量替换,

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

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

尽管目前逃逸分析技术仍在发展之中, 未完全成熟, 但它是即时编译器优化技术的一个重要前进方向, 在日后的Java虚拟机中, 逃逸分析技术肯定会支撑起一系列更实用、 有效的优化技术。

四、公共子表达式消除

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

如果这种优化仅限于程序基本块内, 便可称为局部公共子表达式消除(Local Common Subexpression Elimination) , 如果这种优化的范围涵盖了多个基本块, 那就称为全局公共子表达式消除(Global Common Subexpression 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)之内, 那么在循环中就可以把整个数组的上下界检查消除掉, 这可以节省很多次的条件判断操作。

posted @ 2022-03-23 16:04  Mars.wang  阅读(49)  评论(0编辑  收藏  举报