Java“编译期”是一段“不确定”的操作过程:可能是指一个前端编译器(编译器的前端)把*.java文件转变为*.class文件的过程;可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变为机器码的过程;可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把*.java文件编译成本地机器代码的过程。这三类编译过程中一些比较有代表性的编译器:
前端编译期:Sun的Javac/EclipseJDT中的增量式编译器(ECJ)。
JIT编译器:HotSpot VM的C1/C2编译器。
AOT编译器:GNU Compiler for the Java(GCJ)/Excelsior JET。
Javac这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,可以让那些不是由Javac产生的Class文件也能享受到编译器优化所带来的好处。但Javac做了许多针对编码过程的优化措施来改变程序员的编码风格和提高编码效率。相当多新生的Java愈发特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持。Java中即时编译器在运行时期的优化过程对于程序运行更重要,而前端编译器在编译期的优化过程对于程序编码关系更加密切。
Javac编译器
Javc编译器是由Java编写的程序。
Javac的源码与调式
从Sun Javac的代码看来,编译过程大致可以分为三个过程:
解析与填充符号表过程。
插入式注解处理器的注解处理过程。
分析与字节码生成过程。
Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述三个过程的代码逻辑集中在这个类的compile()和compile2()里,整个编译最关键的处理由8个方法完成。
解析与填充符号表
解析步骤由图中的parseFiles()完成,解析步骤包括了经典程序编译原理中的词法分析和语法分析。
1 词法/语法分析
词法分析是将源代码的字符流转变为标记(Token)集合,单个字符时程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字/变量名/字面量和运算符都可以成为标记。在Javac的源码中,词法分析由com.sun.tools.javac.parser.Scanner类实现。
语法分析是根据Token序列来构造抽象语法树的过程,抽象语法树(AST,Abstract Syntax Tree)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Construct),例如包/类型/修饰符/运算符/接口/返回值甚至连代码注视等都可以是一个语法结构。
在Javac源码中,语法分析由com.sun.tools.javac.parser.Parser类实现,这个阶段产生的抽象语法树由ccom.sun.tools.javac.tree.JCTree类表示,经过这个步骤后,编译器就基本不会再对源码文件进行操作了,后续操作都建立在抽象语法树上。
2 填充符号表
由enterTrees()完成。符号表(Symbol Table)由一组符号地址和符号信息构成的表格。符号表中登记的信息在编译的不同阶段都要用到。在语义分析中,符号表登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表示地址分配的依据。
由com.sun.tools.javac.comp.Enter类实现,此过程的出口是一个待处理列表(To Do List),包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。
注解处理器
在JDK1.6中实现了JSR-269规范,提供了一组插入式注解处理器的标准API在编译期对注解进行处理,我们可以把它看做是一组编译器的插件,在这些插件里面,可以读取/修改/添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,那么编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round。
语义分析与字节码生成
语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。
1 标注检查
Javac编译过程,语义分析过程为标注检查和数据及控制流分析两个步骤,分别由attribute()和flow()完成。
标注检查步骤检查的内容包括变量使用前是由已经声明/变量与赋值之间的数据类型是否匹配等。还有一个重要的动作称为常量折叠,如果我们在代码中:
int a = 1 + 2;
在语法树上仍然看到字面量“1”/“2”和操作符“+”号,但是经过常量折叠后,它们将会被折叠为字面量“3”。由于编译期间进行了常量折叠,所以在代码中“a=1+2”比起直接定义“a=3”,并不会增加程序运行期哪怕仅仅一个CPU指令的运算量。
2 数据及控制流分析
是对程序上下文逻辑更进一步的验证,它可以检查出程序局部变量在使用前是否赋值/方法的每条路径是否都有返回值/是否所有的受检查异常都被正确处理等问题。编译时期的数据及控制流分析与类加载时数据及控制流分析的目的基本上一致,但校验范围有所区别,有一些校验项只有在编译期或运行期才能进行。
//方法一带有final修饰符
public void foo(final int arg){
final int var = 0;
}
//方法二没有final修饰
public void foo(int arg){
int var = 0;
}
这两段代码编译出来的Class文件没有任何区别。将局部变量声明为final,对运行期没有任何影响,变量的不变性由编译器在编译期间保障。
3. 解语法糖
语法糖(Syntactic Sugar),指在计算机语言中添加某种语法,这种语法对语言的功能并没有影响,但是方便程序员使用。通常来说,使用语法糖能增加程序的可读性,从而减少代码出错的机会。
Java中最常用的语法糖主要是泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
4.字节码生成
字节码生成阶段不仅仅把前面各个步骤生成的信息(语法树、符号表)转换成字节码写到磁盘中,编译器还进行了少量代码添加和转换工作。
例如,实例构造器<init>()方法和类构造器<cinit>()方法就是在这个阶段添加到语法树中(这里的实例构造器不是指默认构造函数,如果代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、访问性(public、protected或private)与当前类一致的默认构造函数,这个工作在填充符号表阶段已经完成),这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器是{}块,对于类构造器是static块)、变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()和<cinit>()中,并保证一定先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。除了生成构造器外,还有其他一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuilder(大于等于JDK1.5)的append()操作。
Java语法糖的味道
泛型与类型擦除
泛型是JDK1.5新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别为泛型类、泛型接口和泛型方法。
Java中泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原生类型(Raw Type,也称裸类型),并在相应的地方插入了强制转型代码,因此,对于运行期的Java来说,ArrayList<Integer>与ArrayList<String>就是同一类,所以泛型技术实际上是Java的一颗语法糖,Java中泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
//泛型擦除前 public static void main(String[] args) { Map<String, String> map = new HashMap<>(); map.put("java", "hello java"); map.put("jasvascript", "hello javascript"); System.out.println(map.get("java")); System.out.println(map.get("jasvascript")); } //泛型擦除后 public static void main(String[] args) { Map map = new HashMap(); map.put("java", "hello java"); map.put("jasvascript", "hello javascript"); System.out.println((String)map.get("java")); System.out.println((String)map.get("jasvascript")); }
自动装箱、拆箱与遍历循环
//自动装箱、拆箱与遍历循环 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); }
遍历循环需要被遍历的类实现Iterable接口。
条件编译
Java可以进行条件编译,方法是使用条件为常量的if语句。
//Java语言中的条件编译 public static void main(String[] args) { if(true){ System.out.println("block 1"); }else{ System.out.println("clock 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(""); } }