因为 Java 代码到机器码之间还存在着字节码,所以 Java 语言的『编译期』其实是一段不确定的过程。它可能是指一个前端编译器把 *.java
文件转变成 *.class
文件的过程;也可能是指虚拟机的运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用提前编译器(AOT 编译器,Ahead Of Time Compiler)直接把 *.java
文件编译成机器码的过程。
概述
下面列举了这 3 类编译过程中一些比较有代表性的编译器。
- 前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。
- JIT 编译器:HotSpot VM 的 C1、C2 编译器。
- AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
这 3 类中最符合大家对 Java 程序编译认知的应该是第一类。在限定了编译范围之后,我们需要放宽『代码优化』的定义,因为 Javac 这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由 Javac 产生的 Class 文件(如 JRuby、Groovy 等语言的 Class 文件)也同样能享受到编译器优化所带来的好处。不过 Javac 也做了许多针对 Java 语言编码过程的优化来改善程序员的编码风格和提高编码效率。许多新生的 Java 语法特性,都是靠编译器的『语法糖』来实现,而不是依赖虚拟机的底层改进来支持。总的来说,前端编译器在编译期的优化过程对于程序编码来说更重要,即时编译器在运行期的优化过程对于程序运行来说更重要。
编译期优化
Javac 编译器
了解一项技术的实现内幕的最有效的手段是分析源码。Javac 编译器不像 HotSpot 虚拟机那样使用 C++ 语言实现,它本身就是一个由 Java 语言编写的程序,这为纯 Java 的程序员了解它的编译过程带来了很大的便利。
源码下载
去 OpenJDK 的官网下载源码。
openjdk 8 源码目录
创建一个 javac-source-code 项目,将 src/share/classes/com 目录下的所有内容拷贝至 src 目录。
执行 main 方法
导入 Javac 的源码后,就可以运行 com.sun.tools.javac.Main 的 main 方法来执行编译了,这与直接在命令行中使用 Javac 没有什么区别。Javac 主函数入口
运行 main 方法,由于我们没有指定要编译的源代码路径,控制台会输出下面的内容。
新建一个 HelloWorld.java 文件,并在启动配置中加入这个文件的绝对路径。
再次运行 main 方法,最终会在 HelloWorld.java 的同级目录下生成 HelloWorld.class 文件。
加断点
在 Main.java 中打上断点,然后发现不管怎么设置,调试时总会进入 JDK 自带的 Main.java 方法,没有进入自己打断点的地方。
打开 Project Structure 页面(File->Project Structure),选中图中 Dependencies 选项,将 <Module source>
顺序调整到 JDK 之前。
再次调试就可以进入到项目中的断点了。
Javac 的编译过程
将一组源文件编译为一组相应的类文件的过程并不简单,但通常可以分为 三个过程:
- 解析与填充符号表过程
- 读取命令行上指定的源文件,将其解析为语法树,然后输出到符号表中。
- 注解处理过程
- 调用注解处理器。如果注解处理器生成了新的源文件,则重新编译,直到没有新文件创建为止。
- 分析和字节码生成过程
- 最后,分析语法树并将其转换为类文件。
Javac 的编译过程
Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler 类,上述 3 个过程的逻辑集中在这个类的 compile 和 compile2 方法中,其中主体代码如下图所示。整个编译最关键的处理就由图中标注的 8 个方法来完成。compile 代码片段
compile2 代码片段
语法糖
几乎各种语言都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高开发效率,或能提升语法的严谨性,或能减少编码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用含糖的语法,容易让程序员产生依赖,无法看清语法糖背后程序代码的真实面目。
泛型与类型擦除
泛型是 JDK 1.5 带来的一项新特性,它的本质是参数化类型(Parameterized Type)。也就是说,所操作的数据类型被指定为一个参数。这种参数可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
Java 语言中的泛型只在程序源码中存在,在编译后的字节码文件中就已经替换成原来的类型了,并且在相应的地方插入了强制类型转换代码。因此,对于运行期的 Java 语言来说,ArrayList<int>
与 ArrayList<String>
就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
示例 1
1 | public static void main(String[] args) { |
把这段 Java 代码编译成 Class 文件,然后利用反编译工具(jd-gui-1.4.2)进行反编译后,发现泛型不见了,程序又变回了原生类型(Raw Type)。反编译后
1 | public static void main(String[] args) { |
示例 2
1 | public static void main(String[] args) { |
反编译后
1 | public static void main(String[] args) { |
代码的输出结果为 true
。可以看出,新建的两个不同的 ArraysList<>
对象,编译之后都是 ArraysList
,没有了泛型信息。
自动装箱、拆箱与遍历循环
从纯技术的角度来讲,自动装箱、自动拆箱与遍历循环(Foreach 循环)这些语法糖,无论是实现上还是思想上都不能和上文介绍的泛型相比,两者的难度和深度都有很大差距。专门拿出一节来讲解它们只有一个理由:它们是 Java 语言里使用得最多的语法糖。
示例 3
1 | public static void main(String[] args) { |
反编译后
1 | public static void main(String[] args) { |
示例 3 中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖,我们一起来分析一下反编译后的代码。泛型就不必说了;自动装箱、拆箱在编译之后被转化成了对应的包装方法和还原方法,如本例中的 Integer.valueOf() 与 Integer.intValue() 方法;而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环要求被遍历的类实现 Iterable 接口的原因;最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。
条件编译
许多程序设计语言都提供了条件编译的途径,如 C、C++ 中使用预处理器指示符
#ifdef
来完成条件编译。C、C++ 的预处理器最初的任务是解决编译时的代码依赖关系(如常用的#include
预处理命令),而在 Java 语言之中并没有使用预处理器,因为 Java 语言的编译方式无须使用预处理器(编译器并非一个个地编译 Java 文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)。
那 Java 语言是否有办法实现条件编译呢?Java 语言当然也可以进行条件编译,方法就是使用条件是常量的 if 语句。
示例 4
1 | public class ConditionCompile { |
反编译后
1 |
|
如示例 4 所示,此代码中的 if 语句不同于其他 Java 代码,它在编译阶段就会被『运行』,生成的字节码之中只包括 System.out.println("block 1")
System.out.println("block 1")
两条语句,并不会包含 if 语句及另外一个分支中的 System.out.println("block 2")
System.out.println("block 4")
语句。
Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。由于这种条件编译的实现方式使用了 if 语句,所以它必须遵循最基本的 Java 语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个 Java 类的结构。
枚举
1 | public enum Week { |
使用 javap -p Week.class 反编译之后
1 | public final class jvm.javac.suger.Week extends java.lang.Enum<jvm.javac.suger.Week> { |
可以看到,字节码中并不存在 enum 类型,所有的枚举类都继承自 java.lang.Enum
这个类。 Javac 编译器将 Week 类改成了 final 类型,这样就不能被继承,同时还将它的构造方法改成了 private,这样就不能通过 new 的方式来新增 Week 类的实例。
编写一个注解处理器
实战目标
通过阅读 Javac 编译器的源码,我们知道编译器在把 Java 程序源码编译为字节码的时候,会对 Java 程序源码做各方面的检查校验。这些校验主要以程序『写得对不对』为出发点,虽然也有各种 Warning 的信息,但总体来讲还是较少去校验程序『写得好不好』。有鉴于此,业界出现了许多针对程序『写得好不好』的辅助校验工具,如 CheckStyle、FindBug、Klocwork 等。这些代码校验工具有一些是基于 Java 的源码进行校验,还有一些是通过扫描字节码来完成,在本节的实战中,我们将会使用注解处理器 API 来编写一款拥有自己编码风格的校验工具:NameCheckProcessor。
当然,由于我们的实战都是为了学习和演示技术原理,而不是为了做出一款能媲美 CheckStyle 等工具的产品来,所以 NameCheckProcessor 的目标也仅定为对 Java 程序命名进行检查,根据《Java语言规范》中的要求,Java 程序命名应当符合下列格式的书写规范:
- 类(或接口):符合驼式命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 字段:
- 类或实例变量:符合驼式命名法,首字母小写。
- 常量:要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
上文提到的驼式命名法(Camel Case Name),正如它的名称所表示的那样,是指混合使用大小写字母来分割构成变量或函数的名字,犹如驼峰一般,这是当前 Java 语言中主流的命名规范,我们的实战目标就是为 Javac 编译器添加一个额外的功能,在编译程序时检查程序名是否符合上述对类(或接口)、方法、字段的命名要求(参照 JDK 中的 com.sun.tools.javac.processing.PrintingProcessor 类)。
代码实现
要通过注解处理器 API 实现一个编译器插件,首先需要了解这组 API 的一些基本知识。我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor,这个抽象类中有一个必须要覆盖的 process 方法,它是 Javac 编译器在执行注解处理器代码时要调用的过程。我们可以从这个方法的第一个参数 annotations 中获取到此注解处理器所要处理的注解集合,从第二个参数 roundEnv 中访问到当前这个 Round 中的语法树节点,每个语法树节点在这里表示为一个 Element。
除了 process 方法的入参之外,还有一个很常用的实例变量 processingEnv,它是 AbstractProcessor 中的一个 protected 变量,在注解处理器初始化的时候创建,继承了 AbstractProcessor 的类可以直接访问到它。它代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量。
注解处理器还有两个可以配合使用的 Annotations:@SupportedAnnotationTypes 和 @SupportedSourceVersion,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号 * 作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的 Java 代码。
每一个注解处理器在运行的时候都是单例的,如果不需要改变语法树的内容,process 方法就可以返回一个值为 false 的布尔值,通知编译器这个 Round 中的代码未发生变化,无须构造新的 JavaCompiler 实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此 process 方法的返回值为 false。
NameCheckProcessor
1 | package jvm.javac.processor; |
从上面代码可以看出,NameCheckProcessor 能处理基于 JDK 1.8 的源码,它不限于特定的注解,对任何注解都感兴趣,而在 process 方法中是把当前 Round 中的每一个 RootElement 传递到一个名为 NameChecker 的检查器中执行名称检查逻辑,NameChecker 的代码如代码如下所示。NameChecker
1 | package jvm.javac.processor; |
NameChecker 的代码看起来有点长,但实际上注释占了很大一部分,其实即使算上注释也不到 180 行。它通过一个继承于 javax.lang.model.util.ElementScanner8 的 NameCheckScanner 类,以 Visitor 模式来完成对语法树的遍历,分别执行 visitType、visitVariable 和 visitExecutable 方法来访问类、字段和方法,这 3 个 visit 方法对各自的命名规则做相应的检查,checkCamelCase 与 checkAllCaps 方法则用于实现驼式命名法和全大写命名规则的检查。
整个注解处理器只需 NameCheckProcessor 和 NameChecker 两个类就可以全部完成,为了验证我们的实战成果,我们编写一个反面教材代码,其中的每一个类、方法及字段的命名都存在问题,但是使用普通的 Javac 编译这段代码时不会提示任何一个 Warning 信息。
BADLY_NAMED_CODE
1 | public class BADLY_NAMED_CODE { |
运行与测试
我们可以通过 Javac 命令的 -processor 参数来执行编译时需要附带的注解处理器,如果有多个注解处理器的话,用逗号分隔。
其它应用案例
NameCheckProcessor 的实战例子只演示了 JSR-269 嵌入式注解处理器 API 中的一部分功能,基于这组 API 支持的项目还有用于校验 Hibernate 标签使用正确性的 Hibernate Validator Annotation Processor(本质上与 NameCheckProcessor 所做的事情差不多)、自动为字段生成 getter 和 setter 方法的 Project Lombok(根据已有元素生成新的语法树元素)等,有兴趣的读者可以参阅它们的官方文档。
运行期优化
鉴于篇幅限制,运行期优化的相关内容我们留到 下一篇 中再进行介绍。