概述
在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是 指一个前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是 指Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;还可能是指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
这3类编译过程里一些比较有代 表性的编译器产品:
- 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。
- 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
- 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
后面的讨论都限于第一类,即这种由前端编译器完成的编译行为。
但这类编译器实际上并不会做太多优化操作,主要的优化操作都放在虚拟机中。其原因在于JVM还可以运行由其他语言产生的Class文件(如JRuby,Groovy等),将优化操作放到虚拟机中可以使这些语言产生的Class文件也被优化。
但是,如果把“优化”的定义放宽,把对开 发阶段的优化也计算进来的话,Javac确实是做了许多针对Java语言编码过程的优化措施来降低程序员 的编码复杂度、提高编码效率。相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。
javac编译器
Javac编译器不像HotSpot虚拟机那样使用 C++语言(包含少量C语言)实现,它本身就是一个由Java语言编写的程序。
javac的源码和调试
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下:
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程
- 词法、语法分析:将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表:产生符号地址和符号信息。
- 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段。
- 分析与字节码生成过程
- 标注检查:对语法的静态信息进行检查。
- 数据流及控制流分析:对程序动态运行过程进行检查。
- 解语法糖:将简化代码编写的语法糖还原为原有的形式。
- 字节码生成:将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如下图所示。
我们可以把上述处理过程对应到代码中,Javac编译动作的入口是 com.sun.tools.javac.main.JavaCompiler
类,上述3个过程的代码逻辑集中在这个类的compile()
和compile2()
方法里,其中主体代码如下图所示,整个编译过程主要的处理由图中标注的8个方法来完成。
解析和填充符号表
解析过程由上图中的parseFiles()
方法来完成,解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤。
词法、语法分析
词法分析是将源代码的字符流转变为标记(Token)集合的过程。
语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一 种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个 语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都 建立在抽象语法树之上。
填充符号表
完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程。
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等各种形式)。
符号表中所登记的 信息在编译的不同阶段都要被用到。譬如在语义分析的过程中,符号表所登记的内容将用于语义检查 (如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。
注解处理器
JDK 5之后,Java语言提供了对注解(Annotations)的支持,注解在设计上原本是与普通的Java代码一样,都只会在程序运行期间发挥作用的。
但在JDK 6中又提出并通过了JSR-269提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。可以提前至编译期对代码中的特定注解进行处理, 从而影响到前端编译器的工作过程。
我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。
在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()
方法中完成的,而它 的执行过程则是在processAnnotations()
方法中完成。这个方法会判断是否还有新的注解处理器需要执 行,如果有的话,通过com.sun.tools.javac.processing.JavacProcessing-Environment
类的doProcessing()
方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。
语法分析与字节码生成
经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正 确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。
标注检查
Javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤。
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等,刚才3个变量定义的例子就属于标注检查的处理范畴。(所谓标注,可以理解为变量的相关检查)
在标注检查中,还会顺便进行 一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
例如:
1 | int a = 1+2; |
折叠后:
1 | int a = 3; |
(Java代码只是示例,实际上javac处理的使抽象语法树节点)
数据及控制流分析
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的,但校验范围会有所区别,有一些校验项只有在编译期或运行期才能进行。
解语法糖
语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程 术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。
Java在现代编程语言之中已经属于“低糖语言”,“低糖”的语法让Java程序实现相同功能的代码量往往高于其他语言。
Java中最常见的语法糖包括了前面提到过的泛型(其他语言中泛型并不一定都是语法糖实现,如 C#的泛型就是直接由CLR支持的)、变长参数、自动装箱拆箱,等等,Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
字节码生成
字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen
类来 完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如前文多次登场的实例构造器<init>()
方法和类构造器<clinit>()
方法就是在这个阶段被添加到语 法树之中的。
Java语法糖
泛型
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的 应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
Java与C#的泛型
Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现 方式是“具现化式泛型”(Reified Generics)。
在Java中,泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的地方插入了强制 转型代码。例如,对于运行期的Java语言来说,ArrayList<int>
与ArrayList<String>
其实是同一个类型。
而在C#中,泛型是实际存在于编译和运行期间的,即List<int>
与 List<string>
就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。
Java的泛型 确实在实际使用中会有一些限制,如果读者是一名C#开发人员,可能很难想象下面的代码清单中的Java代码都是不合法的。
1 | public class TypeErasureGenerics<E> { |
上面这些是Java泛型在编码阶段产生的不良影响。并且由于Java中的泛型可能需要频繁装箱拆箱,所以在性能上也比C#的泛型设计更加差。
而Java之所以如此设计泛型,实际原因在于兼容。擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。
泛型的历史背景
泛型思想早在C++语言的模板(Template)功能中就开始生根发芽。其由Martin Odersky着手开发。
Martin Odersky自己在采访自述中提到,进行Generic Java项目的过程中他受到了重 重约束,甚至多次让他感到沮丧,最紧、最难的约束来源于被迫要完全向后兼容无泛型Java,即保 证“二进制向后兼容性”(Binary Backwards Compatibility)。二进制向后兼容性是明确写入《Java语言 规范》中的对Java使用者的严肃承诺,譬如一个在JDK 1.2中编译出来的Class文件,必须保证能够在JDK 12乃至以后的版本中也能够正常运行。
这是Java泛型设计为这种模式的一个重要原因:兼容性。
为了保证这些编译出来的Class文件可以在Java 5.0引入泛型之后继续运行,设计者面前大体上有两 条路可以选择:
- 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本 的新类型。
- 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于 已有类型的泛型版。
在这个分叉路口,C#走了第一条路,添加了一组System.Collections.Generic的新容器,以前的 System.Collections以及System.Collections.Specialized容器类型继续存在。
但如果相同的选择出现在Java中就很可能不会是相同的结果了。Java并不是没有做过第一条路那样的技术决策,在JDK 1.2时,遗留代码规模 尚小,Java就引入过新的集合类,并且保留了旧集合类不动。这导致了直到现在标准类库中还有 Vector
(老)和ArrayList
(新)、有Hashtable
(老)和HashMap
(新)等两套容器代码并存,如果当 时再摆弄出像Vector
(老)、ArrayList
(新)、Vector<T>
(老但有泛型)、ArrayList<T>
(新且有泛型)这样的容器集合,可能叫骂声会比今天听到的更响更大。
类型擦除
由于Java选择了第二条 路,直接把已有的类型泛型化。
要让所有需要泛型化的已有类型,譬如ArrayList
,原地泛型化后变成 了ArrayList<T>
,而且保证以前直接用ArrayList
的代码在泛型新版本里必须还能继续用这同一个容 器,这就必须让所有泛型化的实例类型,譬如ArrayList<Integer>
、ArrayList<String>
这些全部自动成为 ArrayList
的子类型才能可以,否则类型转换就是不安全的。
由此就引出了“裸类型”(Raw Type)的概 念,裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type),只有这样,像代码清单中的赋值才是被系统允许的从子类到父类的安全转型。
1 | rrayList<Integer> ilist = new ArrayList<Integer>(); |
对于如何实现裸类型,有两种选择:
- 在运行期由Java虚拟机来自动 地、真实地构造出
ArrayList<Integer>
这样的类型,并且自动实现从ArrayList<Integer>
派生自ArrayList的继承关系来满足裸类型的定义; - 索性简单粗暴地直接在编译时把
ArrayList<Integer>
还原回ArrayList
,只在元素访问、修改时自动插入一些强制类型转换和检查指令。
显然Java采用后一种方式。
其结果就如下:
泛型擦除前:
1 | public static void main(String[] args) { |
泛型擦除后:
1 | public static void main(String[] args) { |
由此,Java的这种设计模式还产生了如下问题:
不支持原始类型的泛型
一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。
当时Java给出的解决方案一如既往的简单粗暴:既然没法转换 那就索性别支持原生类型的泛型了。你们都用ArrayList<Integer>
、ArrayList<Long>
,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了得了。这个决定后面导致了无数构造包 装类和装箱、拆箱的开销,成为Java泛型慢的重要原因,也成为今天Valhalla项目要重点解决的问题之一。
如下代码就是不支持的:
1 | ArrayList<int> ilist = new ArrayList<int>(); |
运行期无法取到泛型类型信息
这也是很大的一个问题,由于在运行时无法获取到泛型类型。那么就无法通过泛型类型来实例化变量。
对此,不得不采用更加复杂的方法,再将一个对应类型的对象作为形参传入。
例如:
1 | public static <T> T[] convert(List<T> list, Class<T> componentType) { |
无法对带泛型的类型做重载
由于变量擦除。则List<T>
与List<M>
在擦除后后都变成了List
。那么List<T>
与List<M>
在运行时就是同一种类型,也就无法对其进行重载。
例如下面的代码就是错误的:
1 | public class GenericTypes { |
泛型对重载规则的影响
思考以下代码:
1 | public class GenericTypes { |
思考上面的代码,我们说到List<T>
与List<M>
在运行时就是同一种类型。那么上面的代码实际上在类型擦除后变为:
1 | public static String method(List list) { |
由重载的规则,方法是由方法签名来识别的。而方法签名是由方法名和方法参数构成。
所以上面的方法理论上是无法被重载的,因为这两个方法的方法签名是完全一样的。
但实际测试却是可以执行的。
这又是为什么呢?
其原因在于:方法重载要求方法具备不同的特征签名,返回值并不包含 在方法的特征签名中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全 一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。
即方法签名的限制,仅仅对Java程序有限制,而在指令中,并不受此限制。
JCP组织对《Java虚拟机规 范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而 来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。
另外,从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。
自动装箱、拆箱与遍历循环
纯技术的角度而论,自动装箱、自动拆箱与遍历循环(for-each循环)这些语法糖,无论是实现 复杂度上还是其中蕴含的思想上都不能和泛型相提并论,两者涉及的难度和深度都有很大差距。
其原理就是在字节码中加入对应的操作,如valueOf
等。
例如:
原代码:
1 | public static void main(String[] args) { |
自动装箱、拆箱与遍历循环编译之后(编译后Class文件的反编译结果:):
1 | public static void main(String[] args) { |
可以看到,自动装箱、拆箱在编译之后被转化 成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代 码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看 变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员的确也就是使用数组来完成类似功能的。
条件编译
许多程序设计语言都提供了条件编译的途径,如C、C++中使用预处理器指示符(#ifdef)来完成条件编译。
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) { |
只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句 搭配,则可能在控制流分析中提示错误,被拒绝编译,如下代码清单所示的代码就会被编译器拒绝编译。
1 | public static void main(String[] args) { |
Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把 分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。
实战:实现Lombok-插入时注解处理器的应用
插入时注解处理器的应用
引例1-在编译时打印字符
注解处理器类
注解处理器类需要继承自AbstractProcessor,并且实现process方法。init方法相当于构造方法,可以用于初始化对象等。也可以不重写。
例如:
1 | import java.util.*; |
编写一个测试类
由于是注解处理器,所以我们在测试类上加上注解,自定义的注解处理器才能扫描到。
例如:
1 | Anno { } |
命令行调用javac
编译注释处理器:
1
javac AnnoProc.java
调用javac并使用
-processor
来使用注释处理起来编译:1
javac -processor AnnoProc TestClass.java
结果如下:
1
2
3init----mw
process---mw
process---mw
有几点值得注意:
注解的位置如果不指定,就只会在当前目录下搜索,并且不能通过类路径名来进入下一级目录来查找,比如:
processors/AProcessor
。如果有package,那么应该使用packege.Processor
来指定类,例如priv.mw.ToStringProcessor
。如果注解class文件在另外的目录,有两种方法:- 通过
-cp path
指定所有类路径,则指定时会从该目录去搜索。 - 通过
-processorpath path
指定注释类的路径,则会首先从该目录去搜索(优先级比上面高)。
- 通过
@SupportedAnnotationTypes
注解确定了当前注释处理器支持的注解类型,按照类名进行匹配。如果使用通配符*
,则代表支持所有的注解(包括没有注解的类)。
引例2-检查类中变量的命名规范
首先明确其命名规范如下:
- 类(或接口):符合驼式命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 字段:
- 类或实例变量。符合驼式命名法,首字母小写。
- 常量。要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
这里需要介绍一个新的类:javax.lang.model.util.ElementScanner6的NameCheckScanner
。其可以减少我们遍历的操作,它会自动帮助我们遍历抽象语法树,然后调用对应的树节点类型的钩子函数。就不用我们再去写逻辑遍历判断了。
代码如下:
注解类
这里也是继承了AbstractProcessor,然后@SupportedAnnotationTypes("*")
表示支持所有的注解(包括不含注解的类)。然后在里面调用工具类来进行变量合法性判断。
1 | import java.util.*; |
工具类
这里主要是继承了ElementScanner14
。进行循环的扫描节点,然后调用函数进行判断。
1 | import javax.annotation.processing.Messager; |
有一点值得注意的是:这里中文容易乱码。
其中有两点:
在编译是需要加上encoding参数utf-8(如果有中文的话)。
第二次编译的时候就不需要加参数了。
如果电脑是英文,则可能自动将windows区域改为非中文区域。此时需要将区域改回来,否则还是会显示问号’???’。(其路径为控制面板->时钟和区域->区域)
附加-ELement子类
javax.lang.model.element 中 Element 的子接口 | |
---|---|
interface |
ExecutableElement
表示某个类或接口的方法、构造方法或初始化程序(静态或实例),包括注释类型元素。 |
interface |
PackageElement
表示一个包程序元素。 |
interface |
TypeElement
表示一个类或接口程序元素。 |
interface |
TypeParameterElement
表示一般类、接口、方法或构造方法元素的形式类型参数。 |
interface |
VariableElement
表示一个字段、enum 常量、方法或构造方法参数、局部变量或异常参数。 |
引例3-生成toString方法
注意实现要了解下JCTree和TreeMakerAPI。见该文章。
ToStringProcessor
核心文件:用来生成ToString函数的注解处理器
1 | import javax.annotation.processing.*; |
实体类
1 | AString{} |
编译方法
值得注意的是,由于com.sun.*
并不是公开API。所以需要手动引入该包,方法是使用-cp
加上tools.jar文件。
目前文件结构如下:
1 | processor |
在该目录下执行以下命令:
1 | # 编译processor |
其中的变量需要根据不同的环境而改变,由于电脑的默认javac是16,所以需要指定绝对地址。其中各个参数解释:
-cp C:/Users/MW/.jdks/corretto-1.8.0_312/lib/tools.jar
:指定tools.jar文件。-encoding UTF-8
:指定编码,使之可以显示中文。-processorpath processors
:指定processor的路径。-processor SingleToString
:指定processor。可以用分号分隔。
整体完成
这里完成了四个基本注解:
- Getters-注解在类上,为所有字段产生getter方法。
- Setters-注解在类上,为所有字段产生setter方法。
- ToString-注解在类上,为类产生toString方法。
- AllArgsConstructor-注解在类上,为类产生全参构造器方法。
整体设计的比较简单,文件架构如下:
1 | priv.mw |
其中值得注意的点有以下几个点:
context的获取
1
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
treeMaker的获取
1
treeMaker = TreeMaker.instance(context);
rootTrees的获取
1
rootTrees = JavacTrees.instance(processingEnv);
构造赋值语句(assign)的时候,要想将其加入到block中,必须要使用exec来包装一层。例如
1
2
3
4
5
6JCTree.JCAssign assignment = treeMaker.Assign(
treeMaker.Select(treeMaker.Ident(names.fromString("this")), variableDecl.name),
treeMaker.Ident(variableDecl.name));
ListBuffer<JCTree.JCStatement> jcStatements = new ListBuffer<>();
jcStatements.add(treeMaker.Exec(assignment));
JCTree.JCBlock block = treeMaker.Block(0, jcStatements.toList());关于修饰符这块也比较讲究,其构造方法如下:
1
treeMaker.Modifiers(Flags.PUBLIC);
值得注意的是,方法入参的构造:实际上也是一个
JCTree.JCVariableDecl
,但是比较特别的是此时修饰符应当使用**Flags.PARAMETER
**,所以一个完整的例子如下:1
JCTree.JCVariableDecl paramVar = treeMaker.VarDef(treeMaker.Modifiers(Flags.PARAMETER), variableDecl.name, variableDecl.vartype, null);
javac编译时的问题,见javac命令参数参考。
源代码见这里:这里