《深入了解Java虚拟机》第十章

第十章 前端编译与优化

10.1 概述

本只讨论第一类编译期和编译器。

· “编译期”

  1. 有可能指一个编译器的前端把.java文件转变成.class文件的过程;
  2. 也可能指Java虚拟机的即时编译器JIT编译器,Just In Time Compiler)运行期把字节码转变为本地机器码的过程;
  3. 还可能指使用静态的提前编译器AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
    · 上面三类编译过程中有一些比较有代表性的编译器产品:
  • 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。
  • 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
  • 提前编译器:JDK的Jaotc、GNU Compiler for theJava(GCJ)、Excelsior JET。
    · Javac这类前端编译器对代码的运行效率几乎没有任何优化措施可言,基本都集中到了即时编译器中。
    · 如果把“优化”定义放宽,把对开发阶段的优化也算进来Javac确实做了许多针对Java语言编码过程的优化措施来降低编码复杂度,提高编码效率。
    · 相当多新生的java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或JVM的底层改进来支持。

10.2 Javac编译器

10.2.1 Javac的源码与调试

javac发展过程

  1. JDK6之前,Javac并不属于标准Java SE API的一部分,它实现代码单独存放在tools.jar中,要在程序中使用的话就必须把这个库放到类路径上。
  2. JDK6及之后,Javac编译器的实现代码晋升成为标准Java类库之一。
  3. JDK9时,整个JDK所有的Java类库都采用模块化进行重构划分,Javac编译器就被挪到了jdk.compiler模块

· Javac编译器除了JDK自身的标准类库外,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/里面的代码,所以我们的代码编译环境建立时基本无须处理依赖关系。
*
e.g** Eclipse IDE为开发工具,先建立一个Java工程,然后将上述目录下的文件全部复制到工程的源码目录中。之后,就可以运行main方法来执行编译了。使用参数与Javac命令相同。

  • 源码文件有的可能会提示“Access Restriction”(访问限制),被Eclipse拒绝编译。
  • 这是Eclipse为了防止引用非标准类库导致出现兼容性问题。在“JRE System Library”设置中默认包含了一系列的代码访问规则(Access Rules),如果代码引用了规则中禁止的类,就会报错。
  • 我们可以通过自行添加一条允许访问规则来解决。

· 从Javac代码总体结构看,编译过程大致可以分为“1准备3处理”过程:
1) 准备过程:初始化插入式注解处理器
2) 解析与填充符号表过程:
· 词法、语法分析。将源代码字符流转变成标记集合,构造出抽象语法树。
· 填充符号表。产生符号地址和符号信息
3) 插入式注解处理器的注解处理过程
4) 分析与字节码生成过程,包括:
· 标注检查。对语法的静态信息进行检查。
· 数据流及控制流分析。对程序动态运行过程进行检查。
· 解语法糖。将简化代码编写的语法糖还原为原有的形式。
· 字节码生成。将前面各步骤生成的信息转化为字节码。
注:上述三个处理过程里,执行插入式注解时可能会产生新的符号,一旦产生,就需要转会之前的处理中第二步,重新处理这些新符号。
在这里插入图片描述
· Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类。代码逻辑集中在这个类的compile()和compile2()中。
在这里插入图片描述

