【Java】JVM - 前端编译器

389 阅读17分钟

Java 的“编译期”有三种表述:

  1. 将源代码转换为字节码的过程,这一过程称为前端编译,负责过程的编译器称为前端编译器
  2. 将字节码转换为机器码的过程,这一过程称为后端编译,负责这一过程的有解释器即时编译器
  3. 将源代码直接转换为机器码,或者将字节码永久转换为机器码的过程,这一过程称为提前编译,负责这一过程的编译器称为提前编译器

通常程序员所说的“编译”指的都是前端编译过程,因为前端编译是 Java 语言相关而平台无关的,对于程序员最为接近和直观,但实际上后端编译才是最为复杂最为重要的,因为 JVM 的后端编译是语言无关而平台相关的,因此后端编译往往更为复杂,但对性能的影响更为直接。

  • 本文所有信息基于作者实践验证,如有错误,恳请指正,不胜感谢。
  • 转载请于文首标明出处:【Java】JVM - 前端编译器
  • 文章仍未完工,内容会逐步完善

前端编译器负责将 Java 源代码转换为字节码。前端编译器对代码的运行效率几乎没有任何优化,主要负责开发阶段代码的优化。

前端编译器的编译过程大致分为四步:

  1. 初始化插入式注解管理器。

  2. 解析与填充符号表:

    • 词法分析生成标记集合。
    • 语法分析生成抽象语法树。
    • 填充符号表,生成符号地址与符号信息的映射。
  3. 注解处理。

  4. 语义分析与字节码生成,包括:

    • 标注检查。对语法的静态信息进行检查。
    • 数据流及控制流分析。对程序动态运行过程进行检查。
    • 解语法糖。将简化代码编写的语法糖还原为原有的形式。
    • 字节码生成

注解处理器

JDK5 之后 Java 语言支持注解,注解仅在程序运行期间发生作用。JDK6 之后通过“插入式注解处理器”的提案,允许前端编译期间对代码中的特定注解进行处理,从而允许在源代码编码过程中向编译器发送特定的信号。因此插入式注解管理器实际上仅仅只是编译器的一段代码,或叫插件。

解析

解析过程主要包括词法分析语法分析两个步骤,还有一步辅助工作是生成符号表

  • 词法分析(Lexical Analysis):词法分析的作用是将源代码中的字符流转变为标记(Token)集合。

    单个字符是源代码中的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都是标记,如 int a = b + 2 拆分为标记分别为:inta=b+2

  • 语法分析(Grammatical Analysis):语法分析的作用是根据标记序列构造抽象语法树。

    抽象语法树(Abstract Syntax Tree,AST) 是一种用来描述程序代码语法结构的树形表示方式,它的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释。

  • 生成符号表(Filling Symbol Table):符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,用于收集各个符号的相关属性信息,方便编译工作的进行。

经过词法分析和语法分析构造抽象语法树之后,编译器不会再对源代码字符流进行任何操作,一切操作都在抽象语法树上进行。

注解处理

注解处理过程的作用是分析编译时注解所向编译器发送的信号,并根据信号执行相应的动作,对语法树进行读取、修改或添加。如果在注解处理过程中发生对语法树的修改,编译器将回到解析阶段重新执行,直到所有对语法树的修改动作都执行完毕。每一次循环过程成为一个轮次(Round)。

插入式注解管理器不仅仅会对编译时注解进行处理,也可以对某些注释进行处理

语义分析

语义分析的作用是对结构上正确的源程序进行上下文相关性质的检查,如类型检查、控制流检查、数据流检查等。主要分为标注检查和数据及控制流分析。

标注检查与常量折叠优化

标注检查的作用是对语法的静态信息进行检查,如变量使用前是否被声明、变量与赋值之间的数据类型是否能匹配等。同时会进行 常量折叠(Constant Folding) 优化,这是前端编译器会对源代码所做的极少量优化措施之一。

