首页 > 代码库 > 深入理解JVM读书笔记四: (早期)编译器优化

深入理解JVM读书笔记四: (早期)编译器优化

10.1概述

Java 语言的 “编译期” 其实是一段 “不确定” 的操作过程,因为它可能是指一个前端编译器(其实叫 “编译器的前端” 更准确一些)把 .java 文件转变成 .class 文件的过程;也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT 编译器,Ahead Of Time Compiler)直接把 *.java 文件编译成本地机器代码的过程。下面列举了这 3 类编译过程中一些比较有代表性的编译器。

  • 前端编译器:Sun 的 Java、Eclipse JDT 中的增量式编译器(ECJ)。
  • JIT 编译器:HotSpot VM 的 C1、C2 编译器。
  • AOT 编译器:GNU Compiler for the Java (GCJ)、Excelsior JET。

Javac 做了许多针对 Java 语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的 Java 语法特性,都是靠编译器的 “语法糖” 来实现,而不是依赖虚拟机的底层改进来支持,可以说,Java 中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

10.2 javac编译器

10.2.1Javac 的源码与调试

虚拟机规范严格定义了 Class 文件的格式,但是《Java 虚拟机规范(第 2 版)》中,虽然有专门的一章 “Compiling for the Java Virtual Machine”,但都是以举例的形式描述,并没有对如何把 Java 源码文件转变为 Class 文件的编译过程进行十分严格的定义,这导致 Class 文件编译在某种程度上是与具体 JDK 实现相关的,在一些极端情况,可能出现一段代码 Javac 编译器可以编译,但是 ECJ 编译器就不可以编译的问题。从 Sun Javac 的代码来看,编译过程大致可以分为 3 个过程,分别是:

  • 解析与填充符号表过程
  • 插入式注解处理器的注解处理过程
  • 分析与字节码生成过程

技术分享

Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler 类,上述 3 个过程的代码逻辑集中在这个类的 compile() 和 compile2() 方法中,其中主体代码如图 10-5 所示,整个编译最关键的处理就由图中标注的 8 个方法来完成,下面我们具体看一下这 8 个方法实现了什么功能。

技术分享

10.2.2解析与填充符号表

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

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

2、填充符号表

完成了语法分析和词法分析之后,下一步就是填充符号表的过程,也就是图 10-5 中 enterTrees() 方法(图 10-5 中的过程 1.2)所做的事情。符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表中 K-V 值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等)。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

10.2.3 注解处理器

在 JDK 1.5 之后,Java 语言提供了对注解(Annotation)的支持,这些注解与普通 Java 代码一样,是在运行期间发挥作用的。在 JDK 1.6 中实现了 JSR-269 规范(JSR-269:Pluggable Annotations Processing API(插入式注解处理 API)),提供了一组插入式注解处理器的标准 API 在编译期间对注解进行处理,我们可以把它看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,知道所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个 Round,也就是图 10-4 中的回环过程。

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

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

10.2.4语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。举个例子,假设有如下的 3 个变量定义语句:

int a = 1;  
boolean b = false;  
char c = 2;  

后续可能出现的赋值运算:

int d = a + c;  
int d = b + c;  
char d = a + c;  

后续代码中如果出现了如上 3 种赋值运算的话,那它们都能构成结构正确的语法树,但是只有第 1 种的写法在语义上是没有问题的,能够通过编译,其余两种在 Java 语言中是不合逻辑的,无法编译(是否合乎语义逻辑必须限定在语言与具体的上下文环境之中才有意义。如在 C 语言中,a、b、c 的上下文定义不变,第 2、3 种写法都是可以正确编译)。

1、标注检查
Javac 的编译过程中,语义分析过程分为标注检查以及数据及控制流分析两个步骤。

标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠,如果我们在代码中写了如下定义。

int a = 1 + 2; 

那么在语法树上仍然能看到字面量 “1”、“2” 以及操作符 “+”,但是在经过常量折叠之后,它们将会被折叠为字面量 “3”,如图 10-7 所示,这个插入式表达式(Infix Expression)的值已经在语法树上标注出来了(ConstantExpressionValue:3)。由于编译期间进行了常量折叠,所以在代码里面定义 “a=1+2” 比起直接定义 “a=3”,并不会增加程序运行期哪怕仅仅一个 CPU 指令的运算量。

