说起Java语言的编译期,它可能是指编译器把Java源码文件转变为Class字节码文件的过程,也可能是指虚拟机在运行时把字节码转变为机器代码的过程(JIT编译器,Just In Time Compiler)。本章我们来讨论一下上面提到的第一类编译过程
Javac编译器
我们都直接或间接的使用过Javac编译器,它可以将Java源码文件编译为Class字节码文件。Javac做了许多针对Java语言编码过程的优化措施来提升编码风格和编码效率。Java中很多新的语法特性,并非直接靠虚拟机底层直接支持,而是通过编译器的语法糖来实现
Javac的编译过程大致分为3个过程:解析与填充符号表、插入式注解处理器的注解处理过程、分析与字节码生成
解析与填充符号表
(1)解析
解析包含词法分析与语法分析两个过程
词法分析是将源代码中的字符流转变为Token集合的过程。Token是编程过程中的最小元素,比如“int a = b + 1”这行代码一共包含了6个Token,分别是int、a、=、b、+、1
语法分析是根据Token序列构造抽象语法树(Abstract Syntax Tree,AST)的过程。抽象语法树是一种描述程序语法结构的树形表示形式。后续的操作都是建立在抽象语法树之上
(2)填充符号表
完成了解析之后,下一步就是填充符号表。符号表是一组符号地址和符号信息构成的表格。符号表中所记录的信息在编译的不同阶段都要用到。在语义分析中,符号表将用于语义检查和产生中间代码。在目标代码生成阶段,符号表将是对符号名进行地址分配的依据
注解处理器
在JDK1.5之后,Java语言提供了对注解的支持。在JDK1.6中,提供了一组插入式注解处理器的API。这组API可以在编译期读取、修改、添加抽象语法树中的任意元素。在此期间,如果抽象语法树被修改,编译器将重新回到解析与填充符号表的过程重新处理,直到抽象语法树没有再被修改为止。这组API的意义就在于,通过它可以以编程的方式干涉编译器的行为
语义分析与字节码生成
语法分析的结果,使编译器获得了抽象语法树。抽象语法树能够表示一个结构正确的程序抽象,但却无法保证程序符合逻辑。语义分析的作用就是结合上下文,对程序的逻辑进行审查
(1)标注检查
标注检查涉及的内容如:变量使用前是否被声明、给变量赋值的数据类型是否匹配等。其中有一个重要的动作称为常量折叠,比如有如下代码:
int a = 1 + 2;
经过常量折叠,与之等效的代码如下:
int a = 3;
也就是说,对于类似上述两段代码,在运行时效率是相同的。原因在于编译期已经进行过常量折叠
(2)数据及控制流分析
数据及控制流分析涉及的内容如:局部变量使用前是否被赋值、方法的每条路径是否都有返回值、是否所有的Check Exception都被正确处理等。编译期的数据及控制流分析与类加载(关于类类加载方面的内容,请参考本系列文章:类加载机制)时的数据及控制流分析的目的基本是一致的,但是对于特定的校验项只能在编译期或者加载时期进行
比如下面这两个方法,区别在于方法参数及方法体内局部变量是否被final修饰:
但是在编译后,两个方法却是一样的,final修饰符被去除:
原因在于:类变量(实例变量、静态变量)在常量池中有CONSTANT_Field_info符号引用(关于类文件结构方面的内容,请参考本系列文章:类文件结构),而局部变量没有,自然也就没有访问标志信息(access_flags),因此也就不会有变量不变性的信息。也就是说,变量不变性仅在编译期保证
(3)解语法糖
语法糖,是一种方便程序员使用,但是对功能没有影响的语法。Java中许多新的特性并非通过修改虚拟机底层来支持,而是通过语法糖来实现。比如:泛型、变长参数、自动装箱、拆箱等。这些特性在编译阶段被还原成简单的基础语法结构,这个过程就叫做解语法糖。关于语法糖的内容,后面再做介绍
(4)字节码生成
字节码生成是Javac编译过程的最后一个阶段,但这个阶段并不仅仅是把前面各步骤生成的信息转化为字节码并写入磁盘,编译器在此阶段还进行了少量的代码添加和转换工作
比如:实例构造器<init>()方法和类实例构造器<cinit>()方法就是在这个阶段被添加到语法树中的(这里的实例构造器并不是默认构造方法,如果程序中没有提供任何构造方法,那么编译器会在填充符号表阶段添加一个默认构造方法)。这两个构造器会将调用父类的实例构造器代码、变量的初始化(实例变量和类变量)代码、语句块(“{}”块和“static {}”块)代码按此顺序进行收敛
除此之外,还会进行一些代码的优化工作,比如将字符串的加操作替换为StringBuilder.append()等。之后生成最终的Class文件,至此整个编译过程完成
比如源码如下:
编译后:
语法糖
前面提到过,语法糖,是一种方便程序员使用,但是对功能没有影响的语法。Java中许多新的特性并非通过修改虚拟机来做底层支持,而是通过语法糖来实现。下面来举例说明:
泛型
在一些编程语言中(比如C#),泛型在源码以及编译后都是切实存在的,List<int>与List<String>就是两个不同的类型,这种称为真实泛型
但是在Java中,泛型仅存在于源码,经过编译后,泛型会被擦除(被替换为原生类型,并且在相应的地方插入了强制类型转换),这种称为伪泛型
比如源码如下:
编译后通过javap查看其反汇编代码:
红色框中创建map,但是并没有泛型信息,说明泛型被擦除
蓝色框中调用map.put()方法,参数类型都是Object,并没有泛型信息,说明泛型被擦除
绿色框中调用map.get()方法,参数及返回类型都是Object,并没有泛型信息,说明泛型被擦除。checkcast指令检查是否可进行类型转换
黄色框中,通过LocalVariableTypeTable、Signature属性记录原始的泛型信息,但这并不等于泛型信息没有被擦除(关于LocalVariableTypeTable和Signature的信息,可查看Java Virtual Machine Specification)。所谓擦除,仅仅是对方法Code属性中的字节码进行擦除,但是在元数据中依然保留了泛型信息
自动装箱、拆箱、可变参数、foreach循环
比如源码如下:
编译后通过javap查看其反汇编代码:
红色框中创建一个长度为3的Integer类型数组,用于Arrays.asList(T... t)方法的参数,说明可变参数实际是通过数组来实现的
蓝色框中分别将3个int类型参数转换为Integer,用于存入上一步创建的Integer类型数组,这就是自动装箱
绿色框中通过迭代器对list进行遍历,说明foreach循环是通过迭代器来实现的。这也是为什么foreach循环遍历的对象要求实现Iterable接口
黄色框中将每次遍历的Integer类型数据转换为int类型,这就是自动拆箱
除了上面介绍的这些,Java还有不少其他语法糖,如内部类、枚举、断言、针对枚举和字符串的switch语句、try语句中定义和关闭资源等。这些均可通过javap命令了解其本质
思维导图:
笔记6结束