《深入理解JAVA虚拟机笔记》编译与优化

2023-12-29 14:42:37

从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

接下来,笔者挑选了四项有代表性的优化技术,与大家一起观察它们是如何运作的。 它们分别是:

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

内联和虚方法的矛盾

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

无法内联的原因其实在讲解《Java 方法解析和分派调用》的时候就已经解释过:只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用的静态方法才会在编译期进行解析。除了上述 4 种方法之外最多再除去被 final 修饰的方法这种特殊情况,尽管它使用 invokevirtual 指令调用,但也是非虚方法,《Java 语言规范》中明确说明了这点),其他的 Java 方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简而言之,Java 语言中默认的实例方法是虚方法

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

更糟糕的情况是,由于 Java 提倡使用面向对象的方式进行编程,而 Java 对象的方法默认就是虚方法,可以说 Java 间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与虚方法之间会产生“矛盾”

那是不是为了提高执行性能,就应该默认给每个方法都使用 final 关键字去修饰呢?C 和 C++ 语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用 virtual 关键字来修饰,但 Java 选择了在虚拟机中解决这个问题。

类型继承关系分析

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

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

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

所以说,在多数情况下 Java 虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用在高性能的 Java 虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常使用概率很小的分支都可以被激进优化“移除”如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。(提前预留好逃生门,作为假设条件不成立时的退路,如果情况不符合预期,那么就必须抛弃已编译的代码,退回到解释状态进行执行,或者重新进行编译)

逃逸分析

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

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

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

  • 栈上分配(Stack Allocations):在 Java 虚拟机中,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
  • 标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型及 reference 类型等)都不能再进一步分解了,那么这些数据就可以被称为〈标量〉。相对的,如果一个数据可以继续分解,那它就被称为〈聚合量(Aggregate)〉,Java 中的对象就是典型的聚合量如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为“标量替换”假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让 对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除(Synchronization Elimination)线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

公共子表达式消除

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

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

举个简单的例子来说明他的优化过程,假设存在如下代码:

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

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

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.ArrayIndexOutOfBondsException。这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能够避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。

无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次不漏地进行则是可以 “商量” 的事情。例如下面这个简单的情况:数组下标是一个常量,如 foo[3],只要在编译期根据数据流分析来确定 foo.length 的值,并判断下标 “3” 没有越界,执行的时候就无须判断了。更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [ 0,foo.length ) 之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。

把这个数组边界检查的例子放在更高的视角来看,大量的安全检查使编写 Java 程序比编写 C 和 C++ 程序容易了很多,比如:数组越界会得到ArrayIndexOutfBoundsExcepion 异常;空指针访问会得到 NullPointExceptioen 异常;除数为零会得到 ArithmeticExceptinon 异常…在和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 虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案

文章来源:https://blog.csdn.net/lyabc123456/article/details/135288864
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。