JVM后端编译优化 - 笔记

0. 概述

本文对后端编译器:即时编译器(JIT编译器)和提前编译器(AOT编译器)进行分析整理。

两者都不是JVM必需的组成部分。但是,后端编译器编译性能的好坏、代码优化质量的高低,去我是衡量商用JVM优秀与否的关键指标之一,也是其核心所在,最能提心技术水平与价值的功能。

1. 即时编译器

目前主流的两款商用 JVM(HotSpot、OpenJ9)中,Java 程序最初都是通过「解释器(Interpreter)」解释执行的,当 JVM 发现某个方法或代码块的执行特别频繁,就会认为它们是“热点代码(Hot Spot Code)”。

为了提高热点代码的执行效率,JVM 会在「运行时」把这部分代码编译成本地机器码,并用各种手段去优化代码。运行时完成这个任务的后端编译器被称为「即时编译器」。

HotSpot VM 内置了3个即时编译器,分别为:

  • 客户端编译器(Client Compiler),简称C1编译器。
  • 服务端编译器(Server Compiler),简称C2编译器,或Opto编译器。
  • Graal 编译器(JDK 10 出现,长期目标是替代 C2 编译器)。

1.1 解释器与编译器

1.1.1 执行流程

编译器的执行流程大致如下:

输入的代码 -> [ 解释器 解释执行 ] -> 执行结果

即时编译器的执行流程大致如下:

输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

1.1.2 对比分析

目前主流的商用 JVM 内部都同时包含解释器与编译器,二者各有优势:

  • 程序需要迅速启动和执行时,解释器可以省去编译时间,立即执行。
  • 程序启动后,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,可以减少解释器的中间消耗,提高执行效率。
  • 若运行环境的内存资源限制较大,可使用解释器执行节约内存;反之可使用编译执行来提升效率。

总结起来就是:

  1. 解释器启动较快,占用内存较小,但是执行效率稍低。
  2. 编译器启动较慢,占用内存较大,但执行效率较高。

此外,解释器还可以作为编译器激进优化时后备的“逃生门”,也就是给编译器来“兜底”,反之则不行。

凡事有利弊。这里仍以查询接口为例做类比:

  • 解释执行可以理解为直接查询数据库,也就是不使用缓存。程序启动起来比较快(无需连接缓存服务器),但后面运行的时候由于每次都要去查数据库,会有磁盘 IO 开销,会相对慢一些。
  • 而编译执行就相当于使用了缓存。虽然启动会稍慢一些(需要连接缓存服务器,初次查询时既要查询数据库,又要存入缓存),而且需要额外的开销(需要缓存服务器),但是后续的查询效率会提高很多,因为可以直接从缓存获取,不必再查询数据库。

因此,使用缓存其实就是“空间换时间”,编译器与解释器也可以类比来理解。

1.1.3 运行模式

解释器与编译器配合使用的方式在虚拟机中被称为“混合模式(Mixed Mode)”,比如我们查看 JDK 版本时:

$ java -version
java version "11.0.3" 2019-04-14 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM  18.9 (build 11.0.3+12-LTS,  mixed mode)

最后面的 mixed mode 就表示混合模式。

1.2 分层编译

JIT 编译器的编译过程是在「运行期」,这就不可避免会占用应用程序的资源。而且,想要把代码优化得更好,就要花费更多的时间。而且可能还需要解释器帮忙收集一些性能监控信息,又降低了解释器的效率。这可怎么办?

那找个折衷的方案?其实就是分层编译(Tiered Compilation)。

分了哪几个层次呢?主要包括:

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

这几个层次并非固定不变,可以根据不同的运行参数灵活使用。

1.3 热点代码

运行时会被即时编译器编译的目标是“热点代码”,主要包括下面两类:

  • 被多次调用的方法。

  • 被多次执行的循环体。

前者比较容易理解:一个方法被调用的次数多了,自然就成了热点代码。

后者是什么场景呢?当一个方法被调用的次数虽然不多,但方法体内部存在循环次数较多的循环体。这种代码也是“热点代码”(可以理解为方法的一部分是热点代码)。比如:

public void test(){
    //一些其他代码...

    //即便test()方法被调用的次数不多,但是当N足够大时,该部分代码都会成为“热点代码”
    for(int i=0;i

前者是JVM标准的及时编译。

至于后者,虽然热点代码只是方法的一部分,但编译器仍会把「整个方法」作为编译对象,只是入口不同(并非从方法的第一行代码开始)。由于该情况发生在方法执行的过程中,也被称为栈上替换(On Stack Replacement,OSR)。也就是方法的栈帧还在栈上,但方法已经被替换了。

PS: 每个方法被执行时,虚拟机栈都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈等信息。每个方法从被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

1.4 热点探测

关于热点代码的判定,前面一直提的都是“多次”,到底多少次才叫“多”呢?这个问题不仅要“定性”,还要“定量”。

要判定一段代码是不是热点代码、是否触发即时编译的行为称为“热点探测(Hot Spot Code Detection)”。

1.4.1 定量方法

热点探测的主流方法有以下两种:

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection)

就是每隔一段时间去检查一下所有线程的调用栈顶,若发现某个(或某些)方法经常出现在栈顶,该方法就会被认为是“热点代码”。J9 虚拟机使用过该方法。

这种方法的优缺点如下:

  1. 优点:实现简单高效,而且可以通过堆栈信息获取到方法之间的调用关系;
  2. 缺点:难以精确的确定方法热度,容易受到线程阻塞的干扰(即方法阻塞时可能长时间处于栈顶,可能产生误判)。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)

