【周志明JVM笔记】前端编译与优化

  • Post author:
  • Post category:其他

前端编译与优化

概述

在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表达,因为它可能是指一个前端编译器,也可能是指Java虚拟机的即时编译器,还可能是指使用静态的提前编译器。
  • 前端编译器:准确说应该是“编译器的前端”,把*.java文件转变成*.class文件的过程。JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
  • 即时编译器:常称JIT编译器,Just In Time Compiler,运行期把字节码转变成本地机器码的过程。HotSpot虚拟机的C1、C2编译器,Graal编译器。
  • 提前编译器:常称AOT编译器,Ahead Of Time Compiler,直接把程序编译成与目标机器指令集相关的二进制代码的过程。JDK的Jaotc、GUN Compiler for the Java(GCJ)、Excelsior JET。

Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

Javac 编译器

Javac的源码和调试

在JDK6以前,Javac不属于标准Java SE API的一部分,它实现代码单独存放在tools.jar中,要在程序中使用的话必须把这个库放到类路径上。在JDK6发布时通过了JSR 199编译器API的提案,使得Javac编译器的实现代码晋升称为标准Java类库之一,它的源码就改为放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/Javac中。

从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,分别是:

  1. 准备过程:初始化插入式注解处理器。
  2. 解析和填充符号表过程,包括:
    词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    填充符号表。产生符号地址和符号信息。
  3. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
  4. 分析与字节码生成过程,包括:
    -标注检查。对语法的静态信息进行检查。
    数据流及控制流分析。对程序动态运行过程进行检查。
    -解语法糖:将简化代码编写的语法糖还原成原有的形式。
    -字节码生成。将前面各个步骤所生成的信息转化成字节码。

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

把上述处理过程对应到代码中,Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻辑集中在这个类的compile()和compile2()方法中,整个编译过程主要的处理由8个方法完成,分别是:
1.0 initProcessAnnotations(processors):准备过程:初始化插入式注解处理器
1.1 parseFiles:过程1.1,词法分析、语法分析
1.2 enterTrees:过程1.2,输入到符号表
2.0 processAnnotation:过程2,执行注解处理
3.0 compiler2 :过程3,分析及字节码生成
3.1 attribute:标注
3.2 flow:数据流分析
3.3 desugar:解语法糖
3.4 generate:生成字节码

解析与填充字符表

词法、语法分析
  1. 词法分析
    将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。
    关键字、变量名、字面量、运算符都可以作为标记,如“int a = b + 2”这句代码就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个独立的标记,不可以再拆分。
    在Javac源码中,词法分析过程由com.sun.tools.javac.parse.Scanner类来实现。
  2. 语法分析
    根据标记序列构造抽象语法树的过程
    抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

在Javac的源码中,语法分析过程由com.sum.tools.javac.parser.Parser类实现,这个阶段产生的抽象语法树是以com.sum.tools.javac.tree.JCTree类表示的。
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都建议在抽象语法树之上。

填充符号表(enterTrees)
完成词法和语法分析后,下一个阶段是对符号表进行填充的过程。
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,可以把它类比想象成哈希表中的键值对的存储形式。

符号表所登记的信息在编译的不同阶段都要被用到。譬如在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

Javac源码中,填充符号表的过程由com.sum.tools.javac.comp.Enter类实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。

注解处理器

JDK5之后,Java语言提供了对注解(Annotation)的支持,注解在设计上原本是与普通的Java代码一样,都只会在程序运行期间发挥作用的。但在JDK6中又提出并通过了JSR-269(Pluggable Annotations Processing API 插入式注解处理API)提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。
可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。
这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。
有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。

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

语义分析与字节码生成

概述

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

上述代码有上面3种赋值运算,它们都能构成结构正确的抽象语法树,但是只有第一种的写法在语义上是没有错误的,能够通过检查和编译,其余两种在Java语言中是不合逻辑的,无法编译。
在编码时经常能在IDE中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。
语义分析过程可分为标注检查和数据及控制流分析两个步骤

标注检查

标注检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等。
在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
例如Java代码“int a = 1 + 2;”,则在抽象语法树上仍然能看到字面量“1”、“2”和操作符“+”号,但是经过常量折叠优化之后,它们将会被折叠为字面量“3”。
标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr类和com.sun.tools.javac.comp.Check类。

