早期(编译期)优化
概述
Java语言的“编译期”是一段不确定的操作过程,可能是:
- 前端编译器(编译器的前端)把Java文件转换为class文件;Sun 的 Javac、 Eclipse JDT 中的增量式编译器( ECJ)。
- 后端编译器(JIT编译期 Just in time compiler)把字节码变成机器码;JIT 编译器: HotSpot VM 的 C1、 C2 编译器。
- 静态编译器(AOT编译器 ahead of time compiler)直接把Java编译成本地机器代码;
- AOT 编译器: GNU Compiler for the Java(GCJ)、Excelsior JET。
本章讨论第一类编译过程。
Javac 这类编译器对代码的运行效率几乎没有任何优化措施(在 JDK 1. 3 之后, Javac 的- O 优化参数就不再有意义)。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的Class文件(如JRuby、Groovy等语言的Class文件)也同样能享受到编译器优化所带来的好处。但是Javac做了很多针对Java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的低层改进来支持,可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。
Javac编译器
它本身就是一个由 Java 语言编写的程序,这为纯 Java 的程序员了解它的编译过程带来了很大的便利。
Javac的源码与调试
Javac的源码放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac
中,除了JDK自身的API外,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*
里面的代码,调试环境建立起来简单方便,因为基本上不需要处理依赖关系。
以Eclipse IDE环境为例,先建立一个名为“Compile_javac”的java工程,然后把JDK_SRC_HOME/langtools/src/share/classes/com/sun/*
目录下的源文件全部复制到工程的源码目录中。
导入代码期间,源码文件“AnnotationProxyMaker.java”可能会提示“Access Restriction”,被Eclipse拒绝编译。这是由于Eclipse的JRE System Library中默认包含了一系列的代码访问规则(Access Rules),如果代码中引用了这些访问规则所禁止引用的类,就会提示这个错误。可以通过添加一条允许访问Jar包中所有类的访问规则来解决这个问题。
导入了Javac的源码后,就可以运行com.sun.tools.javac.Main的main方法来执行编译了,与命令行中使用的Javac的命令没有什么区别,编译的文件与参数在Eclipse的Debug Configurations面板中的Arguments页签中指定。
从Sun Javac的代码来看,编译过程大致可以分为3个过程,分别是:
- 解析与填充符号表过程。
- 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程。
Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻辑集中在这个类的compile和Compile2方法中。
解析与填充符号表
内容太多以后再来补
注解处理器
提供了一组插入式注解处理器的标准 API 在编译期间对注解进行处理。
有了编译器注解处理的标准 API 后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。
在 Javac 源码中,插入式注解处理器的初始化过程是在 initPorcessAnnotations() 方法中完成的,而它的执行过程则是在processAnnotations() 方法中完成的。
语义分析与字节码生成
编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。
是否合乎语义逻辑必须限定在具体的语言与具体的上下文环境之中才有意义。
标注检查 Javac 的编译过程中,语义分析过程分为标注检查以及数据及控制流分析两个步骤。
标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠。
由于编译期间进行了常量折叠,所以在代码里面定义" a= 1+ 2" 比起直接定义" a= 3", 并不会增加程序运行期哪怕仅仅一个 CPU 指令的运算量。
数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
将局部变量声明为 final, 对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有 CONSTANT_Fieldref_info 的符号引用,自然就没有访问标志( Access_Flags) 的信息。
解语法糖
在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
解语法糖的过程由 desugar() 方法触发。
字节码生成
字节码生成是 Javac 编译过程的最后一个阶段
把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。例如,前面章节中多次提到的实例构造器< init >
()方法和类构造器< clinit >
()方法就是在这个阶段添加到语法树之中的
还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBuffer 或 StringBuilder。
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给 com. sun. tools. javac. jvm. ClassWriter 类,由这个类的 writeClass() 方法输出字节码,生成最终的 Class 文件,到此为止整个编译过程宣告结束。
Java语法糖的味道
泛型与类型擦除
Java 语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type, 也称为裸类型)了,并且在相应的地方插入了强制转型代码。
泛型擦除成相同的原生类型只是无法重载的其中一部分原因。
方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个 Class 文件中的。
Signature、 LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题, Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名, 这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。
由于 List < String >和 List < Integer >擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载。
擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
自动装箱、拆箱与遍历循环
遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。
包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们 equals() 方法不处理数据转型的关系,笔者建议在实际编码中尽量避免这样使用自动装箱与拆箱。
条件编译
Java 语言当然也可以进行条件编译,方法就是使用条件为常量的 if 语句。
实战:插入式注解处理器
此部分的只是日后补上!
本章小结
之所以把 Javac 这类将 Java 代码转变为字节码的编译器称做“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于虚拟机内部的“后端编译器”完成了从字节码生成本地机器码的过程,即前面多次提到的即时编译器或 JIT 编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。