深入理解JVM_11_早期(编译期)优化

引:之前简单的提到过java程序的使用首先要经过编译,然后再“解释”执行,这里我们就先看看它在编译期的过程。

编译概述

java的编译期其实是一段“不确定”的操作过程,它有一下几种形式:

  1. 前端编译器:把.java文件转变为.class文件的过程,如:Sun的javac、Eclipse JDT中的增量式编译器。
  2. 虚拟机的后端运行期编译器(JIT编译器):把字节码转变成机器码的过程,如HotSpotVM的C1、C2编译器。
  3. 静态提前编译器(AOT编译器):直接把*.java文本编译为本地机器代码的过程:GNU Compiler for the Java、Excelsior JET。

我们平时所说的编译基本上都是指第一类,前端编译器。总的来说,Java中的JIT即时编译器在运行期的优化对于程序运行来说更重要,而前端编译器在编译器的优化过程对于程序编码来说关系更密切,相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现的。

Javac编译器

编译过程大致分为3个过程:

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

这3个过程之间的关系与交互顺序如下图:

javac_compiler

解析与填充符号表过程

其中解析步骤包括了经典程序编译原理找那个的词法分析和语法分析两个过程。

词法、语法分析

  1. 词法分析:将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。
  2. 语法分析:根据Token序列构造抽象语法树,抽象语法树是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、运算符、接口、返回值甚至代码注释都可以是一个语法结构。如下图:

    abstract syntax tree

经过上面两个步骤编译器就基本不会再对源文件进行操作了。

填充符号表

  1. 符号表是由一组符号地址和符号信息构成的表格,类似k-v形式。符号表中登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
  2. 在Java源代码中,填空符号表过程的出口是一个待处理列表,包含了每一个编译单元的抽象语法数的顶级节点,以及package-info.java(如果存在)的顶级节点。

注解处理器

JDK1.6之中提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,可以把它看做是一组编译器插件,在这些插件中,可以读取、修改、添加抽象语法树中的任意元素。若这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round。如编译过程图的回环过程。

语义分析与字节码生成

语法分析之后,编译器获得程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的,而语义分析的主要任务是对结构上正确地源程序进行上下文有关性质的审查,如类型审查。语义分析过程分为标注检查以及数据及控制流分析两个步骤。

标注检查

标注检查步骤检查的内容包括诸如变量使用前是否已被声明,变量与赋值之前的数据类型能否匹配,还有一个重要的动作是常量折叠,如果我们在代码写了如下定义:

1
int a = 1 + 2;

在经过变量折叠之后,1 + 2会被折叠为字面量3。所以在代码里定义int a = 1 + 2比起直接定义int a = 3并不会增加程序运行期的运算量。

数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理了等问题,编译期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上是一致的,但校验范围有所区别,有一些校验项只有在编译期或者运行期才能进行,如方法的参数以及局部变量的检查就只会在编译器检查。

解语法糖

  1. 语法糖:指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,当时更方便使用,Java最常用的语法糖主要有泛型、变长参数、自动装箱、拆箱等
  2. 解语法糖:虚拟机运行时不支持这些语法,他们会在编译阶段还原回简单的基础语法结构。

字节码生成

字节码生成是Javac编译过程的最后一个阶段,此阶段编译器还进行了少量的代码添加和转换工作。实例构造器()(不是默认构造函数)和类构造器()就是在这个阶段添加到语法树中的。完成了对语法树的遍历和调整之后,生成最终的Class文件。

Java语法糖的味道

泛型与类型擦除

  1. 本质:参数化类型的应用,就是所操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法上,分别称为泛型类、泛型接口和泛型方法。
  2. 提出原因:先看下面的代码

    1
    2
    Object object = new Object();
    String str = (String)object;

    由于编译器无法检查这个Object强制转换能否成功,为了防止保障强制类型的转换成功,避免ClassCaseException的风险转嫁到程序运行期之中,所以提出了泛型。

  3. 真实泛型:参数化类型无论在源码中还是编译后的中间语言都是存在的。
  4. 伪泛型:参数化类型只在源码中存在,而在中间语言不存在。可以看到下面的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 泛型擦除的例子
    public class GenericTest {

    public static void main(String[] args) {
    Map<String, String> map = new HashMap<>();
    map.put("hello", "您好");
    map.put("how are you?", "最近怎么样?");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you?"));
    }

    }

    利用Beyond Compare反编译可以得到下面的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //  反编译后的代码
    public class GenericTest {

    public static void main(String[] args) {
    Map map = new HashMap<>();
    map.put("hello", "您好");
    map.put("how are you?", "最近怎么样?");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you?"));
    }

    }

    有人可能像我一样,用jd反编译,发现泛型没有被擦除,我一开始很惊讶,后来找了参考,大家可以从参考部分看到该现象的解释,这里需要说明一点,就是由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,所以JCP(Java Community Process)组织对虚拟机规范做出了相应的修改,引入了诸如Signature、LocalVariabelTypeTable等新的属性用于解决伴随泛型而来的参数类型识别问题,从Signature属性的出现我们还可以得出结论:擦除泛型所谓的擦除,仅仅是对方法的Code属性中的字节码进行删除,实际上元数据中还是保留了泛型信息。

自动装箱、拆箱与遍历循环

我们可以看看这些语法糖,编译的过程中做了什么处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 源代码

public class BoxProcessTest {

public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 反编译后的代码

public class BoxProcessTest
{
public static void main(String[] args)
{
List list = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext();)
{
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
}

我们清楚的看到其实语法糖的最后还是用最基本的语法实现的,只是更利于我们写代码了。

再来看看自动装箱的陷阱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BoxTest {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g= 3L;
System.out.println(c == d); //true
System.out.println(e == f); //false,如果Integer在-128到127之间会保存到常量池,此时Integer直接等于数字
System.out.println(c == (a + b)); //true
System.out.println(c.equals(a + b)); //true
System.out.println(g == (a + b)); //true
System.out.println(g.equals(a + b)); //false
}
}

如果你对上面分不清楚的话,加上鉴于包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们的equals()方法不处理数据类型转型的关系,建议在实际编码找那个尽量避免这样使用自动装箱与拆箱。

条件编译

Java语言实现条件编译的方法是使用条件为常量的if语句,案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 源代码

public class ConditionCompilerTest {

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

1
2
3
4
5
6
7
8
9
// 反编译代码

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

我们很清楚的看到编译后的代码就只剩true里面的代码块了,从而实现了条件编译。

总结

通过上面的学习,我们可以从编译器的层次上了解Java源代码编译为字节码的过程,以及各种语法糖的前因后果。

参考

  1. 《深入理解Java虚拟机》
  2. 早期(编译期)优化
  3. 关于java泛型擦除反编译后泛型会出现问题