第 10 章 前端编译与优化
从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰,这个过程犹如一场没有终点,永不停歇的F1方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。
10.1 概述
在Java中有三种编译器
- 前端编译器,负责把
*.java
文件转变成*.class
文件,例如,JDK的javac
- 即时编译器,在运行期把字节码转变成本地机器码,例如,HotSpot虚拟机中的C1、C2等编译器
- 提前编译器:直接把程序编译成与目标机器指令集相关的二进制代码,例如,JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET
Javac这类的前端编译器对代码的运行效率几乎没有任何优化,Java虚拟机设计团队选择把对性能的优化全部集中在运行期的即时编译器中,这样能够让那些不是由Javac生成的Class文件(JRuby、Groovy等语言的Class文件)也同时能享受到编译器优化措施带来的性能红利。但是我们可以这么说,Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑了程序员的编码效率和语言使用者的幸福感的提升。因为前端编译器封装了大量的语法糖以支持新的语法特性来降低程序员的编码复杂度、提高编码效率。
10.2 Javac编译器
Javac编译器不像HotSpot虚拟机那样使用C++语言实现,它本身就是一个由Java语言编写的程序
10.2.1 Javac的源码与调试
虛拟机规范严格定义了Class文件的格式,但是并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的。
从javac代码的总体结构来看,编译过程大致可以分为1个准备阶段和3个处理阶段
- 准备阶段:初始化插入式注解处理器
- 解析与填充符号表阶段,包括
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树
- 填充符号表:产生符号地址和符号信息
- 插入式注解处理器的执行阶段
- 分析与字节码生成阶段
- 标注检查。对语法的静态信息进行检查
- 数据流及控制流分析,对程序动态运行过程进行检查
- 解语法糖,将简化代码编写的语法糖还原为原有的形式
- 字节码生成,将前面各个步骤生成的语法树和符号表等信息转化为字节码
上述步骤如下图,由于在处理插入式注解的时候,会对语法树进行一定的修改,所以编译器会重新回到解析及填充符号表的过程重新处理
javac的主体代码逻辑如下
10.2.2 解析与填充符号表
解析步骤由图10-5中的parseFiles()
方法完成,解析步骤包括了词法分析和语法分析两个过程。
10.2.2.1 词法、语法分析
词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,如int a = b + 2
这句代码包含了6个标记,分别是int
、a
、=
、b
、+
、2
语法分析是根据 Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree, AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。
经过这个步骤之后,编译器就基本不会再对源码文件进行操作子,后续的操作都建立在抽象语法树之上。
完成了语法分析和词法分析之后,下一步就是填充符号表的过程;也就是图10.5中enterTrees()
方法所做的事情。符号表 (Symbol Table)是由一组符号地址和符号信息构成的表格,符号表中所登记的信息在编译的后续多个阶段都要用到。
10.2.3 注解处理器
注解在设计上原本与普通的Java代码一样,是在运行期间发挥作用的。但在JDK1.6中,提供了一组插入式注解处理器的标准API,可以提前在编译期对特定注解进行处理,从而影响前端编译器的工作过程。
我们可以把插入式注解处理器看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。
有了编译器注解处理的标准API后,程序员的代码才可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情。譬如Java著名的编码效率工具Lombok
,它可以通过注解来实现自动产生getter/setter
方法、进行空置检查、生成受查异常表、产生equal()
和hashcode()
方法等,来帮助开发人员消除冗长的Java代码
在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()
方法中完成的,而它的执行过程则是在processAnnotations()
方法中完成的。
10.2.4 语义分析与字节码生成
语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查、控制流检查、数据流检查,等等。javac中的语义分析过程可分为标注检查和数据及控制流分析两个步骤。对应图10.5中的attribute()
和flow()
方法
10.2.4.1 标注检查
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等等。在标注检查过程中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化
10.2.4.2 数据及控制流分析
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译时期的数据流分析和控制流分析与类加载时的数据及控制流分析的目的基本上是一致的。
10.2.4.3 解语法糖
语法糖(Syntactic Sugar)指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是却能更方便程序员使用。通常来说,使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。在javac中由图10.5中的desuger
触发
10.2.4.4 字节码生成
字节码生成是javac编译过程的最后一个阶段,在javac源码里面由com.suntools.javac.jvm.Gen
类来完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作
例如,前面章节中多次提到的实例构造器<init>()
和类构造器<clinit>()
就是在这个阶段添加到语法树之中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块、变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()
和<clinit>()
方法之中。除了生成构造器以外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBuffer
的append
操作等等
10.3 Java语法糖的味道
几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。语法糖可以看做是编译器实现的一些“小把戏”这些“小把戏”可能会使得效率“大提升”。
10.3.1 泛型
泛型的本质是参数化类型(Parameterized Type)的应用,也就是说所操作的__数据类型__被__指定__为一个__参数__。这种参数类型可以用在类、接口和方法的创建中,分別称为泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言地类型系统及抽象能力。
10.3.1.1 Java与C#的泛型
Java选择的泛型实现方式叫做“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现代化式泛型”(Reified Generics)。Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并在需要的地方插入了强制类型转换代码;可以说,对于Java来说,在运行时,ArrayList<Integer>
和ArrayList<String>
其实是同一类型。而对C#来说,无论在编译期还是运行时,这都是两种类型。
擦除式泛型的缺点是性能,而优点是实现起来较为简单,只需要在javac编译器上做出改进即可,不需要改动字节码,不需要改动Java虚拟机,也保证了以前没有泛型的库可以直接运行。
10.3.1.2 类型擦除
Java的类型擦除相对暴力,以ArrayList<Integer>
为例,它直接在编译时把ArrayList<Integer>
还原为了ArrayList
,只在元素访问、修改时自动插入一些强制类型转换和检查指令。下面我们举例说明
先看一段普通的使用泛型容器Map<T,R>
的代码
1 | public static void main(String[] args){ |
我们编译这段代码之后,再反编译,就会得到这样的代码,这个是泛型擦除后的
1 | public static void main(String[] args){ |
如前文所说,只是还原为了裸类型,并且在访问时加入了强制类型转换而已。
10.3.1.3 类型擦除的缺点
这样的泛型实现形式具有很大的局限性,下面举3个例子
首先,使用擦除法直接导致了无法支持原始类型(Primitive Types,例如:Integer
与int
,支持Integer
而不支持int
)数据。例如下面的代码是无法通过编译的。
1 | ArrayList<int> list = new ArrayList<int>(); |
对此,Java给出的解决方式是:既然无法支持原始类型,那么直接用ArrayList<Integer>
就好了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,严重影响了java泛型的性能
第二,运行期无法取得泛型类型信息,导致好多看似合理的用法都不支持,如下所示,下面代码都是无法通过编译的
1 | public class TypeErasure<E>{ |
最后,擦除法一定程度上破坏了面向对象思想,如下
1 | public class Example{ |
这段代码也是无法通过编译的,因为擦除之后,两个方法的签名变得一模一样。
10.3.1.4 未来的泛型
Oracle启动了Valhalla语言改进项目,以改进目前泛型的缺陷
10.3.2 自动装箱、自动拆箱、遍历循环以及变长参数
自动装箱、拆箱在编译后被转化成了对应的包装和还原方法
遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable
接口的原因
变长参数则是在调用时变成了一个数组类型的参数,在变长参数出现之前,也的确是用数组来实现类似功能
下面举一个例子来同时说明这几件事
1 | public static void main(String[] args){ |
我们编译这段代码之后,再反编译,就会得到这样的代码,这个是去掉语法糖之后的
1 | public static void main(String[] args){ |
此外,鉴于包装类的==
运算在不遇到算术运算的情况下不会自动拆箱,以及它们的equal()
方法不处理数据转型的关系,因此建议实际编码中尽量少使用自动拆装箱
10.3.3 其他语法糖
Java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、数值字面量、对枚举和字符串的switch
支持、try
语句中定义和关闭资源、lambda
表达式等