程序编译与代码优化

程序编译与代码优化

Java语言的编译期既可能是一个前端编译器把.java文件转变成.class文件的过程,也有可能是指虚拟机的后端运行期编译器(JIT编译器)把字节码转变为机器码的过程,还可能是静态提前编译器(AOT编译器)直接把*.java文件编译成本地机器代码的过程

这3类编译过程中一些比较有代表性的编译器如下:

  • 前端编译器:Sun的Javac
  • JIT编译器:HotSpot的C1,C2编译器
  • AOT编译器:GNU Complier for the Java(GCJ)

早期(编译期)优化

Javac编译器

Javac编译器的编译过程可以分为3个步骤:

  1. 解析与填充符号表
  2. 插入式注解处理器的注解处理
  3. 分析与字节码生成

Java语法糖

泛型与类型擦除

Java的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型,并在相应地方插入了强制类型转换代码

1
2
3
Map<String, String> map = new HashMap<>();
map.put("hello", "你好");
System.out.println(map.get("hello"));

编译成Class文件,再用字节码反编译工具反编译后,会如下所示:

1
2
3
Map map = new HashMap();
map.put("hello", "你好");
System.out.println((String) map.get("hello"));

泛型擦除导致以下代码会报错

1
2
3
4
5
6
7
8
9
10
public class GenericTypes {

public static void method(List<String> list) {
System.out.println("list<String>");
}

public static void method(List<Integer> list) {
System.out.println("list<Integer>");
}
}

因为泛型擦除,它们的特征签名变得一模一样,方法重载要求方法具备不同的特征签名
但只要返回值不同即可,因为描述符不是完全一致的两个方法就可以完全共存

自动装箱,拆箱和遍历循环

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SyntaxSugars {

public static void main(String[] args){

List<Integer> list = Arrays.asList(1,2,3,4,5);

int sum = 0;
for(int i : list){
sum += i;
}
System.out.println("sum = " + sum);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SyntaxSugars {

public static void main(String[] args) {

List list = Arrays.asList(new Integer[]{
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4),
Integer.valueOf(5)
});

int sum = 0;
for (Iterator iterable = list.iterator(); iterable.hasNext(); ) {
int i = ((Integer) iterable.next()).intValue();
sum += i;
}
System.out.println("sum = " + sum);
}
}

条件编译

根据布尔常量值的真假,编译器会把分支中不成立的代码块消除

1
2
3
4
5
6
7
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}

反编译结果如下

1
2
3
public static void main(String[] args) {
System.out.println("block 1");
}

晚期(运行期)优化

在部分商业虚拟机中,Java 最初是通过解释器解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为”热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)

即时编译器不是虚拟机必须的部分,Java 虚拟机规范并没有规定虚拟机内部必须要有即时编译器存在,更没有限定或指导即时编译器应该如何实现。但是 JIT 编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一

HotSpot虚拟机内的即时编译器

解释器与编译器

解释器与编译器两者各有优势:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码之后,可以获得更高的执行效率
  • 当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释器执行来节约内存,反之可以使用编译执行来提升效率

HotSpot内置两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器,默认是混合模式,即解释器与编译器搭配使用,可以使用参数-Xint强制让虚拟机运行于解释模式,或-Xcomp使虚拟机强制运行编译模式

HotSpot虚拟机还启用分层编译策略,其中包括如下三层:

  • 第0层,程序解释执行,解释器不开启性能监控功能
  • 第1层,也称为C1编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑
  • 第2层,也称为C2编译,将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化

Client Compiler有更高的编译速度,Server Compiler有更好的编译质量

编译对象与触发条件

在运行过程会被即时编译器编译的热点代码有两类:

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

注意的是,第二种情况,虽然编译动作由循环体触发,但编译器仍然会以整个方法(而不是单独的循环体)作为编译对象

多次的判定可以由基于采样的热点探测或基于计数器的热点探测来判定

  • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是「热点方法」。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因数的影响而扰乱热点探测
  • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是「热点方法」。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是统计结果相对来说更加精确和严谨

HotSpot使用的是第二种,整个JIT编译的交互过程如下图

编译优化技术

虚拟机设计团队几乎把对代码的所有优化措施都集中在JIT编译器中,一个简单的例子如下:(注意底层使用的是字节码或机器码指令的优化)

1
2
3
4
5
6
7
8
9
10
11
12
static class B {
int value;
final int get() {
return value;
}
}

public void foo() {
y = b.get();
z = b.get();
sum = y + z;
}

首先是内联,它可以去除方法调用的成本(栈帧等),并且为其他优化建立良好的基础,内联后如下:

1
2
3
4
5
public void foo() {
y = b.value;
z = b.value;
sum = y + z;
}

然后是冗余访问消除

1
2
3
4
5
public void foo() {
y = b.value;
z = y;
sum = y + z;
}

然后是复写传播,这段程序的逻辑中并不需要用一个额外的变量z

1
2
3
4
5
public void foo() {
y = b.value;
y = y;
sum = y + y;
}

然后是无用代码消除

1
2
3
4
public void foo() {
y = b.value;
sum = y + y;
}

最后的代码和一开始的代码效果是一样的,但是省略了很多语句(尤其体现在字节码和机器码指令上)

接下来看几项最具有代表性的优化技术

  • 语言无关的经典优化技术之一:公共子表达式消除
  • 语言相关的经典优化技术之一:数组范围检查消除
  • 最重要的优化技术之一:方法内联
  • 最前沿的优化技术之一:逃逸分析

公共子表达式消除

如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就好了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围覆盖了多个基本块,那就称为全局公共子表达式消除

数组边界检查消除

如果有一个数组 array[],在 Java 中访问数组元素 array[i] 的时候,系统会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < array.length,否则会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException,这就是数组边界检查

对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这是一种不小的性能开销。为了安全,数组边界检查是必须做的,但是数组边界检查并不一定每次都要进行。比如在循环的时候访问数组,如果编译器只要通过数据流分析就知道循环变量是不是在区间 [0, array.length] 之内,那在整个循环中就可以把数组的上下界检查消除

方法内联

前面代码已经解释过

逃逸分析

逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法和线程无法通过任何途径访问到这个方法,则可能为这个变量进行一些高效优化。比如:

  • 栈上分配:如果确定一个对象不会逃逸到方法之外,那么就可以在栈上分配内存,对象所占的内存空间就可以随栈帧出栈而销毁。通常,不会逃逸的局部对象所占的比例很大,如果能栈上分配就会大大减轻 GC 的压力
  • 同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,因而变量的同步措施也就可以消除了
  • 标量替换:标量是指一个数据无法再拆分成更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步拆分,所以它们就是标量。相反,一个数据可以继续分解,那它就称作聚合量,Java 中的对象就是聚合量。如果把一个 Java 对象拆散,根据访问情况将其使用到的成员变量恢复成原始类型来访问,就叫标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。对象被拆分后,除了可以让对象的成员变量在栈上分配和读写,还可以为后续进一步的优化手段创造条件。