深入理解java虚拟机笔记-前端编译与优化

一、概述

在Java技术下谈“编译期”而没有具体上下文语境的话,因为它可能是指一个前端编译器把.java文件转变成.class文件的过程; 也可能是指Java虚拟机的即时编译器运行期把字节码转变成本地机器码的过程; 还可能是指使用静态的提前编译器(常称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。

这3类过程中最符合普通程序员对Java程序编译认知的应该是第一类, 本章标题中的“前端”指的也是这种由前端编译器完成的编译行为。

因为Javac这类前端编译器对代码的运行效率几乎没有任何优化措施可言。因为Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中, 这样可以让那些不是由Javac产生的Class文件(如JRuby、 Groovy等语言的Class文件) 也同样能享受到编译器优化措施所带来的性能红利。

但是, 如果把“优化”的定义放宽, 把对开发阶段的优化也计算进来的话, Javac确实是做了许多针对Java语言编码过程的优化措施来降低程序员的编码复杂度、 提高编码效率。 相当多新生的Java语法特性, 都是靠编译器的“语法糖”来实现, 而不是依赖字节码或者Java虚拟机的底层改进来支持。 我们可以这样认为, Java中即时编译器在运行期的优化过程, 支撑了程序执行效率的不断提升; 而前端编译器在编译期的优化过程, 则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

二、javac编译器

2.1.Javac的源码与调试

从Javac代码的总体结构来看, 编译过程大致可以分为1个准备过程和3个处理过程, 它们分别如下所示。

1) 准备过程: 初始化插入式注解处理器。

2) 解析与填充符号表过程, 包括:

·词法、 语法分析。 将源代码的字符流转变为标记集合, 构造出抽象语法树。

·填充符号表。 产生符号地址和符号信息。

3) 插入式注解处理器的注解处理过程: 插入式注解处理器的执行阶段, 本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。

4) 分析与字节码生成过程, 包括:

·标注检查。 对语法的静态信息进行检查。

·数据流及控制流分析。 对程序动态运行过程进行检查。

·解语法糖。 将简化代码编写的语法糖还原为原有的形式。

·字节码生成。 将前面各个步骤所生成的信息转化成字节码。

上述3个处理过程里, 执行插入式注解时又可能会产生新的符号, 如果有新的符号产生, 就必须转回到之前的解析、 填充符号表的过程中重新处理这些新符号, 从总体来看, 三者之间的关系与交互顺序如图10-4所示

 

 

我们可以把上述处理过程对应到代码中, Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类, 上述3个过程的代码逻辑集中在这个类的compile()和compile2()方法里, 其中主体代码如图10-5所示, 整个编译过程主要的处理由图中标注的8个方法来完成。

 

 

2.2 解析与填充符号表

解析过程由图10-5中的parseFiles()方法(图10-5中的过程1.1) 来完成, 解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤。

1.词法、 语法分析

词法分析是将源代码的字符流转变为标记(Token) 集合的过程, 单个字符是程序编写时的最小元素, 但标记才是编译时的最小元素。 关键字、 变量名、 字面量、 运算符都可以作为标记, 如“int a=b+2”这句代码中就包含了6个标记, 分别是int、 a、 =、 b、 +、 2, 虽然关键字int由3个字符构成, 但是它只是一个独立的标记, 不可以再拆分。 在Javac的源码中, 词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。

语法分析是根据标记序列构造抽象语法树的过程, 抽象语法树(Abstract Syntax Tree, AST) 是一种用来描述程序代码语法结构的树形表示方式, 抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct) , 例如包、 类型、 修饰符、 运算符、 接口、 返回值甚至连代码注释等都可以是一种特定的语法结构。

图10-6是Eclipse AST View插件分析出来的某段代码的抽象语法树视图, 读者可以通过这个插件工具生成的可视化界面对抽象语法树有一个直观的认识。 在Javac的源码中, 语法分析过程由com.sun.tools.javac.parser.Parser类实现, 这个阶段产出的抽象语法树是以com.sun.tools.javac.tree.JCTree类表示的。

经过词法和语法分析生成语法树以后, 编译器就不会再对源码字符流进行操作了, 后续的操作都建立在抽象语法树之上。

2.填充符号表

