第 11 章:后端编译与优化

本文最后更新于:1 年前

后端编译:编译器把 Class 文件转换成与本地基础设施硬件指令集、操作系统相关的二进制机器码。

解释器解释执行 Java 程序->提高热点代码某个运行特别频繁的方法或代码块的执行效率->即时编译器把代码编译成本地机器码,并以各种手段尽可能地进行代码优化

  • 使用解释器执行:
    • 程序需要迅速启动和执行。
    • 程序运行环境中内存资源限制较大。
    • 作为编译器激进优化时后备的逃生门当加载了新类以后,类型继承结构出现变化时可以通过逆优化退回到解释状态继续执行
  • 使用编译器执行:
    • 更高的执行效率。
    • 根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段。

HotSpot 虚拟机实现三个不同的即时编译器:

  • 客户端编译器C1 编译器
    获取更高的编译速度。
  • 服务端编译器C2 编译器
    获取更好的编译质量使用高复杂度的优化算法
  • 【JDK 9+】Graal 编译器:
    长期目标是替换 C2。

虚拟机执行模式:

混合模式解释器与其中一个编译器直接搭配->平衡程序启动响应速度与运行效率->分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次

分层编译:

  1. 第 0 层:程序纯解释执行;解释器不开启性能监控功能。
  2. 第 1 层:使用客户端编译器将字节码编译为本地代码,会进行简单可靠的稳定优化;解释器不开启性能监控功能。
  3. 第 2 层:使用客户端编译器;解释器开启有限的性能监控功能。
  4. 第 3 层:使用客户端编译器;解释器开启全部性能监控。
  5. 第 4 层:使用服务端编译器,会进行一些不可靠的激进优化。

对于热点代码,即时编译器编译的目标对象都是方法体,只是执行入口从方法第几条字节码指令开始执行不同。这种编译发生在方法执行的过程中,所以被称为栈上替换方法的栈帧还在栈上,方法就被替换了

热点探测确定某段代码是否为热点代码、是否需要触发即时编译判定方式:

  • 【J9】基于采样的热点探测:定期检查各个线程的调用栈顶,经常出现在栈顶的方法就是热点方法。
    • 优:简单高效;很容易获取方法调用关系将调用堆栈展开即可
    • 劣:容易受到线程阻塞等影响而扰乱热点探测。
  • 【HotSpot】基于计数器的热点探测:为每个方法建立计数器,统计每个方法的执行次数。
    • 优:统计结果更加精确。
    • 劣:需要维护每个方法的计数器;不能直接获取到方法的调用关系。

计数器:都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

  • 方法调用计数器:统计一段时间内方法被调用的次数。
    • 如果方法调用计数器回边计数器值之和超过方法调用计数器的阈值,会触发即时编译。
    • 当超过一定的时间限度,还没有触发即时编译,则该次数会被减少一半该动作在垃圾收集时进行,即热度的衰减,这段时间被称为此方法统计的半衰周期
  • 回边在循环边界往回跳转计数器:统计一个方法中循环体代码被执行的次数。
    • 如果方法调用计数器回边计数器值之和超过回边计数器的阈值,会触发栈上替换编译。

编译过程:

  • 客户端编译器的编译主要在于局部性的优化,放弃了许多耗时较长的全局优化手段过程:

    (在字节码上完成一部分基础优化方法内联、常量传播等。)
    1、一个平台独立的前端将字节码构造成一种高级中间代码表示HIR,与目标机器指令集无关的中间表示
    (在 HIR 上完成一些优化空值检查消除、范围检查消除等。)
    2、一个平台相关的后端从 HIR 中产生低级中间代码表示LIR,与目标机器指令集相关的中间表示
    3、在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,产生机器代码。
  • 服务端编译器是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器。相对于客户端编译器编译输出的代码质量有很大提高,

提前编译器:

  • 【传统的提前编译】在程序运行之前把程序代码编译成机器码的静态翻译工作。
  • 【动态提前编译也叫即时编译缓存】把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码时直接把它加载进来使用。

即时编译器->不占用程序运行的时间、不消耗运算资源->提前编译器

即时编译器相对于提前编译器的优势:

  • 性能分析制导优化:如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源给它。
  • 激进预测性优化:大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。
  • 链接时优化:可以实现跨越动态链接库的优化Java 语言天生就是动态链接的,一个个 Class 文件在运行期被加载到虚拟机内存当中,在即时编译器里产生优化后的本地代码,比如方法内联。

编译器优化技术

  • 方法内联:把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用。
    • 最重要的优化技术之一。
    • 为其他优化手段建立良好的基础。
    • 多数情况下都是一种激进优化。
    • 对于一个虚方法C、C++ 默认方法是非虚方法,Java 默认方法是虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,需要根据实际类型动态分派。Java 虚拟机使用类型继承关系分析技术解决这个问题。
      类型继承关系分析技术:
      • 对于非虚方法直接内联。
      • 对于只查询到一个版本的虚方法会进行守护内联激进预测性优化,如果加载了导致继承关系发生变化的新类就退回到解释状态执行或重新进行编译
      • 对于多个版本的虚方法使用内联缓存缓存记录下方法接收者的版本信息
  • 逃逸分析:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如方法逃逸作为参数传递到其他方法中线程逃逸赋值给可以在其他线程中访问的实例变量
    • 最前沿的优化技术之一。
    • 为其他优化手段建立良好的基础。
      • 栈上分配:如果确定一个对象不会逃逸出线程之外,让这个对象在栈上分配内存是一个很不错的主意对象所占用的内存空间可以随栈帧出栈而销毁
      • 标量替换:把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问让对象的成员变量在栈上分配和读写
      • 同步消除:如果可以确定一个变量不会逃逸出线程,无法被其他线程访问,就可以消除同步措施造成的耗时操作。
    • 逃逸分析的计算成本非常高,目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。
  • 公共子表达式消除:对于公共子表达式表达式之前已经被计算过了,并且从先前的计算到现在所有变量的值都没有发生变化,只需要直接用前面计算过的表达式结果代替。
    • 语言无关的经典优化技术之一。
  • 数组边界检查消除:如果编译器只要通过数据流分析就可以判定循环变量的取值范围不越界,那么在循环中就可以把整个数组的上下界检查消除掉。
    • 语言相关的经典优化技术之一。