编译器早期优化

1 概述

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

前端编译器:Sun的Javac、 Eclipse JDT中的增量式编译器(ECJ)。
JIT编译器:HotSpot VM的C1、 C2编译器。
AOT编译器:GNU Compiler for the Java(GCJ)、 Excelsior JET。

Javac这类编译器对代码的运行效率几乎没有任何优化措施(在JDK 1.3之后,Javac的-O优化参数就不再有意义)。 虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由Javac产生的Class文件(如JRuby、 Groovy等语言的Class文件)也同样能享受到编译器优化所带来的好处。 但是Javac做了许多针对Java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。 相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

2 Javac编译器

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

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

 

2.1解析与填充符号表

2.1.1 词法、 语法分析

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

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract SyntaxTree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Construct),例如包、 类型、 修饰符、 运算符、 接口、 返回值甚至代码注释等都可以是一个语法结构。

2.1.2. 填充符号表

符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表中K-V值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、 树状符号表、 栈结构符号表等)。符号表中所登记的信息在编译的不同阶段都要用到。 在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。 在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

2.2 注解处理器

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

 

再次观察上图即可搞清楚注解处理部分。

2.3 语义分析与字节码生成

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

int a=1;
boolean b=false;
char c=2;

后续可能出现的赋值运算:

int d=a+c;
int d=b+c;
char d=a+c;

  

后续代码中如果出现了如上3种赋值运算的话,那它们都能构成结构正确的语法树,但是只有第1种的写法在语义上是没有问题的,能够通过编译,其余两种在Java语言中是不合逻辑的,无法编译(是否合乎语义逻辑必须限定在具体的语言与具体的上下文环境之中才有意义。 如在C语言中,a、 b、 c的上下文定义不变,第2、 3种写法都是可以正确编译)。

2.3.1 标注检查

语义分析过程分为标注检查以及数据及控制流分析两个步骤。

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

int a=1+2;

那么在语法树上仍然能看到字面量“1”、 “2”以及操作符“+”,但是在经过常量折叠之后,它们将会被折叠为字面量“3”,由于编译期间进行了常量折叠,所以在代码里面定义“a=1+2”比起直接定义“a=3”,并不会增加程序运行期哪怕仅仅一个CPU指令的运算量。

2.3.2数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、 方法的每条路径是否都有返回值、 是否所有的受查异常都被正确处理了等问题。

//方法一带有final修饰

public void foo(final int arg){
    final int var=0;
    //do something
}

//方法二没有final修饰

public void foo(int arg){
    int var=0;
    //do something
} 

在这两个foo()方法中,第一种方法的参数和局部变量定义使用了final修饰符,而第二种方法则没有,在代码编写时程序肯定会受到final修饰符的影响,不能再改变arg和var变量的值,但是这两段代码编译出来的Class文件是没有任何一点区别的,通过第6章的讲解我们已经知道,局部变量与字段(实例变量、 类变量)是有区别的,它在常量池中没CONSTANT_Fieldref_info的符号引用,自然就没有访问标志(Access_Flags)的信息,甚至可能连名称都不会保留下来(取决于编译时的选项),自然在Class文件中不可能知道一个局部变量是不是声明为final了。 因此,将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。

2.3.3解语法糖

语法糖指在计算机语言中添加的某种语法,这种语法对语言的
功能并没有影响,但是更方便程序员使用。 通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java中最常用的语法糖主要是前面提到过的泛型、 变长参数、 自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。

2.3.4字节码生成

字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、 符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。

例如,前面章节中多次提到的实例构造器<init>( )方法和类构造器<clinit>( )方法就是在这个阶段添加到语法树之中的(注意,这里的实例构造器并不是指默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、 访问性(public、 protected或private)与当前类一致的默认构造函数,<clinit>( )方法和<init>( )方法保证所有的初始化按顺序进行。

3 Java语法糖的味道

3.1泛型与类型擦除

在Java语言处于还没有出现泛型的版本时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。 例如,在哈希表的存取中,JDK 1.5之前使用HashMap的get()方法,返回值就是一个Object对象,由于Java语言里面所有的类型都继承于java.lang.Object,所以Object转型成任何对象都是有可能的。 但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。 在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会转嫁到程序运行期之中。

泛型是JDK 1.5的一项新增特性,Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此,对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

泛型擦除前的例子:

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

泛型擦除后的例子

public static void main(String[]args){
    Map map=new HashMap();
    map.put("hello","你好");
    map.put("how are you?","吃了没?");
    System.out.println((String)map.get("hello"));
    System.out.println((String)map.get("how are you?"));
}

  

泛型擦除确实可能会带来很多问题:

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 method(List<String>list){
        System.out.println("invoke method(List<String>list)");
        return"";
    }
    public static int method(List<Integer>list){
        System.out.println("invoke method(List<Integer>list)");
        return 1;
    }
    public static void main(String[]args){
        method(new ArrayList<String>());
        method(new ArrayList<Integer>());
    }
}

执行结果:

invoke method(List<String>list)
invoke method(List<Integer>list)

  方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。 也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。

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

public static void main(String[]args){
    List<Integer>list=Arrays.asList(1,2,3,4);
    //如果在JDK 1.7中,还有另外一颗语法糖[1]
    //能让上面这句代码进一步简写成List<Integer>list=[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);
} 

自动装箱、 拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。 最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。

这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方 ,比如包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系

 

public static void main(String[]margs){
		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);
		System.out.println(e==f);
		System.out.println(c==(a+b));
		System.out.println(c.equals(a+b));
		System.out.println(g==(a+b));
		System.out.println(g.equals(a+b));
} 
true
false
true
true
true
false

  

3.3 条件编译

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

反编译后

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

 

  

 

posted on 2018-08-02 18:35  平凡的小石头  阅读(130)  评论(0编辑  收藏  举报

导航