完成了语法分析和词法分析之后, 下一个阶段是对符号表进行填充的过程, 也就是图10-5中enterTrees()方法要做的事情。

符号表(Symbol Table) 是由一组符号地址和符号信息构成的数据结构, 读者可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一定是哈希表实现, 可以是有序符号表、 树状符号表、 栈结构符号表等各种形式) 。

符号表中所登记的信息在编译的不同阶段都要被用到。 譬如在语义分析的过程中, 符号表所登记的内容将用于语义检查和产生中间代码, 在目标代码生成阶段, 当对符号名进行地址分配时, 符号表是地址分配的直接依据。 在Javac源代码中, 填充符号表的过程由com.sun.tools.javac.comp.Enter类实现, 该过程的产出物是一个待处理列表, 其中包含了每一个编译单元的抽象语法树的顶级节点, 以及package-info.java(如果存在的话) 的顶级节点。

2.3 注解处理器

JDK 5之后, Java语言提供了对注解(Annotations) 的支持, 注解在设计上原本是与普通的Java代码一样, 都只会在程序运行期间发挥作用的。

但在JDK 6中又提出并通过了JSR-269提案[1], 该提案设计了一组被称为“插入式注解处理器”的标准API, 可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。 我们可以把插入式注解处理器看作是一组编译器的插件, 当这些插件工作时, 允许读取、 修改、 添加抽象语法树中的任意元素。

如果这些插件在处理注解期间对语法树进行过修改, 编译器将回到解析及填充符号表的过程重新处理, 直到所有插入式注解处理器都没有再对语法树进行修改为止, 每一次循环过程称为一个轮次(Round) , 这也就对应着图10-4的那个回环过程。

有了编译器注解处理的标准API后, 程序员的代码才有可能干涉编译器的行为, 由于语法树中的任意元素, 甚至包括代码注释都可以在插件中被访问到, 所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。 只要有足够的创意, 程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情。

譬如Java著名的编码效率工具Lombok, 它可以通过注解来实现自动产生getter/setter方法、 进行空置检查、 生成受查异常表、 产生equals()和hashCode()方法, 等等, 帮助开发人员消除Java的冗长代码, 这些都是依赖插入式注解处理器来实现的, 本章最后会设计一个如何使用插入式注解处理器的简单实战。

在Javac源码中, 插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的, 而它的执行过程则是在processAnnotations()方法中完成。 这个方法会判断是否还有新的注解处理器需要执行, 如果有的话, 通过com.sun.tools.javac.processing.JavacProcessing-Environment类的doProcessing()方法来生成一个新的JavaCompiler对象, 对编译的后续步骤进行处理。

2.4 语义分析与字节码生成

经过语法分析之后, 编译器获得了程序代码的抽象语法树表示, 抽象语法树能够表示一个结构正确的源程序, 但无法保证源程序的语义是符合逻辑的。 而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查, 譬如进行类型检查、 控制流检查、 数据流检查, 等等。

1.标注检查

Javac在编译过程中, 语义分析过程可分为标注检查和数据及控制流分析两个步骤, 分别由图10-5的attribute()和flow()方法(分别对应图10-5中的过程3.1和过程3.2) 完成。

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、 变量与赋值之间的数据类型是否能够匹配, 等等, 刚才3个变量定义的例子就属于标注检查的处理范畴。 在标注检查中, 还会顺便进行一个称为常量折叠的代码优化, 这是Javac编译器会对源代码做的极少量优化措施之一则在抽象语法树上仍然能看到字面量“1”“2”和操作符“+”号, 但是在经过常量折叠优化之后, 它们将会被折叠为字面量“3”。

标注检查在Javac源码中的实现类是com.sun.tools.javac.comp.Attr类和com.sun.tools.javac.comp.Check类。

2.数据及控制流分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证, 它可以检查出诸如程序局部变量在使用前是否有赋值、 方法的每条路径是否都有返回值、 是否所有的受查异常都被正确处理了等问题。 编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的,但校验范围会有所区别, 有一些校验项只有在编译期或运行期才能进行。