常量折叠是指在编译阶段进行常量简化的过程,所有的字面量和常量都可以看作是常量,同时未修改过的变量也可视为常量,但一些编译器不会这么做,javac 就不会将变量进行常量优化。

前端编译器会对字面量和常量间的计算进行合并,并不会在字节码中保留常量之间的计算;同时对于不会被使用的 bytecharshortint 类型都会自动转为 boolean 类型值存储。由于进行常量合并,常量局部变量在使用时会直接使用它的值作为字面量来使用,同时由于常量局部变量在常量池中并没有 CONSTANT_Fieldref_info 符号引用,因此不会保留其常量属性,因此常量局部变量都会以普通变量保存,且由于这些变量不会被使用到,因此 byte``char``short``int 常量将会自动转化为 boolean 类型值存储。

public static void main(String[] args) {
    String a = "a" + "bc";
    String b = "ab" + "c";
    byte    aByte   = 0;
    char    aChar   = ' ';
    boolean aBool   = false;
    short   aShort  = 0;
    int     aInt    = 0;
    long    aLong   = 0;
    float   aFloat  = 0;
    double  aDouble = 0;
}

经过 javac 编译后反编译的代码:

public static void main(String[] args) {
    String a = "abc";
    String b = "abc";
    byte    aByte   = false; // 不会被使用的 byte  变量都将转成 boolean
    char    aChar   = true;  // 不会被使用的 char  变量都将转成 boolean
    boolean aBool   = false;
    short   aShort  = false; // 不会被使用的 short 变量都将转成 boolean
    int     aInt    = false; // 不会被使用的 int   变量都将转成 boolean
    long    aLong   = 0L;
    float   aFloat  = 0.0F;
    double  aDouble = 0.0D;
}

数据及控制流分析

数据及控制流分析的作用是对程序动态运行过程进行检查。如局部变量使用前是否被赋值、方法每条路径是否有返回值、是否所有受检异常都被正确处理等等。本阶段和类加载的验证阶段中的字节码验证的作用是相同的,但验证的范围有所区别,有一些验证项只能在前端编译期进行,有一些验证项只有在运行期才能进行。

举个只能在编译期进行验证的例子:

public void foo1(final int arg) {
    final int var = 0;
    System.out.println(arg);
    System.out.println(var);
}

public void foo2(int arg) {
    int var = 0;
    System.out.println(arg);
    System.out.println(var);
}

上述代码编译后再反编译的结果为:

public void foo1(int arg) {
    int var = false;
    System.out.println(arg);
    System.out.println(0);   // 常量折叠
}

public void foo2(int arg) {
    int var = 0;
    System.out.println(arg);
    System.out.println(var);
}

常量局部变量在常量池中并没有 CONSTANT_Fieldref_info 符号引用,因此 class 文件不会保留其局部变量的常量属性,因此常量局部变量都会以普通变量保存。所以变量的不变性只能由前端编译器在编译期间进行保证

同时由于常量折叠的发生,常量所使用到的地方编译器会自动将常量的值作为字面量使用,因此常量局部变量就变成没有使用过的变量,所以对于 byteshortcharint 类型局部常量,编译器都会自动转化为 boolean 类型值进行存储。

final byte    aFinalByte   = 0;
final char    aFinalChar   = ' ';
final boolean aFinalBool   = false;
final short   aFinalShort  = 0;
final int     aFinalInt    = 0;
final long    aFinalLong   = 0;
final float   aFinalFloat  = 0;
final double  aFinalDouble = 0;
System.out.println("aFinalByte: "   + aFinalByte);
System.out.println("aFinalChar: "   + aFinalChar);
System.out.println("aFinalBool: "   + aFinalBool);
System.out.println("aFinalShort: "  + aFinalShort);
System.out.println("aFinalInt: "    + aFinalInt);
System.out.println("aFinalLong: "   + aFinalLong);
System.out.println("aFinalFloat: "  + aFinalFloat);
System.out.println("aFinalDouble: " + aFinalDouble);

上述代码编译后反编译的结果为:

