JVM:早期(编译期)优化的深入理解

早期(编译期)优化

JVM的编译器可以分为三个编译器:

  1. 前端编译器:把*.java转变为*.class的过程。如Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)
  2. JIT编译器:把字节码转变为机器码的过程,如HotSpot VM的C1、C2编译器
  3. AOT编译器:静态提前编译器,直接将*.java文件编译本地机器代码的过程

本章的后续文字里,“编译期”和“编译器”都仅限于第一类编译过程

1、Javac编译器

Javac编译器本身就是一个由Java语言编写的程序

1)、Javac的源码与调试

Javac的源码存放在JDK_SRC_HOME/langtools/src/share/slasses/com/sun/tools/javac中

编译过程大致可以分为3个过程:

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

这3个步骤之间的关系与交互顺序如下:

2)、解析与符号填充表

解析步骤由parseFiles()方法完成,解析步骤包括了词法分析和语法分析两个过程

A.词法分析与语法分析

词法分析:将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表述方式。语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、接口、返回值甚至代码注释都可以是一个语法结构。语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树由com.sun.tools.javac.tree.JCTree类表示,经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都是建立在抽象语法树之上的

B.填充符号表

完成抽象语法树之后,下一步就是填充符号表的过程,即enterTrees()方法。符号表是由一组符号地址和符号信息构成的表格,类似于哈希表中K-V值对的形式。符号表中所登记的信息在编译的不同阶段都要用到。当对符号名进行地址分配时,符号表是地址分配的依据。填充过程由com.sun.tools.javac.comp.Enter类实现

3)、注解处理器

JDK1.5之后,Java提供了对注解的支持,这些注解与普通的Java代码一样,在运行期间发挥作用。 有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以使用插入式注解处理器在功能上有很大的发挥空间

4)、语义分析与字节码生成

语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查

A.标注检查

Javac的编译过程中,语义分析过程分为标注检查以及数据及控制流分析两个步骤,分别是attribute()和flow()方法

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

int a = 1 + 2;

语法树上仍然能看到字面量“1”、“2”以及操作符“+”,但是在经过常量折叠以后,它们将会被折叠为字面量“3”。由于编译期间进行了常量折叠,所以在代码里面定义“a=1+2”比起直接定义“a=3”,并不会增加程序运行期哪怕仅仅一个CPU指令的运算量

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

B.数据及控制流分析

数据及控制流分析可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题

局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有CONSTANT_Fielddref_info的符号引用,自然就没有访问标志的信息,因此,将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。在Javac的源码中,数据及控制流分析的入口是flow()方法,具体操作由com.sun.tools.javac.comp.Flow类来完成

C.解语法糖

语法糖是指在计算机语言中添加某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用
Java是一种“低糖语言”,常用的语法糖主要是之前提到的泛型、变长参数、自动装箱/拆箱等。虚拟机运行时不支持这些语法,它们在编译期还原回简单的基础语法结构,这个过程称为解语法糖。解语法糖的过程是由desuger()方法触发的

D.字节码生成

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

实例构造器<init>()方法和类构造器<client>()方法就是在这个阶段添加到语法树之中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器而言是{}块,对于类构造器而言是static{}块)、变量初始化(实例变量和类变量)、调用父类的实例构造器等操作收敛到<init>()和<client>()方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述的动态由Gen.normalizeDefs()方法来实现

完成对语法树的遍历与调整之后,就会把填充了所有所需信息的符号表交给com.sun.tools.javac.jvm.ClassWriter类,由这个类的wrtieClass()方法输出字节码,生成最终的Class文件

2、Java语法糖的味道

1)、泛型与类型擦除

泛型是JDK1.5的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法

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

 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文件,然后再用字节码反编译工具进行反编译后,代码如下:

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

当泛型遇到重载:

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

这段代码是不能被编译的,因此参数List<String>和List<Integer>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样

2)、自动装箱、拆箱与遍历循环

自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因,变长参数在调用的时候变成了一个数组类型的参数

 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);// true
 System.out.println(e == f);// false
 System.out.println(c == (a + b));// true
 System.out.println(c.equals(a + b));// true
 System.out.println(g == (a + b));// true
 System.out.println(g.equals(a + b));// false
 }

包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系

3)、条件编译

Java语言使用条件为常量的if语句,此代码中的if语句不同于其他Java代码,它在编译阶段就会被运行,生成的字节码之中只包含条件正确的部分

 public static void main(String[] args) {
 if (true) {
  System.out.println("block 1");
 } else {
  System.out.println("block 2");
 }
 }

Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这是在解语法糖阶段实现的

Java语言中还有不少的其他语言糖,如内部类、枚举类、断言语句、对枚举和字符串的switch支持、try语句中定义和关闭资源等等

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对呐喊教程的支持。如果你想了解更多相关内容请查看下面相关链接

声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:notice#nhooo.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。