概述
前端编译器 把*.java文件转变成*.class文件,如JDK的Javac、Eclipse JDT的增量式编译器(ECJ)。
即时编译器(JIT,Just In Time Compiler),运行期把字节码转变成本地机器码,如HotSpot的C1、C2编译器。
提前编译器(AOT,Ahead Of Time Compiler),直接把程序编译成与目标机器指令集相关二进制代码,如JDK的Jaotc、GNU Compiler for the Java(GCJ)。
Javac编译器
Javac的源码以及调试
分析源码是了解一项技术的实现内幕最彻底的手段,Javac编译器不像HotSpot虚拟机那样使用C++语言(包含少量C语言)实现,它本身就是一个由Java语言编写的程序,这为纯Java的程序员了解它的编译过程带来了很大的便利。
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程,包括
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段。
- 分析与字节码生成过程,包括:
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如图10-4所示。
解析与填充符号表
词法、语法分析
词法分析
将源码中的字符流转变为标记(Token)集合的过程。关键字、变量名、运算符等都可作为标记。比如下面一行代码:
1 | int a = b + 2; |
在字符流中,关键字 int 由三个字符组成,但它是一个独立的标记,不可再分。
该过程有点类似“分词”的过程。虽然这些代码我们一眼就能认出来,但编译器要逐个分析过之后才能知道。
语法分析
根据上面的标记序列构造抽象语法树的过程。
抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方法,每个节点都代表程序代码中的一个语法结构(Syntax Construct),比如包、类型、修饰符等。
通俗来讲,词法分析就是对源码文件做分词,语法分析就是检查源码文件是否符合 Java 语法。
填充符号表
符号表(Symbol Table)是一种数据结构,它由一组符号地址和符号信息组成(类似“键-值”对的形式)。
符号由抽象类 com.sun.tools.javac.code.Symbol 表示,Symbol 类有多种扩展类型的符号,比如 ClassSymbol 表示类、MethodSymbol 表示方法等。
符号表记录的信息在编译的不同阶段都要用到,如:
用于语义检查和产生中间代码;
在目标代码生成阶段,符号表是对符号名进行地址分配的依据。
这个阶段主要是根据上一步生成的抽象语法树列表完成符号填充,返回填充了类中所有符号的抽象语法树列表。
注解处理器
JDK 5 提供了注解(Annotations)支持,JDK 6 提供了“插入式注解处理器”,可以在「编译期」对代码中的特定注解进行处理,从而影响前端编译器的工作过程。比如Lombok。
语义分析与字节码生成
抽象语法树能表示一个结构正确的源程序,却无法保证语义是否符合逻辑。
而语义分析就对语法正确的源程序结合上下文进行相关性质的检查(类型检查、控制流检查等)。比如:
1 | int a = 1; |
Javac 在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤。
标注检查
检查内容:变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等。
常量折叠
该过程中,还会进行一个常量折叠(Constant Folding)的代码优化。
比如,我们在代码中定义如下:
1 | int a = 1 + 2; |
在抽象语法树上仍能看到字面量 “1”、”2” 和操作符 “+”,但经过常量折叠优化后,在语法树上将会被标注为 “3”。
数据及控制流分析
主要检查内容:
局部变量使用前是否赋值
方法的每条路径是否有返回值
受检查异常是否被正确处理等
解语法糖
语法糖(Syntactic Sugar):也称糖衣语法,指的是在计算机语言中添加某种语法,该语法对语言的编译结果和功能并没有实际影响,却能更方便程序猿使用该语言。
PS: 就是让我们写代码更舒服的语法,像吃了糖一样甜。
Java 中常见的语法糖有泛型、变长参数、自动装箱拆箱等。
JVM 其实并不支持这些语法,它们在编译阶段要被还原成原始的基础语法结构。该过程就称为解语法糖(打回原形)。
字节码生成
Javac 编译过程的最后一个阶段。主要是把前面各个步骤生成的信息转换为字节码指令写入磁盘中。
此外,编译器还进行了少量的代码添加和转换工作。比如实例构造器<init>() 和类构造器<clinit>() 方法就是在这个阶段被添加到语法树的。这里的实例构造器并不等同于默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
<init>()和<clinit>()这两个构造器的产生实际上是一种代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()和<clinit>()方法之中,并且保证无论源码中出现的顺序如何,都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述的动作由Gen::normalizeDefs()方法来实现。除了生成构造器以外,还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 5)的append()操作,等等。
Java语法糖的味道
泛型
泛型这个概念大家应该都不陌生,Java 是从 5.0 开始支持泛型的。
由于历史原因,Java 使用的是“类型擦除式泛型(Type Erasure Generics)”,也就是泛型只会在源码中存在,编译后的字节码文件中,全部泛型会被替换为原先的裸类型(Raw Type)。
因此,在运行期间 List<String> 和 List<Integer> 其实是同一个类型。例如:
1 | public class GenericTest { |
经编译器擦除类型后:
1 | public class GenericTest { |
类型擦除是有缺点的,比如:由于类型擦除,会将泛型的类型转为 Object,但是 int、long 等原始数据类型无法与 Object 互转,这就导致了泛型不能支持原始数据类型。进而引起了使用包装类(Integer、Long 等)带来的拆箱、装箱问题。
运行期无法获取泛型信息。
自动装箱、拆箱与遍历
遍历代码示例
1 | public class GenericTest { |
反编译版本 1:
1 | public class GenericTest { |
反编译版本 2:
1 | public class GenericTest { |
不同的反编译器得出的结果可能有所不同,这里找了两个版本对比分析。
从上面两个版本的反编译结果可以看出:Arrays.asList() 方法其实创建了一个数组,而增强 for 循环实际调用了迭代器 Iterator。
自动拆装箱代码示例
1 | public class GenericTest { |
类似代码估计大家都见过,毕竟有些面试题就喜欢这么搞,这些语句的输出结果是什么呢?
我们先看反编译后的代码:
1 | public class GenericTest { |
可以看到,编译器对上述代码做了自动拆装箱的操作。其中值得注意的是:
包装类的 “==” 运算不遇到算术运算时,不会自动拆箱。
equals() 方法不会处理数据转型。
此外,还有个值得玩味的地方:为何 c==d 是 true、而 e==f 是 false 呢?似乎也是个考点。
这就要查看 Integer 类的 valueOf() 方法的源码了:
1 | static final int low = -128; |
可以看到 Integer 内部使用了缓存 IntegerCache:其最小值为 -128,最大值默认是 127。因此,[-128, 127] 范围内的数字都会直接从缓存获取。
而且,该缓存的最大值是可以修改的,可以使用如下 VM 参数将其修改为 500:
-XX:AutoBoxCacheMax=500
增加该参数后,上述 e==f 也是 true 了。
条件编译
Java语言可以进行条件编译,方法就是使用条件为常量的if语句。如下代码所示,该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括“System.out.println(“block 1”);”一条语句,并不会包含if语句及另外一个分子中的“System.out.println(“block 2”);”
1 | public static void main(String[] args) { |
该代码编译后Class文件的反编译结果:
1 | public static void main(String[] args) { |