前端编译与优化
编译器概述
Java中的编译器主要分为三种:
- 前端编译器:把*.java文件转变为*.class文件的过程。代表有JDK的Javac
- 即时编译器:(常称为JIT编译器,Just In Time Compiler)运行期把字节码转变为本地机器的过程。代表有HotSpot虚拟机的C1,C2编译器
- 提前编译器:(常称为AOT编译器,Ahead Of Time Compiler)直接把程序编译成目标机器指令相关的二进制代码的过程
Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则支撑着程序员的编码效率和语言使用者的幸福感的提高
Javac编译器
一、Javac工作流程
从Javac代码的总体结构来看,编译过程大致分为1个准备阶段和3个处理阶段:
- 准备阶段:初始化插入式注解处理器
- 解析与填充符号表过程:
- 词法、语法分析。将源代码的字节流符号转变为标记集合,构造出抽象语法树
- 填充符号表。产生符号地址和符号信息
- 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段。
- 分析与字节码生成阶段
- 标注检查。对语法的静态信息进行检查
- 数据流和控制流分析。对程序动态运行过程进行检查
- 解语法糖。将简化代码编写的语法糖还原成原有的形式
- 字节码生成。将前面各个步骤所生成的信息转化为字节码
在上述3个处理阶段里,执行插入式注解时又可能产生新的符号,如果有的符号产生,必须转回到解析、填充符号表的过程中重新处理这些新符号,总体来看三者之间的关系如下图所示:
上述3个过程的逻辑代码集中在com.sun.tools.javac.main.JavaCompiler类中的compiler()和compiler2()方法中:
二、解析与填充符号表
1、词法、语法解析
该部分内容在上图中parseFiles()方法中完成。
词法分析时将源代码的字符流转变为符号(Token)集合的过程,单个字符是程序编写时的最小元素,但是标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如int a = b+2;
,这句话中就包含了6个标记,分别是int、a、=、b、+、2.词法分析过程由com.sun.tools.javac.parser.Scanner类实现
语法分析是根据标记序列构造抽象语法树的过程。抽象语法树(Abstract Syntax Tree,AST)是一种描述程序语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码的一种语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。Java源码中,语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段的抽象语法树是以com.sun.tools.javac.tree.JCTree类来表示。后续的操作都在抽象语法树上进行。
2、填充符号表
对符号表的填充过程在enterTree()方法中完成。符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,可以类比成哈希表,但也不一定是哈希表。符号表中的信息在编译的不同阶段都要被用到,例如在语义分析阶段中,符号表所登录的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址1分配的直接依据。符号表填充的过程由com.sum.tools.javac.comp.Enter类中实现。
三、注解处理器
插入式注解处理器可以看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。程序员可以使用插入式注解处理器来实现许多原本只能通过编码中人工完成的事情。在Javac源码中,插入式注解处理器的初始化在initPorcessAnnotations()方法中完成,而它的执行过程在porcessAnnotations()方法中完成。
四、语义分析与字节码生产
经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是否符合逻辑。而语义分析的主要任务则是对结构正确的源程序进行上下文相关性检查,譬如进行类型检查、控制流程检查、数据流检查等等。
Javac在编译过程中,语义分析过程可分为标注检查和数据及控制流程分析两个步骤,分别由atrribute()方法和flow()方法完成
1、标注检查
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配。在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施之一。
例如这个变量定义int a = 1 + 2;
,在抽象语法树上仍然能够看到字面量“1” “2”和操作符“+”号,但是在经过折叠优化之后,它们将被折叠为字面量“3”。
标注检查步骤在Javac源码中的实现类为com.sum.tools.javac.comp.Attr类和com.sum.tools.javac.comp.Check类
2、数据及控制流分析
数据分析和控制流分析是对程序上下文逻辑进行更进一步的验证,他可以检验出诸如程序局部变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受查异常都可以被正确处理
数据及控制流分析具体操作由com.sum.tools.javac.comp.Flow类的flow()方法来完成
3、解语法糖
语法糖(Syntactic Sugar),也被称为糖衣语法,是由英国计算机科学家Peter J. Landin发明的一种编程术语,指的是在计算机语言中添加某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。
通常使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会
Java中常见的语法糖包括泛型、变长参数、自动装箱拆箱等等。Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原为原始的基础语法结构,这个过程就叫做解语法糖。解语法糖的过程由desupar()方法触发,在com.sum.tools.javac.comp.TransTypes类和com.sum.tools.javac.comp.Lower类中完成
4、字节码生成
字节码生成是Javac变异的最后阶段,在Javac源码中由com.sum.tools.javac.comp.Gen类完成。
字节码生成阶段不仅仅是把前面的各个步骤所生成的信息(语法树、符号表)转化为字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。例如前面多次登场的实例构造函数<init>()
方法和类构造器<clinit>()
方法就在是在这个阶段添加到语法树中的。
<init>()
方法和<clinit>()
这两个构造器的产生实际上是一种代码收敛的过程,编译器会把语句块(对于实例构造器而言是"{}"块,对于类构造器而言是"static{}"块)、 变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()
方法和<clinit>()
方法之中,并且保证无论源码中出现的顺序如向,都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述的动作由Gen::normalizeDefs()方法来实现。除了生成构造器以外,还有其他时些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuider 或Stringbuffer 的append操作,等。
com.sum.tools.javac.comp.ClassWriter类的writeClass()方法用于输出字节码
Java语法糖
上面已经介绍了语法糖是什么,有什么作用,下面我们来看看几个常用的语法糖是如何实现的
一、泛型
泛型的本质是参数化类型(parameterized Type)或者参数化多态(parameterized Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力
Java和C#中都有泛型,但是由于对于遗留代码的兼容性问题,Java的泛型选择和C#不同的实现方式,下面来介绍一下这两种方式:
- Java选择的泛型实现方式叫做“类型擦除式泛型”(Type Erasure Generics),Java中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的地方插入了强制转换类型代码,因此对于运行期的Java语言来说,
ArrayList<Integer>
和ArrayList<String>
其实是同一种类型 - 而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics),C#中的泛型无论在程序源码中、编译后的中间语言表示里边,亦或者是运行期的CLR中都是立即存在的,所以
ArrayList<Integer>
和ArrayList<String>
就是不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据
Java的类型擦除式泛型无论在使用效果还是运行效率上,几乎是全面落后于C#的具现化泛型,它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做改进即可,不需要改动字节码、不需要改动Java虚拟机,也帮保证以前没有使用泛型的库可以运行在Java 5.0之上。
需要注意的是,泛型擦除擦除的是字节码上的泛型信息,LocalVariableTypeTable中仍然保留了方法参数泛型的信息。
通过反射可以拿到方法参数中泛型的类型信息:
二、自动装箱、拆箱和遍历循环
下面以一个例子来说明自动装箱、拆箱和遍历循环等语法糖解语法糖后的结果:
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
方法的原因 - 变长参数在调用的时候变成了一个数组类型的参数。
三、条件编译
Java语言中的条件编译的实现,也是Java语言的一个语法糖,根据布尔常量值得真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower类中)完成。例如下面的代码:
public static void main(String[] args) {
if (true){
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
编译后的结果为:
public static void main(String[] var0) {
System.out.println("block 1");
}
该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成得字节码之中只包含“System.out.println(“block 1”);”,另外一条语句不会生成。
只有使用条件为常量得if语句才能达到上述效果。由于这个条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此他只能实现语句基本快(Block)级别的条件编译,而没办法实现根据条件调整整个Java类的结构。