数据及控制流分析

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

上述两个方法中,带有final修饰符的arg和var变量的值无法再改变,但是这两段代码编译处理的字节码是没有任何一点区别的,每条指令,甚至每个字节都一模一样。
这是因为局部变量与类字段(实例变量、类变量)的存储是不同的,局部变量在常量池中并没有CONSTANT_Fieldref_info的符号引用,自然就不可能存储有访问标志(access_flags)的信息,甚至可能连变量名称都不一定被保留下来,自然在Class文件中就不可能知道一个局部变量是不是被声明为final了。
因此,把局部变量声明为final,对运行期是完全没有影响的,变量的不变性仅仅有Javac编译器在编译期间来保障,这就是一个只能在编译期而不能在运行期中检查的列子。
在Javac的源码中,数据及控制流分析的入口是flow()方法,具体操作由com.sun.tools.javac.comp.Flow类完成。

解语法糖
  • 语法糖:Syntactic Sugar,也称糖衣语法,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。
  • Java中最常见的语法糖有泛型(其他语言不一定是语法糖)、变长参数、自动装箱拆箱等。
  • Java虚拟机运行时并不直接支持这些语法糖,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
    在Javac源码中,解语法糖的过程由desugar()方法触发,在com.sun.tools.javac.comp.TransTypes类和com.sun.tools.javac.comp.Lower类中完成。
字节码生成
  • 字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。
  • 字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
  • 实例构造器< init>()方法和类构造器< client>()方法就是在这个阶段被添加到语法树中的。
  • 这里的实例构造器并不等于默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
  • < init>()和< client>()方法这两个构造器的产生实际上是一种代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造,< client>()方法无须调用父类的< client>()方法,Java虚拟机会自动保证父类构造器的正确执行,但在< client>()方法中经常会生成调用java.lang.Object的< initial>()方法的代码)等操作收敛到< init>()和< client>()方法之中,并且保证无论源码中出现的顺序如何,都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述动作由Gen::normalizeDefs()方法来实现。
  • 除来生成构造器以外,还有其他一些代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为String Buffer或String Builder的append()操作。
  • 完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到com.sun.tools.javac.jvm.ClassWriter类手上,由这个类的write Class()方法输出字节码,生成最后的Class文件。

Java语法糖的味道

语法糖虽然不会提供实质性的功能改进,但是它们或提高效率,或能提升语法的严谨性,或能减少编码出错的机会。

泛型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为办法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类泛型接口和泛型方法。

Java与C#的泛型
  • Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)。
  • C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的。例如,List和List就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。
  • Java语言中的泛型则不同,它只在程序源码存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,例如List的裸类型就是List),并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList和ArrayList其实是同一个类型,这就是类型擦出。
  • Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型,而它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java5.0上。
裸类型:裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)。即List和List的裸类型是List。
实现裸类型的方式
  • 一种是在运行期由Java虚拟机自动地、真实地构造出ArrayList这样的类型,并且自动实现从ArrayList派生自ArrayList的继承关系来满足裸类型定义;
  • 另一种是简单粗暴地直接在编译时把ArrayList还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令。
类型擦除的缺陷
  1. 直接导致了对原始类型(Primitive Types,即int、long、boolean等基础数据类型)数据的支持成了新的麻烦。因为不支持int、long于Object之间的强制转型,因此Java索性不支持原始类型的泛型了,都用ArrayList和ArrayList,遇到原始类型时把装箱、拆箱也自动做了。
  2. 运行期无法获取到泛型类型信息,只能从一个额外参数中再传入一个泛型类型信息才能从运行期获取。
  3. 丧失了一些面向对象思想应有的优雅性,带来了一些模棱两可的模糊状况。例如,同一个类中有两个方法签名分别是“public void method(List list)”和“public void method(List list)”,由于裸类型的原因导致无法重载,但是如果把第一个方法的返回值修改成int、第二个方法返回值修改成String,在JDK1.6版本(只有该版本可以)中方法重载成功了,即这段代码可以被编译和执行。

版权声明:本文为weixin_40757920原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。