为每个方法(或代码块)建立计数器来统计方法的执行次数,当次数超过一定的阈值就认为是“热点代码”。HotSpot 虚拟机就是使用该方法进行探测的。

该方法的同样也有优缺点:

1.优点:统计结果更加精确严谨;
2.缺点:统计起来稍麻烦(要为每个方法建立并维护计数器),而且不能直接获取到方法的调用关系。

1.4.2 两种计数器

1.4.2.1 方法调用计数器

方法调用计数器(Invocation Counter)用来统计方法被调用的次数。它在客户端和服务端模式下的默认阈值分别为 1500 次和 10000 次。

该计数器触发即时编译的流程图如下:


image.png

PS: 方法调用计数器统计的并非方法被调用的绝对次数,而是是一个相对的执行频率。

什么意思呢?

也就是在一段时间内,如果方法的调用次数未到达阈值,计数器就会减少为原先的一半。该过程被称为热度衰减(Counter Decay),这段时间则被称为半衰周期(Counter Half Life Time)。

比如,若阈值是 10000,半衰周期是 1 小时。如果在 1 小时内,某个方法被调用了 8000 次(未达到即时编译的条件),计数器就会认为该方法没那么“热”,就要给它“泼冷水”,把次数降为 4000 (纯属个人理解)。

当然,有 JVM 参数可以对此进行调整,如下:

# 指定计数器的阈值
-XX:CompileThreshold

# 关闭热度衰减
-XX:-UseCounterDecay

# 设置半衰期时间(秒)
-XX:CounterHalfLifeTime
1.4.2.2 回边计数器

回边计数器(Back Edge Counter)用来统计方法中循环体代码执行的次数(字节码中遇到控制流向后跳转的指令称为“回边”),目的是为了触发栈上替换。

回边计数器触发即时编译的流程如下:


image.png

与此相关的几个 JVM 参数:

# OSR 比率,默认 933
-XX:OnStackReplacePercentage

# 解释器监控比率,默认 33
-XX:InterpreterProfilePercentage

2. 提前编译器

对提前编译的研究主要有两个分支。

2.1 静态翻译

一条就是在程序运行之前,把程序代码“翻译”成机器码。

JIT 编译器的主要缺点在于:它是在「运行期」进行编译的。这就不可避免地要占用应用程序的运行资源(CPU、内存等),进而影响程序的执行性能。

而这种提前编译就是把这个编译阶段放到程序的「运行期」之前,这样就可以不占用应用程序的资源。

2.2 即时编译缓存

其实就是把 JIT 编译器要做的编译工作先做好,并保存下来,当触发 JIT 编译时,直接调用这里的代码就好了。本质上就是给 JIT 编译做缓存。

这种方式也被称为动态提前编译(Dynamic AOT)或者即时编译缓存(JIT Caching)。

2.3 即时编译器与提前编译器

从上面对提前编译器的分析来看,似乎提前编译比 JIT 编译运行效率更高。那它就没缺点了吗?当然不是,否则还要 JIT 编译器干嘛。

相比提前编译器,JIT 编译器的优势在哪里呢?

  • 性能分析制导优化

解释器或客户端编译器在运行的过程中,会不断收集性能监控信息(方法版本选择、条件判断等),这些信息可以帮助 JIT 编译器对代码进行集中优化。

这一点在静态分析时是很难做到的。

  • 激进预测性优化

也就是 JIT 编译器可以进行一些稍微“激进”的优化行为,即便这些行为失败了,也有解释器可以“兜底”。而静态优化就做不到了。

此外,提前编译还会破坏 Java 平台中立性、产生字节膨胀等问题。

3. 编译优化技术

前面分析了 JIT 编译器和提前编译器,它们做的都是“翻译”工作。但关键问题不在于“能不能”翻译,而是翻译的“好不好”。也就是编译出来的代码质量高不高。

那么,它们用什么手段来提升“翻译”的质量呢?

HotSpot VM 的 JIT 编译器使用了不少优化技术(可参考:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex),下面介绍几个非常重要的。

PS: JIT 编译器对代码的优化,这里的“代码”并非我们编写的源代码,而是被编译后的字节码或者机器码。毕竟已经通过类加载器把 Class 文件加载到 JVM 了。

3.1 方法内联

方法内联是编译器最重要的优化手段,业内戏称为“优化之母”。是其他优化手段的基础。