在这两个foo()方法中, 一个方法的参数和局部变量定义使用了final修饰符, 另外一个则没有, 在代码编写时程序肯定会受到final修饰符的影响, 不能再改变arg和var变量的值, 但是如果观察这两段代码编译出来的字节码, 会发现它们是没有任何一点区别的, 每条指令, 甚至每个字节都一模一样。

通过第6章对Class文件结构的讲解我们已经知道, 局部变量与类的字段(实例变量、 类变量) 的存储是有显著差别的, 局部变量在常量池中并没有CONSTANT_Fieldref_info的符号引用, 自然就不可能存储有访问标志(access_flags) 的信息, 甚至可能连变量名称都不一定会被保留下来(这取决于编译时的编译器的参数选项) , 自然在Class文件中就不可能知道一个局部变量是不是被声明为final了。

因此,可以肯定地推断出把局部变量声明为final, 对运行期是完全没有影响的, 变量的不变性仅仅由Javac编译器在编译期间来保障, 这就是一个只能在编译期而不能在运行期中检查的例子。 在Javac的源码中,数据及控制流分析的入口是图10-5中的flow()方法 , 具体操作由com.sun.tools.javac.comp.Flow类来完成。

3.解语法糖

语法糖(Syntactic Sugar) , 也称糖衣语法, 是由英国计算机科学家Peter J.Landin发明的一种编程术语, 指的是在计算机语言中添加的某种语法, 这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。 通常来说使用语法糖能够减少代码量、 增加程序的可读性, 从而 减少程序代码出错的机会。

Java在现代编程语言之中已经属于“低糖语言”(相对于C#及许多其他Java虚拟机语言来说) , 尤其是JDK 5之前的Java。 “低糖”的语法让Java程序实现相同功能的代码量往往高于其他语言, 通俗地说就是会显得比较“啰嗦”, 这也是Java语言一直被质疑是否已经“落后”了的一个浮于表面的理由。

Java中最常见的语法糖包括了前面提到过的泛型(其他语言中泛型并不一定都是语法糖实现, 如C#的泛型就是直接由CLR支持的) 、 变长参数、 自动装箱拆箱, 等等, Java虚拟机运行时并不直接支持这些语法, 它们在编译阶段被还原回原始的基础语法结构, 这个过程就称为解语法糖。

在Javac的源码中, 解语法糖的过程由desugar()方法触发, 在com.sun.tools.javac.comp.TransTypes类 和com.sun.tools.javac.comp.Lower类中完成。

4.字节码生成

字节码生成是Javac编译过程的最后一个阶段, 在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。 字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、 符号表) 转化成字节码指令写到磁盘中, 编译器还进行了少量的代码添加和转换工作。

例如前文多次登场的实例构造器init()方法和类构造器clinit()方法就是在这个阶段被添加到语法树之中的。 请注意这里的实例构造器并不等同于默认构造函数, 如果用户代码中没有提供任何构造函数, 那编译器将会添加一个没有参数的、 可访问性与当前类型一致的默认构造函数, 这个工作在填充符号表阶段中就已经完成。

init()和clinit()这两个构造器的产生实际上是一种代码收敛的过程, 编译器会把语句块(对于实例构造器而言是“{}”块, 对于类构造器而言是“static{}”块) 、 变量初始化(实例变量和类变量) 、 调用父类的实例构造器(仅仅是实例构造器, clinit()方法中无须调用父类的clinit()方法, Java虚拟机会自动保证父类构造器的正确执行, 但在clinit()方法中经常会生成调用java.lang.Object的init()方法的代码) 等操作收敛到init()和clinit()方法之中, 并且保证无论源码中出现的顺序如何, 都一定是按先执行父类的实例构造器, 然后初始化变量, 最后执行语句块的顺序进行, 上面所述的动作由Gen::normalizeDefs()方法来实现。

除了生成构造器以外, 还有其他的一些代码替换工作用于优化程序某些逻辑的实现方式, 如把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 5) 的append()操作, 等等。

完成了对语法树的遍历和调整之后, 就会把填充了所有信息的符号表交给com.sun.tools.javac.jvm.ClassWriter类手上, 由这个类的writeClass()方法输出字节码, 生成最终的Class文件, 到此, 整个编译过程宣告结束。

posted @ 2022-03-23 09:56  Mars.wang  阅读(61)  评论(0编辑  收藏  举报