byte    aFinalByte   = false; // 转为 boolean 值存储
char    aFinalChar   = true;  // 转为 boolean 值存储
boolean aFinalBool   = false;
short   aFinalShort  = false; // 转为 boolean 值存储
int     aFinalInt    = false; // 转为 boolean 值存储
long    aFinalLong   = 0L;
float   aFinalFloat  = 0.0F;
double  aFinalDouble = 0.0D;
System.out.println("aFinalByte: 0");
System.out.println("aFinalChar:  ");
System.out.println("aFinalBool: false");
System.out.println("aFinalShort: 0");
System.out.println("aFinalInt: 0");
System.out.println("aFinalLong: 0");
System.out.println("aFinalFloat: 0.0");
System.out.println("aFinalDouble: 0.0");

解语法糖

解语法糖是指在编译过程中,将 Java 代码中的语法糖还原为原始的基础语法结构。

Java 语言中的语法糖包括泛型、自动装箱、自动拆箱、循环遍历和可变长参数。

泛型

泛型(Generic)就是将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。它的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)。

类型擦除

Java 通过类型擦除的方式实现泛型,因此叫类型擦除式泛型(Type Erasure Generic)。类型擦除是指同一类型的两种泛型类型,虽然在源代码中是两种不同的类型,但是在前端编译过后,都会被替换回原来的裸类型(Raw Type),并且在相应的地方插入强制类型转换代码。因此在字节码文件中和运行期间 List<String>List<Integer> 的类型都是 List

与类型擦除式泛型相对的是“具现化类型”(Reified Generics)的方式实现泛型。具现化类型是指无论是编码阶段还是运行阶段,所有泛型类型都将成为一个真正存在的类型,所有泛型类型都有自己独立的虚方法和类型数据。

为什么选择类型擦除?

如果要引入泛型,那么有两种方式:

  • 为需要泛型化的类型重新设计一套泛型化版本的新类型,如 ListGenericList<T> 共存。

  • 把需要泛型化的类型直接原地泛型化,如 List 变成 List<T>

由于需要泛型的类型主要是容器类型,同时 JDK1.2 中引入了新的集合类,导致有 VectorArrayList 两套容器共存的局面,如果才有第一种方式,到时将会出现三套容器共存的局面,这将让用户无法接受,因此 Java 采用原地泛型化的方式实现泛型。

由于采用原地泛型化的方式,Java 提出了裸类型(Raw Type)这一概念,用于表示泛型类型剥除泛型之后剩下的类型。即 ListList<T> 的裸类型。

由于 Java 语言规范承诺 Java 会保证“二进制向后兼容性”,因此新的泛型类型必须能够兼容之前存在的旧版本类型。所以在实现泛型类型 List<T> 时还必须保证 List 类型可用,而且所有 List<T> 类型都要能够转化为 List 类型。

public static void test1() {
    ArrayList<Double> doubles = new ArrayList<>();
    ArrayList<Integer> ints = new ArrayList<>();
    ArrayList list;
    list = ints;
    list = doubles;
}

实现这一转化有两种方式:

  1. 类型擦除,在编译时暴力的将泛型化类型还原回裸类型,只有在应用泛型的地方进行强制类型转换来触发运行时类型检查。

  2. 具现化类型,由 JVM 在运行期间动态、真实地构造出泛型化类型并继承于裸类型,由于真实的类型限定,所有使用到泛型的地方都只允许使用泛型参数限定的类型,同时泛型化类型可以向上转型为裸类型。

由于泛型主要用于容器类,此前 java 中的容器一般通过 Obejct 成员或者 Object[] 成员来实现,又由于 Java 中数组是协变的,因此容器可以存储所有 Object 子类型的元素,也就是任意类型的元素。所以在实现泛型时,除了需要保证所有泛型类型都能直接转型为裸类型之外,还需要保证转换之后其裸类型依旧能适用于所有的类型数据。即 List<T> 转型为 List 之后,可以不单单使用 T 类型的数据。如下所示:

public static void test2() {
    ArrayList<Integer> intList = new ArrayList<>();
    intList.add(42);
    ArrayList rawTypeList = intList;
    rawTypeList.add("HELLO WORLD");
    rawTypeList.forEach(System.out::println);
}

由于使用具现化类型的方式实现的泛型化类型的实例在运行时会有严格的类型检查,在转型为裸类型之后,无法对该实例使用非泛型参数的数据,因此无法做到向后兼容,所以 Java5.0 时粗暴地采取了类型擦除的方式,在编译期将泛型化类型擦除成裸类型。

其实具现化类型也可以做到向后兼容,让 JVM 舍弃掉向上转型的方式,改用额外实例化裸类型实例的方式来实现对裸类型的支持即可做到向后兼容,但他们偷懒了,使得无数用户不得不承受着类型擦除带来的各项缺点而让 Java 泛型饱受诟病。

类型擦除的实现

通过编译时泛型擦除的方式实现泛型,意味着不需要对 JVM 进行任何改动,所有处理工作都交给前端编译器执行,前端编译器在泛型参数校验通过之后,会将所有泛型信息擦除,同时将使用到泛型的地方进行强制类型转换。

private static void test3() {
    ArrayList<Integer> integers = new ArrayList<>();
    integers.add(42);
    Integer integer = integers.get(0);
}

编译为字节码指令为:

 0: new           #5                  // class java/util/ArrayList
 3: dup
 4: invokespecial #6                  // Method java/util/ArrayList."<init>":()V
 7: astore_0
 8: aload_0
 9: bipush        42
11: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokevirtual #8                  // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
17: pop
18: aload_0
19: iconst_0
20: invokevirtual #14                 // Method java/util/ArrayList.get:(I)Ljava/lang/Object;
23: checkcast     #15                 // class java/lang/Integer
26: astore_1
27: return

泛型没完全擦除?

前端编译期间会把字节码文件中方法体 Code 属性中的泛型信息擦除,本地变量表和返回值的泛型信息仍会保留着用作校验,但是运行代码中的类型为裸类型。因为代码中只能申请裸类型数组(如:new ArrayList[]),所以实例化一个泛型类型数组(如: new ArrayList<Integer>[])是无法通过编译的。因此一般容器类数组的实现都是使用嵌套容器类的方式实现,而非直接使用裸类型数组。

List<Integer>[] lists;               // 可以用于声明本地变量、方法参数和返回值,但代码不行。
lists = new LinkedList<Integer>[16]; // ERROR,实例化泛型类型数组无法通过编译。
lists = new LinkedList[16];          // 实例化裸类型数组可以通过编译。

类型擦除的缺点

  • 类型不安全:因为泛型化类型实例可以转换为裸类型实例,此时该裸类型实例可以接受所有 Object 类型数据。裸类型实例不会对数据输入作任何类型检查,此时原泛型化实例在输出数据时非常有可能发生类型检查异常。

    解决办法是不使用任何泛型化类型的裸类型。

    private static void test4() {
        ArrayList<Integer> intList = new ArrayList<>();
        intList.add(42);
        ArrayList rawTypeList = intList;
        rawTypeList.add("HELLO WORLD");
        Integer int1 = intList.get(0);
        Integer int2 = intList.get(1); // ClassCastException
    }
    

    对生成的字节码进行反编译之后,由于裸类型实例没有被使用到,因此直接被丢弃,所有类型数据都可以被 integers 接收,仅在进行获取操作时进行强制类型转换,此时将抛出异常:

    private static void test4() {
        ArrayList<Integer> intList = new ArrayList();
        intList.add(42);
        intList.add("HELLO WORLD");
        Integer int1 = (Integer)intList.get(0);
        Integer int2 = (Integer)intList.get(1); // ClassCastException
    }
    
  • 运行时无法获取泛型类型信息:由于在运行期间不会保留任何泛型类型信息,因此该泛型参数无法用于进行任何运行时操作,如无法用作类型判断:

    private static class TestClass<T> {
        private boolean test(Object o) {
            return o instanceof T; // cannot compile
        }
    }
    

    如果必须在运行期间使用泛型的类型信息,则不得不在方法参数列表中加入一个类型参数:

    private static void test5() {
        List<Integer> integers = new ArrayList<>();
        integers.add(42);
        integers.add(24);
        Integer[] intArr = TestClass.convertListToArray(integers, Integer.class);
        Arrays.stream(intArr).forEach(System.out::println);
    }
    
    private static class TestClass<T> {
        public static <T> T[] convertListToArray(List<T> list, Class<T> componentType) {
            T[] array = (T[]) Array.newInstance(componentType, list.size());
            for (int i = 0; i < list.size(); i++) {
                array[i] = list.get(i);
            }
            return array;
        }
    
  • 无法使用泛型化类型进行重载:由于泛型化类型在编译期间会被擦除为裸类型,因此泛型化类型参数在编译后都会被转化为裸类型参数,因此无法进行重载:

    private static void test6Helper(List<Long> longList) {}
    
    private static void test6Helper(List<Integer> intList) {}
    
    // 'test6Helper(List<Long>)' clashes with 'test6Helper(List<Integer>)'; 
    // both methods have same erasure
    

    尽管我们知道返回值不参与方法重载,但是改写成这样却可以通过 javac6 的编译,编译后的 class 文件可以在 Java6 之后任意版本的 jvm 中运行:

    private static long test6Helper(List<Long> longList) {
        System.out.println("long");
        return 1l;
    }
    private static int test6Helper(List<Integer> intList) {
        System.out.println("int");
        return 1;
    }
    private static void test6() {
        test6Helper(new ArrayList<Long>());
        test6Helper(new ArrayList<Integer>());
    }
    

    编译后的结果为:

    private static long test6Helper(java.util.List<java.lang.Long>);
        descriptor: (Ljava/util/List;)J
        // 略...
        Signature: #22           // (Ljava/util/List<Ljava/lang/Long;>;)J
    
    private static int test6Helper(java.util.List<java.lang.Integer>);
        descriptor: (Ljava/util/List;)I
        // 略...
        Signature: #26           // (Ljava/util/List<Ljava/lang/Integer;>;)I
    

    运行结果为:

    long
    int
    

    这里也是一个体现编译器差异的地方,这份代码在某些编译器上允许编译,某些编译器上不允许编译。

    方法重载要求方法具备不同的 Java 特征签名(Java 特征签名包括方法名称、参数顺序与参数类型),返回值并不包含在方法签名中,因此对于 Java 而言返回值不参与重载。但对于 Class 文件而言,方法名相同,只要方法的描述符不同,两个方法就可以共存。因此此处编译出来的 class 文件是合法的。

    Java 特征签名包括方法名称、参数顺序和参数类型。
    字节码特征签名包括方法名称、参数顺序、参数类型、返回类型和受查异常表。
    方法描述符,按照向参数列表、后返回值的顺序描述,参数列表按照参数顺序排列在 () 中。 查看字节码中的 Signature 属性可以发现方法签名并不同,因此上述代码可以通过少部分编译器的编译。

自动装箱与自动拆箱

Java 中存在几个包装类类型,它们对应着 8 大基本数据类型。在编码时,可以在使用包装类实例的地方直接使用基本数据类型 Integer a = 10,也可以在使用基本数据类型的地方直接使用包装类实例 int b = new Integer(10)。编译器会自动进行类型转换,这个过程称为自动装箱自动拆箱

自动装箱的实现是在编译期间,编译器为基本数据类型自动添加 valueOf 方法实现的,例如 Integer a = 10 在编译后会被转化为 Integer a = Integer.valueOf(10)

自动拆箱的实现是在编译期间,编译器为包装类型自动调用 value 方法实现的,例如 int c = a + 10 在编译后会被转化为 int c = a.intValue() + 10

包装类缓存区

由于拆装箱是非常常见的操作,基本数据类型的使用也是最为常见的操作,因此在它们对应的包装类中设置了缓存实例,这种做法导致了会有许多匪夷所思的细节错误。

Integer 缓存区:

private static class IntegerCache {
    static final Integer cache[];
}
类型缓存区默认范围
Byte-128 ~ 127
Short-128 ~ 127
Integer-128 ~ 127
Long-128 ~ 127
Character0 ~ 128
BooleanTRUE、FALSE

缓存区问题:

Integer a = 10, b = 10, c = 128, d = 128, e = 118;
System.out.println(a == b);             // true
System.out.println(c == d);             // false
System.out.println(c == (a + e));       // true

在调用 valueOf 包装的时候,对于缓存区范围内的数值,会直接返回缓存区内的对象,否则就会实例化一个新的包装类对象并返回。因此可以看到 a == bd != e 这种情况的发生。但当包装类对象与两个数的和做比较时,运算的一方会拆箱成基本数据类型,所以全等比较也会使用基本数据类型进行比较,因此 c == (a + e)

Integer a = 20, b = 120;
Long c = 140L;
System.out.println(c == (a + b));
System.out.println(c.equals(a + b));

Long 类型实际上不能与 Integer 类型直接进行全等比较,会抛出异常。但与上面一样,双方都会拆箱为基本数据类型,int 再进行类型提升,反编译后的代码也显示了这一点:c == (long)(a + b)

但当 Long 对象主动调用 equals 方法时,它就不会进行拆箱了,此时 a + b 在运算之后就会直接装箱为 Integer 对象传入方法中,而不是进行类型提升,类型不同比较结果自然是 false

for-each 循环

for-each 循环实际上是迭代器写法的语法糖,这就是 for-each 循环需要遍历类型实现 Iterable 接口的原因:

List<Integer> intList = new ArrayList<>();
//...
for (Integer i : intList) {/*...*/}

以上代码编译后反编译的结果是:

List<Integer> intList = new ArrayList();
//...
Integer i;
for(Iterator var5 = intList.iterator(); var5.hasNext();) {
    i = (Integer)var5.next();
    //...
}

虽然是使用迭代器实现的,但 for-each 写法依旧不能在遍历过程中删除列表元素,因为要在迭代器遍历中合法地删除元素,必须使用 Iterator::remove 方法。

可变长参数

可变长参数实际上是数组的语法糖。为了让编译器可以直接将它转换为数组,可变长参数必须在参数列表的最后一位。这就代表着参数列表最多只能有一个可变长参数。

public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

try-resource 写法

try-resource 写法用于帮助程序员自动关闭实现了 Closeable 接口的对象,Closeable 接口定义了 close 方法,因此所有实现该接口的类型都是可关闭的。这一个操作也是在编译期间进行解语法糖,编译器会帮我们添加调用 Closeable::close 的代码。

try (FileInputStream in = new FileInputStream(file)) {
    // ...
} catch (IOException e) {
    e.printStackTrace();
}

反编译后的代码:

try {
    FileInputStream in = new FileInputStream(file);
    Throwable var3 = null;
    try {
        // ...
    } catch (Throwable var13) {
        var3 = var13;
        throw var13;
    } finally {
        if (in != null) {
            if (var3 != null) {
                try {
                    in.close();
                } catch (Throwable var12) {
                    var3.addSuppressed(var12);
                }
            } else {
                in.close();
            }
        }

    }
} catch (IOException var15) {
    var15.printStackTrace();
}

字符串连接

字符串的 + 操作也是 Java 提供的语法糖,在 JDK5 之前,字符串相加在编译时会转化为 StringBuffer::append 操作,在 JDK5 及以后的版本中,会转化为 StringBuilder::append 操作。

String s1 = "HELLO ";
String s2 = "WORLD!";
String str = s1 + s2 ;

反编译后的代码:

String s1 = "HELLO ";
String s2 = "WORLD!";
String str = (new StringBuilder()).append(s1).append(s2).toString();