JVM(7):前端编译与优化

1. Javac编译器

        Javac是一个由Java语言编写的程序,从其代码总体结构来看,可以将编译过程大致分为 $1$ 个准备过程和 $3$ 个处理过程:

  1. 准备过程:初始化插入式注解处理器
  2. 解析与填充符号表过程,包括词法分析、语法分析和符号表创建
  3. 插入式注解处理器的注解处理过程
  4. 分析与字节码生成过程,包括标注检查、数据流及控制流分析、解语法糖和字节码生成

        在上述处理过程中,执行插入式注解时可能又会产生新的符号。如果产生新的符号,就必须对它们进行解析,因此又回到了步骤 $2$ 。Javac编译动作的入口是 $com.sun.tools.javac.main.JavaCompiler$ 类,上述 $3$ 个过程的代码逻辑集中在这个类的 $compiler(\ )$ 和 $compiler2(\ )$ 方法。

1.1 解析与填充符号表

        解析过程通过 $JavaCompiler.parseFiles(\ )$ 方法发起。

1.1.1 词法、语法分析

        词法分析过程由 $com.sun.tools.javac.parser.Scanner$ 类完成。语法分析过程由 $com.sun.tools.javac.parser.Parser$ 类完成,产出的抽象语法树以 $com.sun.tools.javac.tree.JCTree$ 类表示。在完成词法、语法分析之后,编译器后续的操作都建立在抽象语法树的基础上,不会再对源码进行操作了。

1.1.2 填充符号表

        填充符号表过程通过 $JavaCompiler.enterTrees(\ )$ 方法发起,由 $com.sun.tools.javac.comp.Enter$ 类完成,产出一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级结点。

1.2 注解处理器

        在JDK 5时,注解只会在程序运行期间发挥作用。JDK 6中提供了一组称为“插入式注解处理器”的标准API,使得特定注解的处理可以提前至编译期进行。插入式注解处理器可以读取、修改和添加抽象语法树的元素。如果在这个过程中发生了修改操作,那么编译器需要返回解析与填充符号表过程重新处理。

1.3 语义分析与字节码生成

        抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义符合逻辑,因此需要进行语义分析对源程序进行上下文相关性质的检查,譬如类型检查、控制流检查、数据流检查等。

1.3.1 标注检查

        标注检查由 $JavaCompiler.attribute(\ )$ 方法发起。标注检查步骤要检查的内容包括变量使用前是否已声明、变量与赋值之间的数据类型是否匹配等。此外还会进行常量折叠 ( $Constant\ \ Folding$ ) 。

1.3.2 数据及控制流分析

        数据及控制流分析是对程序上下文逻辑的更进一步验证,可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有受查异常都被正确处理等。编译期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的,但有一些校验项只有在编译期或者运行期才能进行。数据及控制流分析由 $JavaCompiler.flow(\ )$ 方法发起,由 $com.sun.tools.javac.comp.Flow$ 类完成。

1.3.3 解语法糖

        Java中最常见的语法糖包括泛型、变长参数、自动装箱拆箱等。JVM并不支持这些语法,它们会在编译期还原为基础语法结构,这个过程称为解语法糖。解语法糖过程由 $JavaCompiler.desugar(\ )$ 方法发起,由 $com.sun.tools.javac.comp.TransTypes$ 类和 $com.sun.tools.javac.comp.Lower$ 类完成。

1.3.4 字节码生成

        字节码生成过程由 $com.sun.tools.javac.jvm.Gen$ 类完成,除了把之前步骤生成的信息(语法树、符号表)转化成字节码指令之外,还进行少量的代码添加和转换工作。例如实例构造器 <$init$>$(\ )$ 和类构造器 <$clinit$>$(\ )$ 就是在这个阶段被添加到语法树中的。要注意的是这里的构造器并非默认构造器,默认构造器的产生是在填充符号表的过程中执行的。除了生成构造器外,还有一些其他的代码替换工作比如将字符串连接替换为 $StringBuffer$ 或者 $StringBuilder$ 的 $append(\ )$ 等。在完成上述步骤后,$com.sun.tools.javac.jvm.ClassWriter.writeClass(\ )$ 方法将会输出Class文件,代表编译完成。

2. 语法糖

2.1 泛型

        泛型的本质是参数化类型 ( $Parameterized\ \ Type$ ) 或者参数化多态 ( $Parametric\ \ Polymorphism$ ) 的应用。Java选择的泛型实现方式称为“类型擦除式泛型” ( $Type\ \ Erasure\ \ Generics$ ) ,即泛型只在源码中存在,在字节码文件中被替换为裸类型 ( $Raw\ \ Type$ ),并且在相应的地方插入了强制转型代码。裸类型可以视为所有该类型泛型化实例的父类,因而可以将泛型化实例赋值给裸类型实例。Java在编译时会将泛型化实例,如 $ArrayList$<$Integer$>还原为裸类型实例,如 $ArrayList$ ,并在元素访问和修改时插入一些强制类型转换和类型检查指令。因为不能将原生类型转换为 $Object$ ,因此Java不支持原生类型的泛型,同时也增加了对原生类型的装箱拆箱工作。

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

        自动装箱、拆箱与遍历循环是Java语言中使用的最多的语法糖。使用语法糖的示例如下:

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);
}

对上述代码进行编译和反编译后得到的结果为:

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);
}

        类似的还有变长参数,它通过在编译时转换为一个数组类型的参数实现。

2.3 条件编译

        在C/C++中,我们可以通过预处理器实现条件编译。虽然Java中并没有预处理器,但是也可以实现条件编译,即通过条件为常量的 $if$ 语句。在编译的过程中,编译器会删去分支中无法到达的代码,这一工作也属于解语法糖的范畴。

        除了上述所提到的以外,内部类、枚举类、断言、数值字面量、对枚举的 $switch$ 支持、$try-with-resources$ 、$Lambda$ 表达式(虽然不能算单纯的语法糖,但也在前端编译器中做了大量的转换工作)等都属于语法糖。

JVM(7):前端编译与优化