18

JVM笔记-后端编译与优化

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzU4NzYyMDE4MQ%3D%3D&%3Bmid=2247484211&%3Bidx=1&%3Bsn=bf8c2196f147430001daa6b1e540ffdc
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

1. 概述

前面分析了 JVM 的前端编译器 Javac,本文分析后端编译器:即时编译器(JIT 编译器)和提前编译器(AOT 编译器)。

其实二者都不是 JVM 必需的组成部分。但是,后端编译器编译性能的好坏、代码优化质量的高低,却是衡量一款商用 JVM 优秀与否的关键指标之一,也是其核心所在。

2. 即时编译器

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

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

这种机制可以类比我们平时调用接口查询数据:

  • 某个接口如果查询比较简单、且访问量较少,就没必要使用缓存,直接查询数据库就行;

  • 当该接口访问量很大时,为了提高查询效率,可以使用缓存提高效率。

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

  • 客户端编译器(Client Compiler),简称 C1 编译器。

  • 服务端编译器(Server Compiler),简称 C2 编译器,或 Opto 编译器。

  • Graal 编译器(JDK 10 出现,长期目标是替代 C2 编译器)。

2.1 解释器与编译器

2.1.1 执行流程

解释器的执行流程大致如下:

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

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

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

此处引用了 RednaxelaFX 大佬在知乎的回答,链接:https://www.zhihu.com/question/37389356/answer/73820511 。若想了解更深层次的内容,要去看编译原理相关的书了。

2.1.2 对比分析

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

  • 程序需要迅速启动和执行时,解释器可以省去编译时间,立即执行。

  • 程序启动后,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,可以减少解释器的中间消耗,提高执行效率。

  • 若运行环境的内存资源限制较大,可使用解释器执行节约内存;反之可使用编译执行来提升效率。

总结起来就是:

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

  2. 编译器启动较慢,占用内存较大,但执行效率较高。

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

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

  • 解释执行可以理解为直接查询数据库,也就是不使用缓存。程序启动起来比较快(无需连接缓存服务器),但后面运行的时候由于每次都要去查数据库,会有磁盘 IO 开销,会相对慢一些。

  • 而编译执行就相当于使用了缓存。虽然启动会稍慢一些(需要连接缓存服务器,初次查询时既要查询数据库,又要存入缓存),而且需要额外的开销(需要缓存服务器),但是后续的查询效率会提高很多,因为可以直接从缓存获取,不必再查询数据库。

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

2.1.3 运行模式

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

$ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

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

此外,也可以使用参数 -Xint 强制虚拟机运行于“解释模式(Interpreter Mode)”:

$ java -Xint -version
java version ...
...
... (build 25.191-b12, interpreted mode)

还可以使用参数 -Xcomp 强制虚拟机运行于“编译模式(Compiled Mode)”:

$ java -Xcomp -version
java version ...
...
... (build 25.191-b12, compiled mode)

2.2 分层编译

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

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

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

  1. 程序纯解释执行,且解释器不开启性能监控功能。

  2. 使用 C1 编译器将字节码编译为本地代码来执行,进行简单可靠的稳定优化,不开启性能监控功能。

  3. 使用 C1 编译器执行,仅开启一部分性能监控功能(方法及回边次数统计等)。

  4. 使用 C1 编译器执行,开启全部性能监控(在第二层之外,还会收集如分支跳转、虚方法调用版本等全部的统计信息)。

  5. 使用 C2 编译器将字节码编译为本地代码(相比 C1 编译器,C2 编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化)。

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

2.3 热点代码

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

  1. 被多次调用的方法。

  2. 被多次执行的循环体。

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

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

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

// 即便 test() 方法被调用的次数不多,但当 N 足够大时,该部分代码也会成为“热点代码”
for (int i=0; i<N; i++) {
// 执行一些操作...
}

// 一些其他代码...
}

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

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

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

2.4 热点探测

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

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

2.4.1 定量方法

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

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

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

这种做法的优缺点如下:

  1. 优点:实现简单高效,而且可以通过堆栈信息获取到方法之间的调用关系;

  2. 缺点:难以精确的确定方法热度,容易受到线程阻塞的干扰(即方法阻塞时可能长时间处于栈顶,可能产生误判)。

  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)

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

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

  1. 优点:统计结果更加精确严谨;

  2. 缺点:统计起来稍麻烦(要为每个方法建立并维护计数器),而且不能直接获取到方法的调用关系。

2.4.2 两种计数器

HotSpot 为每个方法准备了两类计数器,下面分别介绍。

2.4.2.1 方法调用计数器

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

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