10.2.2 解析与填充符号表

  1. 词法、语法分析
    词法分析是将源代码的字符流转变为标记(Token)集合的过程。
    · 单个字符是程序编写的最小元素,但是标记才是编译时的最小元素。
    · 标记包括:关键字、变量名、运算符、字面量。
    e.g int a=b+2 这句代码中就包含了6个标记,int关键字由三字符构成,但是只是一个独立标记。

    语法分析是根据标记序列构造抽象语法树(AST)的过程。
    · AST是一种用来描述程序语法结构的树形表示方式,每个节点代表程序代码中一个语法结构。
    · 语法结构(Syntax Construct)包括:包、类型、修饰符、接口、返回值、代码注释等。

    解析之后,编译器就不会对源码字符流操作了,后续操作都是建立在抽象语法树上。

    1. 填充符号表
      · 符号表是由一组符号地址和符号信息构成的数据结构。实现形式多样,如哈希表、有序符号表、树状符号表等。
      · 符号表中所登记的信息,在编译不同阶段都会用到。譬如,语义分析阶段,将用于语义检查(如一个名字的使用与原先的声明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。
      · Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现。
      · 该过程的产物是一个待处理表,其中包含了每一个编译单元的AST的顶级节点,以及package-info.java(如果存在的话)的顶级节点。

10.2.3 注解处理器

· JDK5后,Java语言提供了对注解的支持。
· 注解设计原本与普通java代码一样,在运行期发挥作用。但JDK6中提出并通过提案,设计了一组“插入式注解处理器”的标准API,将处理提前至编译器。
· 我们可以将插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素
· 如果这组插件在处理注解期间对树进行过修改,编译器将会回到解析及填充符号表的过程重新处理,直到所有插件没有再对树进行修改为止。每一次循环称为一个轮次(Round)
· 有了注解处理器的标准API后,程序员代码才有可能干涉编译器的行为。由于语法树任意元素都能在插件中被访问到,所以通过注解处理器可以实现许多在编码中由人共完成的事情(譬如自动生成getter/setter方法)。

10.2.4 语义分析与字节码生成

· 经过语法分析后,编译器获得了表示一个结构正确的源程序的抽象语法树。但是,并不能保证源程序的语义是符合逻辑的。
· 语义分析要做的事就是对结构正确的源程序进行上下文性质的相关性检查,譬如类型检查、控制流检查、数据流检查等。
· 我们编码时看到的红线标注,绝大多数是源于语义分析的检查结果——不合逻辑,拒绝编译。
· 语义分析过程分为标注检查数据及控制流分析两步:

  1. 标注检查
    · 主要检查内容包括:变量使用前是否声明,变量与赋值间的数据类型是否匹配等。
    · 还会进行一个常量折叠的代码优化,这是Javac编译器对源代码做的极少量优化措施之一。
    e.g int a = 1 + 2; 优化前抽象语法树上能看到的“1”“2”“+”,优化后折叠为字面量“3”(这个值称为“插入式表达式的值”)
    · 标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr类.Check类

  2. 数据及控制流分析
    · 是对程序上下文逻辑的进一步验证。
    · 可检验出:局部变量使用前是否有赋值,受查异常是否都被正确处理,方法的每一条路径是否都有返回值等。
    · 编译期类加载时的的数据及控制流分析目的基本一致,但是校验范围有所区别。
    e.g final语义校验:一个只能在编译期而不能在运行期中检查的例子
    在这里插入图片描述

  • 观察这两段代码编译出来的字节码,会发现它们是没有任何一点区别的,每条指令,甚至每个字节都一模一样。
  • 由于第六章中已知,局部变量在常量池中并没有CONSTANT_Fieldref_info的符号引用,自然不可能有访问标志,所以有没有final修饰在Class文件中都无法得知。
  • 因此,是否把局部变量声明为final,对运行期都完全没有影响。变量的不变性仅靠Javac在编译期保证。
  • · 数据及控制流分析的入口是图10-5中过程3.2的flow()方法,具体操作由com.sun.tools.javac.comp.Flow类来完成。
  1. 解语法糖
    · 语法糖,也称糖衣语法,指的是在计算机语法中添加一些语法,这些语法在编译结果和功能上没有任何影响,但能方便程序员使用该语言。
    · 使用语法糖,能够减少代码量,增加程序可读性,从而减少程序出错机会。
    · Java相对于JVM上其他的语义来说,属于“低糖语言”,尤其在JDK5之前。
    · Java中最常见的语法糖包括:泛型、变长参数、自动装箱拆箱等。
    · JVM运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖
    · 在Javac的源码中,过程由desugar()方法触发,在com.sun.tools.javac.comp.TransTypes类.Lower类中完成。

  2. 字节码生成
    · 在Javac的源码中,由com.sun.tools.javac.jvm.Gen类来完成。
    · 字节码生成过程不仅是把之前阶段生成的信息(语法树、符号表)转化为字节码指令写到磁盘中,编译器还进行了少量代码添加与转换工作。
    · 例如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。

    注:

    1. 这里的实例构造器并不等同于默认构造函数,如果代码没有提供构造函数,在填充符号表阶段,编译器就会添加一个没有参数、可访问性与当前类型一致的默认构造参数。
    2. <init>()和<clinit>()这两个构造器的产生实际上是一种代码收敛的过程。
      · 编译器会把语句块(对实例构造器而言是“{}”块,对类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(<clinit>()方法中无须调用父类的<clinit>()方法,JVM会自动保证父类构造器的正确执行,但在<clinit>()方法中常会生成调用Object的<init>()方法的代码)等操作收敛到<init>()和<clinit>()方法之中。
      · 并且保证,无论源码顺序如何,都一定按照执行父类的实例构造器、初始化变量、执行语句块的顺序进行。上面所述的动作由Gen::normalizeDefs()方法来实现。

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

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

10.3 Java语法糖

语法糖可以看作是前端编译器实现的一些“小把戏”,它可以使得效率提升,但我们也需要了解背后真面目,更好地利用它们。

10.3.1 泛型

· 泛型本质是参数类型或者参数多态的应用,即将操作的数据类型指定为方法签名中的一种特殊参数,这种参数能够在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
· 程序员能够针对泛化的数据类型,编写相同的算法。
1. Java与C#泛型

  • 同年,Java 5.0和C#2.0各自添加了泛型的语法特性。

  • Java选择的泛型实现方式叫作“类型擦除式泛型”,而C#选择的泛型实现方式是“具现化式泛型”。

  • C#中的泛型,无论式源码里、编译后的中间语言表示(这时候泛型是一个占位符)里、运行期的CLR里都是切实存在的。例如List<int>与List<string>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。

  • Java中的泛型则不同。它只在源码中存在,在编译后的字节码文件里,全部泛型都被替换为裸类型了,并且在相应地方插入了强制转换类型代码。因此早期Java,ArrayList<Integer>与ArrayList<String>其实是同一个类型。

  • 这带来的不利影响是:
    1)使用效果落后。Java泛型在编码阶段产生不良影响:
    在这里插入图片描述
    2)运行效率落后。C#引入泛型后,性能比java提高不少。因为在使用平台提供的容器类型时无需拆箱装箱。

  • 唯一的优势是实现这种泛型的影响范围大:擦除式泛型实现几乎只需在Javac上做出改进即可。不需要改动字节码、虚拟机,甚至之前没有使用泛型的库也能直接运行在Java5.0上。

  1. 泛型历史背景
    · 把Pizza语言的泛型移植到Java语言上,形成了Java5.0的泛型实现。但事实上,Pizza语言中的泛型更接近C#的泛型。只是在移植过程中,受到许多约束。
    其中最紧的是由于Java需要完全向后兼容无泛型java,即保证“二进制向后兼容性”(在JDK之前版本编译出的Class文件,必须保证在之后的版本也能够正常运行)。
    · 为了保证编译出来的Class文件可以在引入泛型之后继续运行,设计思路大体上有两条:
    1)需要泛化的类型,以前有的就保持不变,然后平行加入一套泛型化版本的新类型。
    (e.g 假设:Vector(无泛型)和vector<T>(有泛型))
    2)直接把已有的、需要泛型化的类型都原地泛型化
    · C#走了第一条路,增加了一组新容器。但由于Java已经面世近10年了,遗留代码量巨大。所以Java选择了第二条路。但其实如果有足够时间好好设计与实现,完全能够做出更好的泛型系统,不至于今天的Valhalla项目还要偿还泛型偷懒的技术债了。

  2. 类型擦除
    · 既要直接泛型化已有的类型(ArrayList<T>),又要保证之前用了这种类型(ArrayList)的代码在新版本中依旧可以运行,这就必须让所有泛型化的实例类型(ArrayList<Integer>)全部自动成为这种类型(ArrayList)的子类型,否则类型转换不安全。
    · 所以引入“裸类型”,它应当被视为所有该类型泛型化实例的共同父类型。
    · 如何实现裸类型?主要有两种设计选择:
    1)在运行期由JVM自动、真实地生成实例类型(ArrayList<Integer>),并且自动实现从实例类型派生自原来类型(ArrayList)的继承关系
    2)直接在编译期把实例类型(ArrayList<Integer>)还原为原来类型(ArrayList),只在元素访问、修改时插入一些强制类型转换和检查命令

    · Java采用了第二种方式。
    · e.g 将一段使用泛化类型的代码编译成Class文件,再用字节码反编译工具进行反编译,会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从ObjectString的强制转型代码。
    泛型擦除前:
    在这里插入图片描述
    泛型擦除后:
    在这里插入图片描述

    · 缺点:
    1)目前Java不支持原生类型的泛型(ArrayList<int>)。Java解决方案就是都使用封装类型(ArrayList<Integer>)遇到原生类型,自动装箱拆箱。这就导致了许多开销,这也是Valhalla项目重点解决问题之一。

    e.g 下图这种情况,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long(原生类型)与Object之间的强制转型。
    在这里插入图片描述

    2)运行时无法取得泛型类型信息,使得代码变得啰嗦。例如
    前面图片所示的Java不支持的几种泛型用法,都是由于运行期JVM无法取得泛型类型而导致的。
    当我们参数需要取得参数化类型T,但由于不能直接取得,所以不得不加入一个类型参数。

    3)丧失了一些面向对象思维的优雅,带来些模棱两可的情况。
    如:函数重载,参数分别为List和List,代码拒绝编译。部分原因是因为编译后参数擦除,裸类型相同,导致这两个方法的特征签名一模一样。

    · 如果添加两个并不需要实际使用到的、不一样的返回值类型,却发现重载成功了,即代码能够编译、执行了。(在Javac中成功,其他版本或者ECJ中可能失败。因为前端编译器在《Java虚拟机规范》中没具体定义,各虚拟机实现不同)
    · 这并不是Java语言中返回值不参与重载选择的认知违背
    Java代码层面的方法特征签名:方法名称、参数顺序、参数类型;字节码层面的方法特征签名:还包括返回值及受查异常表】方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择。但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。
    · 因此这两个方法加入了不同返回值后,能共存于一个Class文件中。

    由于Java泛型的引入,各种场景下的方法调用有新的需求,如在泛型类中如何获取传入的参数化类型等。
    · 所以《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用来解决参数类型识别问题。
    · Signature:存储一个方法在字节码层面的特征签名。这个属性中保存的参数类型是包括了参数化类型的信息。
    · 从Signature属性出现也可知,擦除只是对方法的Code属性中字节码进行擦除,实际上元数据中还是保留了泛型信息。
    (编码时能通过反射手段获取参数化类型的根本依据)

    1. 值类型与未来的泛型
      · 实现泛型的方式待定。可能被具现化,也可能继续维持兼容,不过可以选择不被完全擦除,相对完整地记录在Class文件中。
      · 明确的是,未来Java应该会提供“值类型”的语言层面的支持。

      · 在C#中,没有int等原生数据类型,开发人员甚至可以自定义自己的值类型,只要继承于ValueType即可。因为ValueType也是统一基类Object的子类,所以不会遇到int不装箱就无法转型为Object的尴尬。
      · 值类型可以与引用类型一样,有构造函数、方法、属性字段,区别是值类型在赋值的时候是整体赋值,而不是像引用类型那样传递引用。
      · 值类型的实例很容易实现分配在方法的调用栈上。这样值类型会随着当前方法的退出而自动释放,不会给垃圾收集带来压力。

      · 在Valhalla项目中,Java的值类型方案被称为“内联类型”。计划通过关键字inline来定义,在字节码层也有专门的对应操作码(Q开头)来支撑。

