You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
266 lines
17 KiB
266 lines
17 KiB
---
|
|
从 final 能够“提升”程序性能,谈编译优化
|
|
---
|
|
|
|
### 前言
|
|
|
|
或许你不止一次听说过 fianl 能够提升程序性能,那真的是这样的嘛?
|
|
|
|
本节先通过讲解主流编译优化的手段,了解完编译优化的思想和内在原理,相信你对这个问题就会做出一个很好的回答。
|
|
|
|
### 概述
|
|
|
|
对于 Java 代码的编译,分为前端编译和后端编译。
|
|
|
|
前端编译是指通过 Javac 工具,将 Java 代码转化为字节码的过程。既然 Javac 负责字节码的生成,那肯定就会有一些通用的优化手段。比如常量折叠、自动装拆箱、循环遍历、条件编译等。其次,还有使用 StringContactFactory 对 “+” 的重载的统一入口等。
|
|
|
|
后端编译则是指 JVM 内置的解释器和即时编译器(C1、C2)。
|
|
|
|
JVM 在对代码执行的优化可以分为运行时优化和即时编译器(JIT)优化。运行时优化主要是解释执行和动态编译通用的一些机制,比如说锁机制(如偏斜锁)、内存分配机制(如 TLAB)等。除此之外,还有一些专门用于优化解释执行效率的,比如说模版解释器、内联缓存(inline cache,用于优化虚方法调用的动态绑定)。
|
|
|
|
JVM 的即时编译器优化是指将热点代码以方法为单位转换成机器码,直接运行在底层硬件之上。它采用了多种优化方式,包括静态编译器可以使用的如方法内联、逃逸分析,也包括基于程序运行 profile 的投机性优化(speculative/optimistic optimization)。
|
|
|
|
### 编译优化
|
|
|
|
1. 前端编译优化
|
|
- 常量折叠
|
|
- 条件编译
|
|
2. 后端编译优化
|
|
- 锁机制
|
|
- 内存分配机制
|
|
- Intrinsic 机制
|
|
- 方法内联
|
|
- 逃逸分析
|
|
|
|
2. 循环优化
|
|
6. 向量化
|
|
7. 其他
|
|
|
|
### 常量折叠
|
|
|
|
常量折叠发生在 Javac 的编译过程中的语义分析过程中的标注检查阶段。
|
|
|
|
```java
|
|
String abc = "a" + "b" + "c";
|
|
```
|
|
|
|
以上代码经过常量折叠之后变为:
|
|
|
|
```java
|
|
String abc = "abc";
|
|
```
|
|
|
|
### 条件编译
|
|
|
|
```java
|
|
public void show() {
|
|
if (1 + 1 != 2) {
|
|
//do something
|
|
}
|
|
}
|
|
```
|
|
|
|
Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉,这一工作将在编译器解语法糖阶段完成。
|
|
|
|
### 锁机制
|
|
|
|
JDK 1.6 对锁的实现引入了大量的优化,包括自旋锁、锁消除、偏向锁,轻量级锁来减少锁操作的开销。
|
|
|
|
锁主要存在四种状态:依次是无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
|
|
|
|
- 自旋锁和自适应自旋
|
|
|
|
在很多应用中,锁定状态只会持续很短的时间,为了这么一点时间就去挂起、恢复线程,不值得。我可以让等待线程执行一定次数的循环,在循环中去获取锁,这就称为自旋锁。它可以节省系统切换线程的消耗,但是仍占用处理器。在之后又引入自适应的自旋锁,不再通过次数来限制,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
|
|
|
|
- 偏向锁
|
|
|
|
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁。
|
|
|
|
- CAS
|
|
|
|
### 内存分配机制
|
|
|
|
对象优先在 TLAB 上分配内存。在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配内存空间的任务就等同于把一块确定大小的内存从 Java 堆中划分出来。内存分配方式有两种,一种是指针碰撞,一种是空闲列表。
|
|
|
|
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改了一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有两种办法,一种是对内存分配进行同步处理,另外一种就是把内存分配在 TLAB 上,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。
|
|
|
|
### Intrinsic 机制
|
|
|
|
或者叫做内建方法,对应的注解为 @HotSpotIntrinsicCandidate,就是针对特别重要的基础方法,JDK 团队直接提供定制的实现,利用汇编或者编译器的中间表达式编写,然后 JVM 会直接在运行时进行替换。
|
|
|
|
这么做的理由有很多,例如,在不同体系结构的 CPU 在指令等层面存在着差异,定制才能充分发挥出硬件的能力。我们日常使用的典型字符串操作、数组拷贝等基础方法,HotSpot 虚拟机都提供了内建实现。
|
|
|
|
但是,如果 Java 核心类库的开发者更改了原本的实现,那么虚拟机中的高效实现也需要相应的修改,以保证程序语义一致。
|
|
|
|
需要注意的是,其他虚拟机未必维护了这些 intrinsic 的高效实现,它们可以直接使用原本的较为低效的 JDK 代码。同样,不同版本的 HotSpot 虚拟机所实现的 intrinsic 数量也大不相同。通常越新版本的 Java,其 intrinsic 数量越多。
|
|
|
|
你或许会产生这么一个疑问:为什么不直接在源代码中使用这些高效实现呢?
|
|
|
|
这是因为高效实现通常依赖于具体的 CPU 指令,而这些 CPU 指令不好在 Java 源程序中表达。再者,换了一个体系架构,说不定就没有对应的 CPU 指令,也就无法进行 intrinsic 优化了。
|
|
|
|
在 Math 类刚好有这样的一个例子:
|
|
|
|
```java
|
|
public static void main(String[] args) {
|
|
|
|
long startTime = System.nanoTime();
|
|
for (int i = 0; i < 1000; i++) {
|
|
int result = Math.max(0, i);
|
|
}
|
|
long endTime = System.nanoTime();
|
|
System.out.println("内建方法执行耗时:");
|
|
System.out.println(endTime - startTime);
|
|
|
|
startTime = System.nanoTime();
|
|
for (long i = 0; i < 1000; i++) {
|
|
long result = Math.max(0L, i);
|
|
}
|
|
endTime = System.nanoTime();
|
|
System.out.println("非内建方法执行耗时:");
|
|
System.out.println(endTime - startTime);
|
|
|
|
}
|
|
|
|
内建方法执行耗时:
|
|
92170
|
|
非内建方法执行耗时:
|
|
97459
|
|
```
|
|
|
|
#### intrinsic 与 CPU 指令
|
|
|
|
第一个例子是整数加法的溢出处理。一般我们在做整数加法时,需要考虑结果是否会溢出,并且在溢出的情况下做出相应的处理,以保证程序的正确性。
|
|
|
|
Java 核心类库提供了一个 Math.addExact 方法。它将接收两个 int 值作为参数,并返回这两个 int 值的和。当这两个 int 值之和溢出时,该方法将抛出 ArithmeticException 异常。
|
|
|
|
```java
|
|
@HotSpotIntrinsicCandidate
|
|
public static int addExact(int x, int y) {
|
|
int r = x + y;
|
|
// HD 2-12 Overflow iff both arguments have the opposite sign of the result
|
|
if (((x ^ r) & (y ^ r)) < 0) {
|
|
throw new ArithmeticException("integer overflow");
|
|
}
|
|
return r;
|
|
}
|
|
```
|
|
|
|
在 Java 层面判断 int 值之和是否溢出比较费事。我们需要分别比较两个 int 值与它们的和的符号是否不同。如果都不同,那么我们便认为这两个 int 值之和溢出。对应的实现便是两个异或操作,一个与操作,以及一个比较操作。
|
|
|
|
在 X86_64 体系架构中,大部分计算指令都会更新状态寄存器,其中就有表示指令结果是否溢出的溢出标识位(overflow flag)。因此,我们只需要在加法指令之后比较溢出标识位,便可以知道 int 值之和是否溢出了。对应的伪代码如下所示:
|
|
|
|
```java
|
|
public static int addExact(int x, int y) {
|
|
int r = x + y;
|
|
jo LABEL_OVERFLOW; // jump if overflow flag set
|
|
return r;
|
|
LABEL_OVERFLOW:
|
|
throw new ArithmeticException("integer overflow");
|
|
// or deoptimize
|
|
}
|
|
```
|
|
|
|
最后一个例子则是 Integer.bitCount 方法,它将统计所输入的 int 值的二进制形式中有多少个 1。在 X86_64 体系架构中,我们仅需要一条指令 popcnt,便可以直接统计出 int 值中 1 的个数。
|
|
|
|
#### Intrinsic 与方法内联
|
|
|
|
HotSpot 虚拟机中,intrinsic 的实现方式分为两种。
|
|
|
|
一种是独立的桩程序。它既可以被解释执行器利用,直接替换对原方法的调用;也可以被即时编译器所利用,它把代表对原方法的调用的 IR 节点,替换为对这些桩程序的调用的 IR 节点。以这种形式实现的 intrinsic 比较少,主要包括 Math 类中的一些方法。
|
|
|
|
另一种则是特殊的编译器 IR 节点。显然,这种实现方式仅能够被即时编译器所利用。
|
|
|
|
在编译过程中,即时编译器会将原方法的调用的 IR 节点,替换成特殊的 IR 节点,并参与接下来的优化过程。最终,即时编译器的后端将根据这些特殊的 IR 节点,生成指定的 CPU 指令。大部分的 intrinsic 都是通过这种方式实现的。
|
|
|
|
这个替换过程是在方法内联时进行的。当即时编译器碰到方法调用节点时,它将查询目标方法是不是 intrinsic。
|
|
|
|
这个替换过程是在方法内联时进行的。当即时编译器碰到方法调用节点时,它将查询目标方法是不是 intrinsic。
|
|
|
|
也就是说,如果方法调用的目标方法是 intrinsic,那么即时编译器会直接忽略原目标方法的字节码,甚至根本不在乎原目标方法是否有字节码。即便是 native 方法,只有它被标记为 intrinsic,即时编译器便能够将之 “内联” 进来,并插入特殊的 IR 节点。
|
|
|
|
事实上,不少被标记为 intrinsic 的方法都是 native 方法。原本对这些 native 方法的调用需要经过 JNI,其性能开销十分巨大。但是,经过即时编译器的 intrinsic 优化之后,这部分 JNI 开销便直接消失不见,并且最终的结果也十分高效。
|
|
|
|
举个例子,我们可以通过 Thread.currentThread 方法来获取当前线程。这是一个 native 方法,同时也是一个 HotSpot intrinsic。在 X86_64 体系架构中,R13 寄存器存放着当前线程的指针。因此,对该方法的调用将被即时编译器替换为一个特殊 IR 节点,并最终生成读取 R13 寄存器指令。
|
|
|
|
### 方法内联
|
|
|
|
方法内联,它指的是在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
|
|
|
|
方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。
|
|
|
|
以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作仅剩下字段访问。
|
|
|
|
在 C2 中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。
|
|
|
|
> 即时编译器首先解析字节码,并生成 IR 图,然后在该 IR 图上进行优化。优化是由一个个独立的优化阶段串联起来的。每个优化阶段都会对 IR 图进行转换。最后即时编译器根据 IR 图的节点以及调度顺序生成机器码。
|
|
|
|
#### 方法内联的条件
|
|
|
|
方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。
|
|
|
|
此外,内联越多也将导致生成的机器码越长。在 Java 虚拟机里,编译生成的机器码会被部署到 Code Cache 之中,这个 Code Cache 是有大小限制的。
|
|
|
|
因此,即时编译器不会无限制的进行方法内联。下面我便列举即时编译器的部分内联规则。(其他的特殊规则,如自动拆箱总会被内联、Throwable 类的方法不能被其他类中的方法所内联)
|
|
|
|
首先,有 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法,会被强制内联。而由 -XX:CompileCommand 中的 dontinline 指令以及有 @DontInline 注解的方法,则始终不会被内联。
|
|
|
|
其次,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 Native 方法,都将导致方法调用无法内联。
|
|
|
|
再次,C2 不支持内联超过 9 层的调用,以及 1 层的直接递归调用。
|
|
|
|
最后,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。
|
|
|
|
然鹅,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化,即转换为一个或多个直接调用,然后才能进行方法内联。
|
|
|
|
即时编译器的去虚化方式可分为完全去虚化以及条件去虚化。
|
|
|
|
完全去虚化是通过类型推导或者类层次分析,识别虚方法调用的唯一目标方法,从而将其转化为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
|
|
|
|
条件去虚化通过向代码中增添类型比较,将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助 Java 虚拟机所收集的类型 Profile。
|
|
|
|
### 逃逸分析
|
|
|
|
逃逸分析是 “一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。
|
|
|
|
在 Java 虚拟机的即时编译器语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
|
|
|
|
前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
|
|
|
|
关于后者,由于 Java 虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确定该方法调用会不会将调用者或所传入的参数存储至堆上。因此,我们可以认为方法调用的调用者以及参数是逃逸的。
|
|
|
|
通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些 “未知代码” 入口。
|
|
|
|
#### 基于逃逸分析的优化
|
|
|
|
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
|
|
|
|
我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸对象的加锁、解锁操作。
|
|
|
|
实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上诉条件被强化为证明锁对象不逃逸出当前编译的方法。
|
|
|
|
synchronized(new Object()){} 会被完全优化掉,这正是因为基于逃逸分析的锁消除,由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的 happens-before 规则。
|
|
|
|
synchronized(escapedObject){} 则不然。由于其他线程可能会对逃逸了的对象 escapedObject 进行加锁操作,从而构造了两个线程之间的 happens-before 关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。
|
|
|
|
不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。
|
|
|
|
我们知道,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
|
|
|
|
如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈帧来自动回收所分配的内存空间。这样一来,我们便无需借助垃圾收集器来处理不再被引用的对象。
|
|
|
|
不过,由于实现起来需要更改大量假设了 “对象只能堆分配” 的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。
|
|
|
|
所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则是同时存储多个值,其中一个典型的例子便是 Java 对象。
|
|
|
|
标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。
|
|
|
|
### 循环优化
|
|
|
|
循环优化有两种:
|
|
|
|
- 循环无关代码外提
|
|
- 循环展开
|
|
|
|
### 其他
|
|
|
|
还有一些优化方式,比如:
|
|
|