2、数据及控制流分析

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

// 方法一带有 final 修饰  
public void foo(final int arg) {  
    final int var = 0;  
    // do something  
}  

// 方法而没有 final 修饰  
public void foo(int arg) {  
    int var = 0;  
    // do something  
}  

在这两个 foo() 方法中,第一种方法的参数和局部变量定义使用了 final 修饰符,而第二种方法则没有,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改吧 arg 和 var 变量的值,但是这两段代码编译出来的 Class 文件是没有任何一点区别的,通过第 6 章的讲解我们已经知道,局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有 CONSTANT_Fieldref_info 的符号引用,自然就没有访问标志(Access_Flags)的信息,甚至可能连名称都不会保留下来(取决于编译时的选项),自然在 Class 文件中不可能知道一个局部变量是不是声明为 final 了。因此,将局部变量声明为 final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。

3、 解语法糖
语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Perter J.Landin)发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

4、字节码生成

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

如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、访问性(public、protected 或 private)与当前类一直的默认构造函数,这个工作在填充符号表阶段就已经完成)。除了生成构造器以外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBuffer 或 StringBuilder(取决于目标代码的版本是否大于或等于 JDK 1.5)的 append() 操作等。

10.3语法糖的味道

10.3.1泛型与类型擦除

泛型技术在 C# 和 Java之中的使用方式看似相同,但实现上却有着根本性的分歧,C# 里面泛型无论是在程序源码中、编译后的 IL 中(Intermediate Language,中间语言,这时候泛型是一个占位符),或是运行期的 CLR 中,都是切实存在的,List 与 List 就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

Java 语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制类型代码,因此,对于运行期的 Java 语言来说,ArrayList 与 ArrayList 就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

代码清单 10-2 是一段简单的 Java 泛型的例子,我们可以看一下它编译后的结果是怎样的。

代码清单 10-2 泛型擦除前的例子

public static void main(String[] args) {  
    Map<String, String> map = new HashMap<String, String>();  
    map.put("hello", "你好");  
    map.put("how are you?", "吃了没?");  
    System.out.println(map.get("hello"));  
    System.out.println(map.get("how are you?"));  
}  

把这段 Java 代码编译成 Class 文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了(用jd-gui 查看发现声明的时候泛型还在,其他地方就变成了强制类型转换),程序又变回了 Java 泛型出现之前的写法,泛型类型都变回了原生类型,如代码清单 10-3 所示。

代码清单 10-3 泛型擦除后的例子
技术分享

当泛型遇见重载
1、当泛型遇见重载 1

public class GenericTypes {  

    public static void method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
    }  

    public static void method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
    }  
}  

请想一想,上面这段代码是否正确,能否编译执行?也许你已经有了答案,这段代码是不能被编译的,因为参数 List 和 List 编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两种方法的特征签名变得一模一样。初步看来,无法重载的原因已经找到了,但真的就是如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的其中一部分原因,请再接着看一看代码清单 10-5 中的内容。

public class GenericTypes {  

    public static String method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
        return "";  
    }  

    public static int method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
        return 1;  
    }  

    public static void main(String[] args) {  
        method(new ArrayList<String>());  
        method(new ArrayList<Integer>());  
    }  

}  

执行结果:

invoke method(List<String> list)  
invoke method(List<Integer> list)  

代码清单 10-5 与代码清单 10-4 的差别是两个 method 方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行(注:测试的时候请使用 Sun JDK 1.6(1.7 和 1.8 也无法进行编译) 进行编译,其他编译器,如 Eclipse JDT 的 ECJ 编译器,仍然可能会拒绝这段代码)了。这是对 Java 语言中返回值不参与重载选择的基本认知的挑战吗?

代码清单 10-5 中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个 method() 方法加入了不同的返回值后才能共存在一个 Class 文件之中。前面介绍 Class 文件方法表(method_info)的数据结构时曾经提到过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个 Class 文件中的。

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

10.3.3条件编译 略

个人感觉C#在语言层面上比Java优雅太多太多。

深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)PDF版下载:
http://download.csdn.net/detail/xunzaosiyecao/9648998

作者:jiankunking 出处:http://blog.csdn.net/jiankunking

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    深入理解JVM读书笔记四: (早期)编译器优化