它的行为理解起来其实很简单:就是在方法调用中,把目标方法的代码“复制”到调用的方法之中,避免发生真实的方法调用。示例代码如下:

public static void foo() {
  if (obj != null) {
    System.out.println("hello");
  }
}

public static void testInline() {
  Object obj = null;
  foo(obj);
}

该段代码实际是无用代码(Dead Code),经过方法内联(把 foo 方法的代码代入到 testInline 方法中)之后可以发现。

但若不做内联,后续即便进行了无用代码消除的优化,也无法发现该无用代码。

3.2 逃逸分析

逃逸分析(Escape Analysis)是目前 JVM 中比较前沿的优化技术。但它并不直接优化代码,而是一种为其他优化措施提供依据的分析技术。

它的基本原理是分析对象的动态作用域,当一个对象在方法中被定义后,按照逃逸程度从低到高可分为:

  • 不逃逸:对象只能在本方法内使用。
  • 方法逃逸:对象可能被外部方法引用(例如作为调用参数传递到其他方法)。
  • 线程逃逸:对象可能被外部线程访问到(例如赋值给线程共享的变量)。

若一个对象未发生逃逸,或者逃逸程度较低,可以为这个对象采取不同程度的优化。

3.2.1 栈上分配

JVM 中,对象的内存空间分配在堆上似乎是一个常识。当对象不再使用时,垃圾收集器会将其内存空间回收,这个过程其实是要消耗大量资源的。

假如……把对象的内存空间分配到栈上呢?

What ???这简直是颠覆认知!

但是,不妨沿着这个思路考虑一下:如果这样做了有什么好处呢?

这样一来对象占用的内存空间就会随着栈帧出栈而销毁,不必再由垃圾收集器费时费力地去回收了,可以节省不少资源。这样一想似乎也是不是不可以。

这就是所谓的栈上分配(Stack Allocations),它可以支持「方法逃逸」,但不支持线程逃逸。

PS:由于复杂度等原因,HotSpot 目前暂未做这项优化,但有些 JVM(例如 Excelsior JET)已经在使用了。

3.2.2 标量替换

先看一下标量(Scalar)和聚合量(Aggregate)的概念:

  • 标量:无法再分解为更小数据的数据,例如 JVM 中的原始数据类型(int、long、reference 等)。
  • 聚合量:可以继续分解的数据,例如 Java 中的对象。

所谓「标量替换(Scalar Replacement)」,就是根据实际访问情况,将一个对象“拆解”开,把用到的成员变量恢复为原始类型来访问。

简单来说,就是把聚合量替换为标量。

若一个对象不会逃逸出「方法」,且可以被拆散,那么程序真正执行时就可能不去创建这个对象,而是直接创建它的若干个被该方法使用的成员变量代替。

还有这操作?

其实细想一下,这个操作跟前面的「栈上分配」还是有些类似的:栈上分配的是对象,而标量替换则是在栈上分配对象的一部分成员变量,连对象都懒得创建了。

3.2.3 同步消除

线程同步本身相对耗时,如果逃逸分析能够确定一个变量不会逃逸出线程,则该变量的读写就不会有线程安全问题,对该变量的同步措施就可以安全的消除了。

换句话说,如果对线程安全的数据加了锁,JVM 就可以把它优化消除。示例代码如下:

public void t1() {
    // 变量 o 不会逃逸出线程。因此,对它加的锁就可以被消除
    Object o = new Object();
    synchronized (o) {
        System.out.println(o.toString());
    }
}

3.3 公共子表达式消除

如果一个表达式,在两次计算过程中,其内所有变量的值并没有发生变化,那么则将其称为公共子表达式。

举个栗子:

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

上面这段代码在计算 b*c 的两次中并没有变化,因此可以将其简写为int a = E * 4 + (E + d) + d,再进一步还可以进行 代数化简 优化,将其优化为:

int a = E * 5 + 2 * d;

3.4 数组范围检查消除

假如有一个数组 array,当我们访问数组下标在 [0, array.length) 范围之外的元素时,就会抛出 java.lang.ArrayIndexOutOfBoundsException 异常,也就是数组越界了,例如:

public void test1() {
  String[] array = new String[]{"a", "b", "c"};
  // 数组越界
  String s = array[3];
}

其实是 JVM 在执行的时候隐含了一次边界判断(运行期)。当这样的判断很多时,肯定对性能有一定的影响。

但这个判断看起来似乎又是必要的,就不能优化了吗?

实际上也并非不能,如果把这些判断放在编译期呢?代码在编译的时候,就根据控制流分析(可参考前文的前端编译)是否会产生数组越界,那么在运行期间不是就不用判断了吗?

参考:

https://mp.weixin.qq.com/s?__biz=MzU4NzYyMDE4MQ==&mid=2247484211&idx=1&sn=bf8c2196f147430001daa6b1e540ffdc

https://www.zhihu.com/question/37389356/answer/73820511

https://www.jianshu.com/p/eede776beeb7

你可能感兴趣的:(JVM后端编译优化 - 笔记)