目录
编译期可能是
指一个前端编译器(叫“编译器的前端”更准确一些)把*.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]。
Java中即时编译器在运行期的优化过程
,支撑了程序执行效率的不断提升;而
前端编译器在编译期的优化过程
,则是支撑着程序员的 编码效率和语言使用者的幸福感的提高。
Javac编译器
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下 所示。
1)准备过程:
初始化插入式注解处理器。
2)
解析与填充符号表过程
,包括:
-
词法、语法分析
。将源代码的字符流转变为标记集合,构造出抽象语法树。 -
填充符号表
。产生符号地址和符号信息。
3)
插入式注解处理器的注解处理过程
:插入式注解处理器的执行阶段。
4)
分析与字节码生成过程
,包括:
-
标注检查
。对语法的静态信息进行检查。 -
数据流及控制流分析
。对程序动态运行过程进行检查。 -
解语法糖
。将简化代码编写的语法糖还原为原有的形式。 -
字节码生成
。将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺 序如图。
整个编译过程主要的处理由图中标注的8个方法来完成。
解析与填充符号表
解析过程由图10-5中的parseFiles()方法(图10-5中的过程1.1)来完成,解析过程包括了经典程序 编译原理中的词法分析和语法分析两个步骤。
1.词法、语法分析(parseFiles(*))
词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元 素,但标记才是编译时的最小元素。
关键字、变量名、字面量、运算符都可以作为标记
,如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但 是它只是一个独立的标记,不可以再拆分。在Javac的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner类来实现。
语法分析是根据标记序列构造抽象语法树的过程。
抽象语法树(Abstract Syntax Tree,AST)是
一种用来描述程序代码语法结构的树形表示方式
,抽象语法树的
每一个节点都代表着程序代码中的一个语法结构
(SyntaxConstruct),例如
包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。
在Javac的源码中,语法分析过程由 com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树是以com.sun.tools.javac.tree.JCTree 类表示的。
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都 建立在抽象语法树之上。
2.填充符号表(enterTrees(*))
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构
,读者可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一 定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等各种形式)。
符号表中所登记的信息在编译的不同阶段都要被用到。
譬如在语义分析的过程中,符号表所登记的内容将用于语义检查 (如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。
在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果 存在的话)的顶级节点。
注解处理器
一组被称为“插入式注解处理器”的标准API,可以
提前至编译期对代码中的特定注解进行处理
,
从而影响到前端编译器的工作过程
。
我们可以
把插入式注解处理器看作是一组编译器的插件
,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。
譬如Java著名的编码效率工具Lombok ,它可以通过注解来实现自动产生 getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法,等等
语义分析与字节码生成
经过语法分析之后,编译器获得了程序代码的抽象语法树表示,
抽象语法树能够表示一个结构正 确的源程序,但无法保证源程序的语义是符合逻辑的。
而
语义分析的主要任务则是对结构上正确的源 程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等
假设有如下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 中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。
Javac在编译过程中,
语义分析过程可分为标注检查和数据及控制流分析两个步骤
,分别由图10-5 的attribute()和flow()方法完成。
1.标注检查(attribute(*))
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否 能够匹配,等等。
在标注检查中,还会顺便进行 一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施 之一(代码优化几乎都在即时编译器中进行)。
例: int a = 1 + 2;
在抽象语法树上仍然能看到字面量“1”“2”和操作符“+”号,但是在经过常量折叠优化之后,它们 将会被折叠为字面量“3”,如图10-7所示,这个插入式表达式(Infix Expression)的值已经在语法树上 标注出来了(ConstantExpressionValue:3)。
由于编译期间进行了常量折叠,所以在代码里面定义“a=1+2”比起直接定义“a=3”来,并不会增加程序运行期哪怕仅仅一个处理器时钟周期的处理工作量。
标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr类和 com.sun.tools.javac.comp.Check类。
2.数据及控制流分析(flow(*))
数据流分析和控制流分析是
对程序上下文逻辑更进一步的验证
,它
可以检查出诸如程序局部变量 在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问 题。
编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的
, 但
校验范围会有所区别
,
有一些校验项只有在编译期或运行期才能进行
。
// 方法一带有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文件结构的讲解我们已经知道,局部变量与类的字段(实例变量、类变量)的存储是 有显著差别的,
局部变量在常量池中并没有CONSTANT_Fieldref_info的符号引用
,自然就不可能存储 有访问标志(access_flags)的信息,甚至可能连变量名称都不一定会被保留下来(这取决于编译时的 编译器的参数选项),自然在Class文件中就不可能知道一个局部变量是不是被声明为final了。
因此,
可以肯定地推断出把局部变量声明为final,对运行期是完全没有影响的,变量的不变性仅仅由Javac编 译器在编译期间来保障
,这就是一个只能在编译期而不能在运行期中检查的例子。
在Javac的源码中, 数据及控制流分析的入口是图10-5中的flow()方法,具体操作由 com.sun.tools.javac.comp.Flow类来完成。
3.解语法糖
语法糖(Syntactic Sugar),也称糖衣语法,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响, 但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序的可读性,从而 减少程序代码出错的机会。
在Javac的源码中,解语法糖的过程由desugar()方法触发,在com.sun.tools.javac.comp.TransTypes类 和com.sun.tools.javac.comp.Lower类中完成。
4.字节码生成
字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。
字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如前文多次登场的实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语 法树之中的。请注意这里的实例构造器并不等同于默认构造函数,
如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private或)与当前 类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
<init>()和<clinit>()这两个构造器的产生实际上是一种代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造器,
<clinit>()方法中无须调用父类的<clinit>()方法
,
Java虚拟机会自动保证父类构造器的正确执行,但在<clinit>()方法中经常会生成调用java.lang.Object的<init>()方法的代码
)等操作收敛到<init>()和 <clinit>()方法之中,并且保证无论源码中出现的顺序如何,都一定是按
先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行
,上面所述的动作由Gen::normalizeDefs()方法来实现。
除了生成构造器以外,还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 5)的append()操 作,等等。
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到 com.sun.tools.javac.jvm.ClassWriter类手上,由这个类的writeClass()方法输出字节码,生成最终的Class 文件,到此,整个编译过程宣告结束。
Java语法糖的味道
泛型
泛型的本质是
参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用
,即
可以将操作的数据类型指定为方法签名中的一种特殊参数
,
这种参数类型能够用在类、接口 和方法的创建中,分别构成泛型类、泛型接口和泛型方法
。
泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。
1.Java与C#的泛型
Java选择的泛型实现方式叫作“
类型擦除式泛型
”(Type Erasure Generics),而C#选择的泛型实现 方式是“具现化式泛型”(Reified Generics)。
C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List<int>与 List<String>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。
Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换 为原来的裸类型(Raw Type)了,并且在相应的地方插入了强制 转型代码,因此对于运行期的Java语言来说,ArrayList<Integer>与ArrayList<String>其实是同一个类型。
// Java中不支持的泛型用法
public class TypeErasureGenerics<E> {
public void doSomething(Object item) {
if (item instanceof E) { // 不合法,无法对泛型进行实例判断
...
}
E newItem = new E(); // 不合法,无法使用泛型创建对象
E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组
}
}
Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛 型,而它的唯一优势是在于实现这种泛型的影响范围上:
擦除式泛型的实现几乎只需要在Javac编译器 上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以 直接运行在Java 5.0之上。
2.泛型的历史背景
在没有泛型的时代,由于Java中的数组是支持协变(Covariant)的[6],对应的集合类 也可以存入不同类型的元素,这样的代码尽管不提倡,但是完全可以正常编译成 Class文件。
Object[] array = new String[10];
array[0] = 10; // 编译期不会有问题,运行时会报错
ArrayList things = new ArrayList();
things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");
为了保证这些编译出来的Class文件可以在Java 5.0引入泛型之后继续运行,设计者面前大体上有两 条路可以选择:
1)需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本 的新类型。
2)直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于 已有类型的泛型版。(JAVA选择泛型擦除实现)
3.类型擦除
“裸类型”(Raw Type):裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)
// 裸类型赋值
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;
选择:
一种是在运行期由Java虚拟机来自动 地、真实地构造出ArrayList这样的类型,并且自动实现从ArrayList派生自ArrayList 的继承关系来满足裸类型的定义;
另外一种是索性简单粗暴地直接在编译时把ArrayList还原 回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令(实际选择)
// 泛型擦除前
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?"));
}
擦除式泛型的缺陷
首先,使用
擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持
又成了麻烦
// 非法操作
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;
实现:有了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做,导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因。
第二,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦
/*
写一个泛型版本的从List到数组的转换方法,由于不能从List中取得参数化类型T,所以
不得不从一个额外参数中再传入一个数组的组件类型进去
*/
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)");
}
}
// 可以运行
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>());
}
}
之所以第二次能编译和执行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。
介绍Class文件方法表 (method_info)的数据结构时曾经提到过,
方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择
,但是在Class文件格式之中,只要描述符不是完全 一致的两个方法就可以共存。也就是说
两个方法如果有相同的名称和特征签名,但返回值不同,那它 们也是可以合法地共存于一个Class文件中的。
由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生 影响并带来新的需求,如
在泛型类中如何获取传入的参数化类型
等。
所以JCP组织对《Java虚拟机规 范》做出了相应的修改,引入了诸如
Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题
,
Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名
,
这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。
修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。
从Signature属性的出现我们还可以得出结论,
擦除法所谓的擦除,仅仅是对方法的Code属 性中的字节码进行擦除,实际上元数据中还是保留了泛型信息
,这也是我们在编码时能通过反射手段 取得参数化类型的根本依据。
自动装箱、拆箱与遍历循环
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);
}
编译后:自动装箱、拆箱在编译之后被转化 成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代 码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看 变长参数,它在调用的时候变成了一个数组类型的参数
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);
}
自动装箱的陷阱:
包装类 的“==”运算在不遇到算术运算的情况下不会自动拆箱
,以及它们
equals()方法不处理数据转型
的关系
public class Main {
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
}
}
条件编译
Java语言之中并没有使用预处理器,因为Java语言天然的编译方式(编译器并非一个个 地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个 文件之间能够互相提供符号信息)就无须使用到预处理器。
Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。
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("");
}
}
Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把 分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。
由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写 在方法体内部,因此它
只能实现语句基本块(Block)级别的条件编译
,而没有办法实现根据条件调整 整个Java类的结构。
除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java语言 还有不少其他的语法糖,如内部类、枚举类、断言语句、数值字面量、对枚举和字符串的switch支持、try语句中定义和关闭资源(这3个从JDK 7开始支持)、Lambda表达式(从JDK 8开始支持, Lambda不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作),等等。