10.3.2 自动装箱、拆箱与遍历循环

· 自动装箱、拆箱与遍历循环(foreach循环)这些语法糖,复杂度和蕴含思想都不及泛型。但这些是Java中使用最多的语法糖。

e.g 自动装箱陷阱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g =3L;
System.out.println(c==d);
System.out.println(e==f);
System.out.println(c==(a+b));
System.out.println(c.equals(a+b));
System.out.println(g==(a+b));
System.out.println(g.equals(a+b));
}

输出结果:
true
false
true
true
true
false

注:
1.包装类的“==”在不遇到算术运算的情况下,不会自动拆箱,默认比较地址值;它们的equals()方法不处理数据转型的关系。
2.在Java中会有一个Integer缓存池,缓存的大小是:-128~127

10.3.3 条件编译

· java语言没有使用预处理器来解决编译时的代码依赖问题。而C/C++中使用预处理器指示符(#ifdef)来完成条件编译。
因为Java语言天然的编译方式:编译器并非一个个编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译。因此各个文件间可以互相提供符号信息。
· 不过Java依旧有方法进行条件编译:使用条件为常量的if语句。(不能使用其他条件语句完成条件编译)
· Java语言中条件编译的实现,也是一颗语法糖,根据布尔常量值的真假,编译器把分支中不成立的代码块消除掉。这一工作在编译器解除语法糖阶段完成。
· 因为使用的if语句,所以只能写在方法体内部,因此只能实现语句基本块(Block)级别的条件编译,没法根据条件,调整整个Java类的结构。

Java程序命名规范
- 类(或接口):符合驼式命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 字段:
· 类或实例变量。符合驼式命名法,首字母小写。
· 常量。要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
注:驼式命名法,指混合使用大小写字母来分割名字。

总结:
· 在前端编译器中,“优化”手段主要用于提升程序的编码效率。之所以把Javac这类将Java代码转变为字节码的编译器称作“前端编译器”,是因为它只完成了从程序抽象语法树或中间字节码的生成。
· 在此之后,还有一组内嵌于虚拟机内部的“后端编译器”来完成代码优化以及从字节码生成本地机器码的过程。即即时编译器或提前编译器。
这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟机性能最重要的一个指标。