ayeemiE.png!web

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

什么意思呢?

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

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

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

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

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

# 设置半衰期时间(秒)
-XX:CounterHalfLifeTime

2.4.2.2 回边计数器

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

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

YRfYJrr.png!web

与此相关的几个 JVM 参数:

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

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

3. 提前编译器

对提前编译的研究主要有下面两条分支。

3.1 静态翻译

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

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

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

3.2 即时编译缓存

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

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

3.3 即时编译&提前编译

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

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

  • 性能分析制导优化

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

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

  • 激进预测性优化

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

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

4. 编译器优化技术

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

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

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

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

4.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 方法中)之后可以发现。

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

4.2 逃逸分析

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

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

  • 不逃逸:对象只能在本方法内使用。

  • 方法逃逸:对象可能被外部方法引用(例如作为调用参数传递到其他方法)。

  • 线程逃逸:对象可能被外部线程访问到(例如赋值给线程共享的变量)。

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

4.2.1 栈上分配

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

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

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

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

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

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

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

4.2.2 标量替换

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

  • 标量:无法再分解为更小数据的数据,例如 JVM 中的原始数据类型(int、long、reference 等)。

  • 聚合量:可以继续分解的数据,例如 Java 中的对象。

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

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

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

还有这操作?

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

4.2.3 同步消除

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

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

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

4.3 代码示例

上面介绍了方法内联和逃逸分析的相关优化手段,这里以伪代码的形式演示它们优化的过程。

  • 原始代码

// 原始代码
public class Point {
private int x;
private int y;
// getter/setter ...
}

public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
  • 方法内联

首先,将 Point 的构造函数和 getX() 方法进行内联:

// 方法内联优化后
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 堆中分配内存示意方法
p.x = xx; // Point 构造函数内联后
p.y = 42;
return p.x; // p.getX() 方法内联后
}
  • 逃逸分析

经过逃逸分析,发现 Point 对象不会逃逸出 test() 方法,可以进行「标量替换」,如下:

// 标量替换优化后
public int test(int x) {
int xx = x + 2;
int px = xx; // 标量替换
int py = 42;
return px;
}
  • 无效代码消除

经过数据流分析,发现变量 py 对方法不会造成任何影响,可以进行消除,如下:

// 无效代码消除优化后
public int test(int x) {
return x + 2;
}

可以看到,原始代码经过一系列的优化,最终结果简洁了很多。

更少的代码也意味着占用的内存空间更少,执行起来效率也更高,这也是优化的意义所在。

4.3 公共子表达式消除

4.3.1 公共子表达式

所谓公共子表达式,就是当有一个表达式 E 在以前被计算过,而且下次再遇到的时候 E 的所有变量都未改变,则这次 E 的出现就被称为「公共子表达式」。也就是不必再花功夫重新计算,直接拿来用就好了。有木有“缓存”的感觉?

根据作用域,公共子表达式的消除可分为两种:局部公共子表达式消除和全局公共子表达式消除。

4.3.2 示例代码

若有如下代码:

public class Test {
public int t1() {
int a=1, b=2, c=3;
int d = (c * b) * 12 + a + (a + b * c);
return d;
}
}

Javac 编译后生成的字节码如下:

  public int t1();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=4, locals=5, args_size=1
# ...
6: iload_3
7: iload_2
8: imul # 计算 b*c
9: bipush 12
11: imul # 计算 (c * b) * 12
12: iload_1
13: iadd # 计算 (c * b) * 12 + a
14: iload_1
15: iload_2
16: iload_3
17: imul # 计算 b*c
18: iadd # 计算 (a + b * c)
19: iadd # 计算 (c * b) * 12 + a + (a + b * c)
20: istore 4
22: iload 4
24: ireturn
# ...

Javac 编译器并未做任何优化,每次都会重新计算。

这段代码进入即时编译器后,将进行如下优化:

编译器检测到 c * b 与 b * c 是一样的表达式,且在计算期间 b 和 c 的值不变,因此:

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

此时,编译器还可能进行代数化简(Algebraic Simplification),如下:

int d = E * 13 + a + a;

这样计算起来就可以节省一些时间。

4.4 数组边界检查消除

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

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

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

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

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

5. 小结

本文主要分析了即时编译器和提前编译器,主要内容梳理如下:

NFFvU3I.png!web

相关阅读:

JVM笔记-前端编译与优化

JVM笔记-类加载机制

JVM笔记-运行时内存区域划分

本文内容就到这里,希望对大家有所帮助~

ZnquA3u.png!web

【觉得不错,鼓励一下~】


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK