读书笔记 ---- 《深入理解Java虚拟机》---- 第9篇:早期(编译期)优化

上一篇:Tomcat、OSGI和JNDI类加载器的实现:https://blog.csdn.net/pcwl1206/article/details/84560779

目 录:

1、概述

2  Javac编译器 

2.1  解析与填充符号表

2.1.1  词法、语法分析

2.1.2  填充符号表

2.2  注解处理器

2.3  语义分析与字节码生成

2.3.1  标注检查

2.3.2  数据及控制流分析

2.3.3  字节码生成

3  Java语法糖

3.1  泛型与类型擦除

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

3.3  条件编译

4  总结


1、概述

Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just  In  Time  Compiler)把字节码转变成机器码的过程;还有可能是静态编译器(AOT编译器,Ahead  Of  Time  Compiler)直接把*.java文件编译成本地机器代码的过程。

1、前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ);

2、JIT编译器:HotSpot  VM的C1、C2编译器;

3、AOT编译器:GNU  Compiler  for  the  Java(GCJ)、Excelsior  JET。


2  Javac编译器 

从Sun Javac的代码来看,编译过程大致可以分为3个过程:

1、解析与填充符号表的过程;

2、插入式注解处理器的注解处理过程;

3、分析与字节码生成过程。

Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上诉三个过程的代码主要集中在这个类中的compile()和compile2()方法中。整个编译阶段最关键的8个方法如下图标注所示:

2.1  解析与填充符号表

2.1.1  词法、语法分析

词法分析是将源代码的字符流转变为标记(Token)集合单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,如“int a = b + 2”这句代码中包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由三个字符构成,但是它只是一个Token,不可再拆分。在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。

词法分析器的作用是将Java源文件的字符流转变成对应的Token流。而语法分析器是将词法分析器分的Token流组件成更加结构化的语法树,也就是将一个个单词组装成一句话,一个完整的语句。哪些词语组合在一起是主语,哪些是谓语、哪些是宾语、哪些是定语等没要做进一步区分。 
  语法分析是根据Token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树由com.sun.tools.javc.tree.JCTree类表示,经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都是建立在抽象语法树上。

2.1.2  填充符号表

符号表是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表K-V值对的形式。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检测和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。在Javac源码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现。 
  一个类除了类本身会定义一些符号变量如类名称、变量名称和方法名称等,还有一些符号是引用其它类的,这些符号会调用其它类的方法或者变量等,还有一些类可能会继承或者实现超类和接口等。这些符号都是在其他类中定义的,那么就需要将这些类的符号也解析到符号表中。 
  在Enter类解析这一步骤中,还有一个重要的步骤就是添加默认的构造函数。如果代码中没有提供任何构造函数,那么编译器将会添加一个没有参数、访问性与当前一致的默认构造函数。

2.2  注解处理器

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

插入式注解处理器的初始过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成的。

2.3  语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务就是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。

Javac的编译过程中,语法分析过程分为标注检查以及数据及控制流分析两个步骤。

2.3.1  标注检查

标注检查的检查内容包括:变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配。

在标注检查步骤中一个重要的动作称为常量折叠,如果我们在代码中写了如下定义:

int a = 1 + 2;

那么在语法树上仍然能看到字面量1、2以及操作符+,但是在进过常量折叠之后,他们将会被折叠为字面量3。

标注检查步骤在Javac源码中的实现的类是com.sun.tools.javac.comp.Attr类和com.sun.tools.javac.comp.Check类。

2.3.2  数据及控制流分析

数据流分析 :

数据流主要完成如下工作:

1、检查变量在使用前是否都已经被正确赋值;

2、保证final修饰的变量不会被重复赋值;

3、要确定方法的返回值类型。这里需要检查方法的返回值类型是否确定,并检查接受这个方法返回值的引用类型是否匹配,如果没有返回值,则不能有任何引用类型指向方法的这个返回值;

4、所有的Checked Exception都要捕获或者向上抛出;

5、所有的语句都要被执行到。这里会检查是否有语句出现在一个return方法的后面,因为在return方法后面的语句永远也不会被执行到。

控制流分析 :

控制流主要完成如下工作:

1、去掉无用的代码,比如永假的if代码块;

2、变量的自动转换,比如自动装箱拆箱;

3、去除语法糖。解语法糖的过程由desugar()方法触发,在com.sun.tools.javac.comp.TransTypes和com.sun.tools.javac.comp.Lower类中完成;

4、数据流及控制流的分析入口是flow()方法,具体操作由com.sun.tools.javac.comp.Flow类来完成。

2.3.3  字节码生成

字节码生成是Javac编译过程的最后一个阶段,在Javac源码中由com.sun.tools.javac.jvm.Gen类来完成。字节码阶段不仅仅把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。 比如:实例构造器<init>方法和类构造器<clinit>方法就是在这个阶段添加到语法树中的。 

完成了对语法树的遍历和调整后,就会把填充了所有所需信息的符号表交给com.sun.tools.javac.jvm.ClassWriter类,由这个类的writeClass()方法输出字节码,生成最终的Class文件,到此为止,整个编译过程结束。

生成Java字节码需要经过以下两个步骤:

1、将Java方法中的代码块转化成符合JVM语法的命令形式,JVM的操作都是基于栈的,所有的操作都必须经过出栈和进栈来完成。

2、按照JVM的文件组织格式将字节码输出到以class为扩展名的文件中。


3  Java语法糖

Java中的语法糖不会提供实质性的功能改进,但是它们可以提高效率、提升语法的严谨性以及减少代码出错的机会。Java中常用到的语法糖有:泛型、变长参数、自动装箱/拆箱、内部类、枚举类、断言语句、对枚举和字符串的switch支持、try语句中定义和关闭资源等。

3.1  泛型与类型擦除

泛型是JDK1.5中新增的特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

Java中的泛型是“伪泛型”。Java中的泛型在源码中存在,但在编译后的字节码文件中就已经替换为了原来的原生类型了,并且在相应的地方插入了强制转换代码,因此,对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除

下面举两个案例用于说明Java泛型中的一些细节。

【案例1:泛型擦除示例

// 泛型擦除前的例子
public static void main(String[] args){
    Map<String, String> map = new HashMap<String, String>();
    map.put("hello", "你好");
    map.put("how are you?", "吃了吗?");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you?"));
}

将上面这段代码先编译成Class文件,然后再用字节码反编译工具进行反编译后,就会发现泛型都不见了,泛型类型又变成了原生类型。

// 泛型擦除后的例子
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?"));
}

【案例2:当泛型遇见重载

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

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

很明显上面这段代码是不能被正确编译的,因为参数List<Integer>和List<String>编译后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。但是,实际上,泛型擦除成相同的原生类型只是无法重载的其中一部分原因,接着看下面这段代码。

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

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

上诉代码中仅仅给两个method()方法添加了不同的返回值,但是重载就成功了,这段代码能够成功的被编译和执行。肯定会有这样的迷惑:“方法重载不能根据返回值的不同来确定的”。

之所以加了返回值后能够编译和运行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件中。在前面的类文件结构中,Class文件方法表(method_info)中提到过,方法的重载要求方法具备不同的特征前面,返回值不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在Class文件格式中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法有相同的名称和特征签名,但返回值不同,那么它们也是可以合法地共存在一个Class文件中

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

直接看案例代码:

// 自动装箱、拆箱与遍历循环
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<Integer> 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);
}

上诉代码一共包括:泛型、自动装箱、自动拆箱、遍历循环和可变参数5种语法糖。

泛型:上面已经解释过了;

自动装箱、拆箱:在编译后被转化成了对应的包装和还原方法,如Integer.valueOf()方法和Integer.intValue()方法;

遍历循环:把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterator接口的原因;

变长参数:在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。

【案例2:自动装箱的陷阱

public class Demo {

	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
		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()方法不处理数据转型的关系,所以在实际代码开发中应该尽量避免这样使用自动装箱与拆箱。

3.3  条件编译

Java语言可以使用条件为常量的if语句进行条件编译。如下面的代码所示,此代码中的if语句不同于其他Java代码,它在编译阶段被“运行”,生成的字节码之中只包括"System.out.println("block1");"一条语句,并不会包含if语句及另外一个分支中的“System.out.println("block2");”。

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

上诉代码编译后的Class文件的反编译结果为:

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

需要说明的是:只有使用条件为常量的if语句才能达到上述的效果,如果使用常量与其他有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如:

public static void main(String[] args){
    // 编译器将会提示:"Unreachable code"
    while(false){
        System.out.println(" ");
    }
}

Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块取消掉,这一工作在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower类中)完成。


4  总结

本文从编译器源码实现的层次上介绍了Java源代码编译为字节码的过程,分析了Java语言中泛型、主动装箱/拆箱、条件编译等语法糖的前因后果。

在前端编译器Javac中,“优化”手段主要用于提升程序的编码效率,之所以把Javac这类将Java代码转变为字节码的编译器称作“前端编译器”,是因为它们只完成了从程序到抽象语法树或中间字节码的过程。而在此之后,还有一组内置于虚拟机内部的“后端编译器”完成从字节码生成本地机器码的过程,即前面多次提到的即时编译器/JIT编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。


上一篇:Tomcat、OSGI和JNDI类加载器的实现:https://blog.csdn.net/pcwl1206/article/details/84560779

推荐及参考:

1、Javac编译过程:https://blog.csdn.net/u013256816/article/details/50804493

2、Javac编译过程https://www.cnblogs.com/zxf330301/p/5504500.html

猜你喜欢

转载自blog.csdn.net/pcwl1206/article/details/84635959