深入理解Java虚拟机(第3版)学习笔记——后端编译与优化(超详细)

第11章 后端编译与优化

11.1 概述

后端编译编译期把class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程

  • 即时编译(Just In Time)
  • 提前编译(Ahead Of Time)

后端编译器性能的好坏、代码优化质量的高低是衡量一款商用虚拟机优秀与否的关键指标之一。

11.2 即时编译器

热点代码:Java程序通过解释器进行解释执行时,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),

即时编译器:为了提高热点代码的执行效率,在运行时,虚拟机将会把这些热点代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

  • 通过即时编译器编译后的本地机器码,会被保存下来,如果下次发现需要执行相同的代码,就不需要去解释执行或者编译执行了,直接使用缓存中的代码。
11.2.1 解释器和编译器

**解释器:**解释器是一行一行地将字节码解析成机器码,解释到哪就执行到哪,狭义地说,就是for循环100次,你就要将循环体中的代码逐行解释执行100次。

  • 解释器可能在执行阶段会为编译器收集性能监控信息

即时编译器(JIT):以方法为单位,将热点代码的字节码一次性转为机器码,并在本地缓存起来的工具。避免了部分代码被解释器逐行解释执行的效率问题

  • 客户端编译器(C1):分层编译模式下获取更高的编译速度
  • 服务端编译器(C2):分层编译模式下获取更好的编译质量
  • 第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器

解释器还可以作为编译器激进优化时后备的“逃生门”:

  • 在编译阶段,如果发现激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I8BA0HHQ-1658981760641)(D:\note\笔记仓库\图片\image-20220728095651749.png)]

    由上图可知:在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作

在JDK7之后引入的默认编译策略——分层编译:

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后:

  • 解释器、客户端编译器和服务端编译器就会同时工作
  • 热点代码都可能会被多次编译
  • 客户端编译器获取更高的编译速度,服务端编译器来获取更好的编译质量
  • 在解释执行的时候也无须额外承担收集性能监控信息的任务
  • 而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n7kzhARB-1658981760643)(D:\note\笔记仓库\图片\image-20220728100955998.png)]

11.2.2 编译对象与触发条件

编译对象:热点代码主要包括两类:

  • 被多次调用的方法;
  • 被多次执行的循环体;

对于上面两种情况,编译的目标对象都是整个方法体

  • 对于循环体,虽然其只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。

    这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为栈上替换(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”。

热点探测的两种方式

  • 基于采样的热点探测:周期检查每个线程栈顶,统计哪个方法出现次数多,但是不准确

    容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

  • 基于计数器的热点探测:Hotspot虚拟机目前在用,为每个方法建立计数器,统计方法的调用次数。计数器分为方法调用计数器(默认阈值C1是1500次,C2是1w,到达阈值则触发即时编译,可通过参数调整)和回边计数器(统计一个方法中循环体的执行次数)。

    需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但计算结果准确

方法调用即时编译的流程

如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WnNc5N15-1658981760645)(D:\note\笔记仓库\图片\image-20220728102600428.png)]

回边计数即时编译的流程

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整 个执行过程如图11-4所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qhEWJO5u-1658981760645)(D:\note\笔记仓库\图片\image-20220728103832471.png)]

方法调用计数器和回边计数器计数上的区别?

方法调用计数器默认不是方法被调用的绝对次数

在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一段时间之内方法被调用的次数:

  • 方法调用计数器热度的衰减:当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半。此动作是虚拟机进行垃圾收集时顺便进行的。可通过参数关闭;
  • 半衰周期:上面的时间限度就是半衰周期,可通过参数设置;

回边计数器是方法被调用的绝对次数

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

11.2.3 编译过程

在默认条件下,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。

可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

客户端编译器的编译过程——简短的三段式编译器:

  • 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。

    在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、 常量传播等优化将会在字节码被构造成HIR之前完成。

  • 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示)

    在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

  • 最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dzUBZRlv-1658981760648)(D:\note\笔记仓库\图片\image-20220728104417260.png)]

服务端编译器的编译过程:

服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器

大部分的优化动作在服务端编译器上都能实现:

  • 无用代码消除、循环展开 、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等4
  • 还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除()等。
  • 另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联、分支频率预测等,

11.3 提前编译器

提前编译器(Ahead Of Time Compiler,AOT编译器):直接把程序编译成与目标机器指令集相关的二进制代码的过程。目前有两种主要的实现方式:

  • 与传统C、C++编译器类似 的,在程序运行之前把程序代码编译成机器码的静态翻译工作
  • 把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用。

两种方式的优点:

  1. 第一种实现方式在Java中的存在价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。例如最耗时的优化措施之一:过程见分析,必须在全程序范围内做大量耗时的计算工作,如果是在程序运行之前进行的静态编译,这些耗时操作就可以大胆的进行。
  2. 第二种方式,本质上是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热之后才能达到最高性能的问题。这种提前编译被称为动态提前编译或者直接叫即时编译缓存。HotSpot运行时可以直接加载这些编译结果,实现快速程序启动速度,减少程序达到全速运行状态所需要的时间。

但是即时编译相比于提前编译也有很多优点:

  • 性能分析制导优化:解释器和客户端编译器在运行期间会不断收集性能监控信息,这些信息一般无法在静态分析是获得,或者不一定存在唯一的解,但在动态运行时很容易得到
  • 激进预测性优化:静态优化无论何时都必须保证优化后的所有程序外部可见影响(不仅仅是执行结果)与优化前必须是一致的。而即时编译的策略就可以不必那么保守,如果果性能监控信息能够支持它做出一些正确的可能性很大但是无法保证绝对正确的预测判断,就可以进行大胆的优化,大不了退回到低级编译器甚至解释器上运行。而这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码
  • 链接时优化:由于Java天生是动态连接的,所以提前编译无法做到链接后的优化。

11.4 编译器优化技术

1. 方法内联

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

主要目的:

  • 去除方法调用的成本(如查找方法版本,建立栈桢等)
  • 为其他优化建立良好的基础,便于在更大范围上进行后续优化手段,可以获得更好的优化效果。

,所以,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。

由于Java中大多数的方法是虚方法,由于动态链接的规则,所以按照经典编译原理的优化理论,大多数的Java方法在编译期都无法进行内联。

对于一个虚方法,编译器静态的去做内联的时候很难确定应该使用哪个方法版本?

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

  • 如果是非虚方法,那么直接进 行内联就可以了,这种的内联是有百分百安全保障的;

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

    不过由于Java程序动态连接的特性,可能在运行过程中会加载新的类型改变CHA结论,因此这种内联属于激进预测性优化,必须预留好逃生门,即当假设条件不成立时的退路

    如果在过程中加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译

如果是多个版本的,编译器会使用内联缓存(Inline Cache)的方式来缩减方法调用的开销:内联缓存是一个建立在目标方法正常入口之前的缓存,它的工作原理大致为:

  1. 在未发生方法调用之前,内联缓存状态为空
  2. 当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是种单态内联缓存(Monomorphic Inline Cache)。
  3. 但如果真的出现方法接收者不一致的情况, 就说明程序用到了虚方法的多态特性, 这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行

在Java虚拟机中运行方法内敛多数情况下是一种激进优化

2. 逃逸分析

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

根据逃逸程度,可能为这个对象实例采取不同程度的优化,如:

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

  • 标量替换:

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

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

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

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

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

3. 公共子表达式消除

公共子表达式消除的原理:如果一 个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直 接用前面计算过的表达式结果代替E。

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

假设存在如下代码:

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

如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将如代码清单11-12所示,是完全遵照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

当这段代码进入虚拟机即时编译器后,它将进行如下优化

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

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

int d = E * 13 + a + a;
4. 数组边界检查消除

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

有时数组边界检查不是必须继续进行的这些情况下就可以省略:

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

隐式异常处理

数组边界检查的例子放在更高的视角来看,大量的安全检查使编写Java程序比编写C和 C++程序容易了很多。例如系统会提醒我们的各种异常。但这些安全检查也导致出现相同的程序, 从而使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(),务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器)。

  • 这样当foo不为空的时候,对value的访问是不会有任何额外对foo判空的开销的
  • 当foo真的为空时,必须转到异常处理器中恢复中断并抛出NullPointException异常。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多
  • HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息来判断当前的foo是否会经常为空来自动选择最合适的方案。(即是否进行优化)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值