总结:对程序语法进行校验,并进行部分优化,比如省略不会执行的if语句,lombok代码的生成。
在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是
指一个前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是
指Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器
码的过程;还可能是指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程
序编译成与目标机器指令集相关的二进制代码的过程。下面笔者列举了这3类编译过程里一些比较有代
表性的编译器产品:
·前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)[1]。
·即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
·提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)[2]、Excelsior JET [3]。
Javac这类前端编译器对代码的运行效率几乎没
有任何优化措施可言(在JDK 1.3之后,Javac的-O优化参数就不再有意义),哪怕是编译器真的采取
了优化措施也不会产生什么实质的效果。因为Java虚拟机设计团队选择把对性能的优化全部集中到运
行期的即时编译器中,这样可以让那些不是由Javac产生的Class文件(如JRuby、Groovy等语言的Class
文件)也同样能享受到编译器优化措施所带来的性能红利。
Java中即时编译器在运行期的优
化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的
编码效率和语言使用者的幸福感的提高。(比如lamad表达式在磁盘存储可能是匿名内部类的形式,在我们编写的时候使用lamad表达式使代码简洁)
Javac编译器
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下
所示。
1)准备过程:初始化插入式注解处理器。
2)解析与填充符号表过程,包括:
·词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
·填充符号表。产生符号地址和符号信息。3)插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一
个插入式注解处理器来影响Javac的编译行为。
4)分析与字节码生成过程,包括:
·标注检查。对语法的静态信息进行检查。
·数据流及控制流分析。对程序动态运行过程进行检查。
·解语法糖。将简化代码编写的语法糖还原为原有的形式。
·字节码生成。将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转
回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺
序如图10-4所示。


解析与填充符号表
解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤。
1.词法、语法分析
词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元
素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如“int
a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但
是它只是一个独立的标记,不可以再拆分。在Javac的源码中,词法分析过程由
com.sun.tools.javac.parser.Scanner类来实现。
语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一
种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个
语法结构(SyntaxConstruct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都
可以是一种特定的语法结构。
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都
建立在抽象语法树之上。
2.填充符号表
完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程,符号表(Symbol Table)是由一组符号地址和
符号信息构成的数据结构,读者可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等各种形式)。符号表中所登记的
信息在编译的不同阶段都要被用到。譬如在语义分析的过程中,符号表所登记的内容将用于语义检查
(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号
名进行地址分配时,符号表是地址分配的直接依据。
注解处理器
JDK 5之后,Java语言提供了对注解(Annotations)的支持,注解在设计上原本是与普通的Java代
码一样,都只会在程序运行期间发挥作用的。但在JDK 6中又提出并通过了JSR-269提案[1],该提案设
计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,
从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些
插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法
树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有
再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。
有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的
任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功
能上有很大的发挥空间。只要有足够的创意,程序员能使用插入式注解处理器来实现许多原本只能在
编码中由人工完成的事情。譬如Java著名的编码效率工具Lombok [2],它可以通过注解来实现自动产生
getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法,等等,帮助开发人
员消除Java的冗长代码,这些都是依赖插入式注解处理器来实现的。
CheckStyle也是基于以上实现的。
语义分析与字节码生成
经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正
确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源
程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。举个简单的
例子,假设有如下3个变量定义语句:
int a = 1;
boolean b = false;
char c = 2;后续可能出现的赋值运算:
int d = a + c;
int d = b + c;
char d = a + c;后续代码中如果出现了如上3种赋值运算的话,那它们都能构成结构正确的抽象语法树,但是只有
第一种的写法在语义上是没有错误的,能够通过检查和编译。其余两种在Java语言中是不合逻辑的,
无法编译(是否合乎语义逻辑必须限定在具体的语言与具体的上下文环境之中才有意义。如在C语言
中,a、b、c的上下文定义不变,第二、三种写法都是可以被正确编译的)。我们编码时经常能在IDE
中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。
1.标注检查
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否
能够匹配,等等,在标注检查中,还会顺便进行
一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施
之一(代码优化几乎都在即时编译器中进行)。
int a = 1 + 2;在抽象语法树上仍然能看到字面量“1”“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变量的值,但是如果观察这两段代
码编译出来的字节码,会发现它们是没有任何一点区别的,每条指令,甚至每个字节都一模一样。可以肯定地推断出把局部变量声明为final,对运行期是完全没有影响的,变量的不变性仅仅由Javac编
译器在编译期间来保障,这就是一个只能在编译期而不能在运行期中检查的例子。
3.解语法糖
语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程
术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,
但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。
Java在现代编程语言之中已经属于“低糖语言”(相对于C#及许多其他Java虚拟机语言来说),尤
其是JDK 5之前的Java。“低糖”的语法让Java程序实现相同功能的代码量往往高于其他语言,通俗地说
就是会显得比较“啰嗦”,这也是Java语言一直被质疑是否已经“落后”了的一个浮于表面的理由。
Java中最常见的语法糖包括了前面提到过的泛型(其他语言中泛型并不一定都是语法糖实现,如
C#的泛型就是直接由CLR支持的)、变长参数、自动装箱拆箱,等等,Java虚拟机运行时并不直接支
持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
4.字节码生成
字节码生成是Javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如前文多次登场的实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。请注意这里的实例构造器并不等同于默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private或<package>)与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
除了生成构造器以外,还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 5)的append()操作,等等。
Java语法糖
泛型
public class TypeErasureGenerics<E> { public void doSomething(Object item) {
if (item instanceof E) { // 不合法,无法对泛型进行实例判断 ...
}
E newItem = new E(); // 不合法,无法使用泛型创建对象
E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组
}
}Object[] array = new String[10]; array[0] = 10; // 编译期不会有问题,运行时会报错 ArrayList things = new ArrayList(); things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");因为泛型接入之前,在没有泛型的时代,由于Java中的数组是支持协变(Covariant)的,对应的集合类也可以存入不同类型的元素,某些类已经有非泛型版本,比如ArrayList原先没有泛型属于裸类型,因为java需要保持低版本代码可以在高版本正常运行的兼容性,所以使用了类型擦除机制,底层存储的时候实际上是没有泛型的,只不过会在使用的时候加校验,比如intger泛型的时候会加intger.valueOf()这种会在性能上造成影响,这也是java泛型性能低的原因。
c#底层存储直接就存储成对应类型了,因为c#当时发行时间不久,改造成本小,java已经有10余年历史,影响太大。
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?"));
}因为泛型擦除,所以无法直接获取到泛型类型,以下集合转数组得专门传class类型增加了复杂度。
public static <T> T[] convert(List<T> list, Class<T>componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}以下不能成功重载,原因泛型擦除导致无法识别。
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)");
}
}之所下面能编译和执行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。
方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。

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

自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员的确也就是使用数组来完成类似功能的。
条件编译
该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包
括“System.out.println("block 1");”一条语句,并不会包含if语句及另外一个分子中
的“System.out.println("block 2");”
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语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句
搭配,则可能在控制流分析中提示错误,被拒绝编译.

Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把
分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(
com.sun.tools.javac.comp.Lower
类中)完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写
在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。