Java早期编译优化

概述

Java语言编译期是一段不确定的操作过程,可能指前端编译器(叫编译器的前端更准确)把*.java文件转变为*.class文件的过程,也可能指虚拟机后端编译器(JIT编译器 Just Time Compiler)把字节码转为机器码的过程;还可能指静态提前编译器(AOT编译器)直接把*.java文件编译为本地机器代码的过程。

列举下这三类编译过程中又代表性的编译器:

前端编译器:Sun的Javac,Eclipse JDT中的增量式编译器(ECJ)。

JIT编译器:HotSpot VM中的C1 C2编译器。

AOT编译器:GCJ  JET

我们对于优化二字定义稍微宽松一些,因为Javac编译器对代码运行效率几乎没有任何优化措施,虚拟机设计团队将对性能的优化集中到了后端的JIT中,这样可以让那些不是由javac产生的class文件(Groovy JRuby等语言的class文件)也同样能享受到编译器优化带来的好处。但是Javac做了许多针对编码的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的Java语法特性,都是靠编译器的语法糖来实现,而不是依赖虚拟机的底层改进来支持。Java中JIT在运行期的优化过程对于程序运行更加重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

Javac编译器

将Javac编译器源码导入到eclipse中后,就可以运行javac.Main的main方法来执行编译,与命令行中使用javac的命令没有区别,编译的文件与参数在Eclipse的Debug Configurations面板中的Arguments页签中指定。

虚拟机规范严格定义了Class文件的格式,但是并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的,在一些极端情况,可能出现一段代码Javac编译器可以编译,但是ECJ无法编译的问题(后面的泛型擦除问题),从Sun Javac代码来看,编译过程大致可以分为3个过程:

1.解析与填充符号表过程

2.插入式注解处理器的注解处理过程

3.分析与字节码生成过程

Java编译过程的入口是javac.main.JavaCompiler类,上面三个过程的代码逻辑集中在这个类的compile和compile2方法中,主体代码如下:

 

解析与填充符号表

解析步骤包括了经典程序编译原理中的词法分析语法分析两个过程。

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

2.语法分析时根据token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式。语法树的每一个节点都代表程序代码中一个语法结构,如:包 类 修饰符 运算符 甚至注释 等等  语法树由javac.parser.Parser类实现

完成语法词法分析后,下一步就是填充符号表的过程,符号表是由一组符号地址与符号信息构成的表格,可以想象为哈希表中K-V键值对的形式(实际上还可以是list  tree 等),在Javac源代码中,填充符号表由javac.comp.Enter类实现。

注解处理器

jdk1.5以后,Java语言提供了对注解的支持,注解与java代码一样在运行期发挥作用。jdk1.6中提供了插入式注解处理器的标准API在编译期间对注解进行处理,可以将其看做编译器的插件,可以读取、修改、添加抽象语法树中任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是上图的回环过程。

有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器行为,由于语法树中任意元素,甚至代码注释都可以在插件中访问到,所以通过插入式注解处理器来实现许多只能在编码中完成的事。

语义分析与字节码生成

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

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

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

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

后续代码如果出现了如上3种赋值运算的话,都能构成结构正确的语法树,但是只有第一种写法在语义上没有问题,能够通过编译。

语义分析过程分为标注检查数据及控制流分析两个步骤。

1.标注检查

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

int a = 1+2;

在语法树上仍能看到字面量1 2以及操作符+,但是经过常量折叠之后,他们将会被折叠为字面量3,由于编译期间进行了常量折叠,所以在代码里面定义a=1+2比起直接定义c=3并不会增加程序运行期哪怕仅仅一个CPU指令的运算量。标注检查步骤在javac源码中的实现类是javac.comp.Attr类和javac.comp.Check类。

2.数据及控制流分析

数据及控制流分析是对程序上下文逻辑的进一步验证,可以检查出诸如程序局部变量使用前是否赋值,方法的每条路径是否都有返回值等。编译器的数据及控制流分析与类加载时的数据及控制流分析的目的基本是一致的,但校验范围有所区别,有一些校验只有在编译期或运行期才能进行。举一个关于final修饰符的数据及控制流分析的例子:

 public void foo(final int arg){
      final int var = 0;
   }
   public void foo(int arg){
      int var = 0;
   }

这两个foo中,第一种方法的参数和局部变量定义使用了final修饰符,而第二种方法没有,在代码编写时程序肯定会受到final修饰符的影响,不能再改变arg和var的值,但是这两段代码编译出来的class文件是没有区别的!! 因为局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有CONSTANT_Fieldref_info的符号引用,自然就没有访问标志(Access_Flags)的信息,甚至可能连名称都不会保留下来(取决于编译时的选项),自然在class文件中不可能知道一个局部变量是不是声明为final了。因此将局部变量声明为final,对运行期没有影响,变量的不变性仅仅由编译器在编译期间保障。在Javac源码中,数据及控制流分析的入口是flow方法,由javac.comp.Flow类完成。

3.解语法糖

Java最常用的语法糖主要由泛型、变参、自动装箱/拆箱等,虚拟机运行时不支持这些语法,他们在编译阶段还原简单的基础语法结构,这个过程称为解语法糖。   javac源码中,解语法糖过程由desugar方法触发,在comp.TransTypes类和comp.Lower类中完成。

4.字节码生成

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

例如,前面提到的实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段添加到语法树中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对实例构造器而言是{}块,对类构造器而言是static{}块)、变量初始化(实例变量和类变量)、调用父类实例构造器(仅仅是实例构造器)等操作收敛到<init>和<clinit>方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。  除了生成构造器以外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于jdk是否大于1.5)的append操作等。

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

Java语法糖的味道

语法糖可以看做编译器实现的一些小把戏,这些小把戏可能会使效率提升,但是我们也应该了解这些小把戏背后真实世界,那样才能利用好它们,不被迷惑。

泛型与泛型擦除

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

泛型思想起源于c++,在Java还没有出现泛型版本时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如在HashMap的get方法中,返回值就是一个Object对象,由于所有类型都继承于Object,所以Object转型为任何对象都有可能。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是什么类型的对象。在编译期间,编译器无法检查这个Object强制转型是否成功,如果仅仅依赖程序员去保证这项操作的正确性,许多风险就会转嫁到程序运行期中。

泛型技术在C#和Java中使用方式看似相同,实现上却有根本性分歧,C#里面泛型无论在程序源码中、编译后、运行期都是切实存在的,List<int> 与 List<String>就是两个不同的类型,他们在运行期生成,有自己的虚方法和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型

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

通过一段代码来验证一下:

泛型擦除前的例子

public class Test01 {

   public static void main(String[] args) {
      Map<String,String> map = new HashMap<String,String>(); //hashMap后面不加泛型javac会警告
      map.put("hello","你好");
      map.put("how are you?","你好吗");
      System.out.println(map.get("hello"));
      System.out.println(map.get("how are you?"));
   }

}

编译后,再反编译,  泛型都不见了:

public class Test01
{
  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?"));
  }
}

JDK团队为什么选择类型擦除的方式来实现Java语言的泛型支持呢?因为实现简单、兼容性考虑还是别的原因呢?我们不得而知,有不少人对Java提供的伪泛型颇有微词,在众多批评中,有一些比较表面,还有一些从性能上说泛型会由于强制转型操作和运行期缺少针对类型的优化等 从而导致比C#的泛型慢一些,这种说法完全偏离了方向,姑且不论Java泛型是不是真的比C#泛型慢,从性能的角度上评价用于提升语义准确性的泛型思想就不太恰当。 泛型在某些场景下确实存在不足!通过擦除法实现泛型丧失了一些泛型思想应有的优雅!

当泛型遇见重载1

上面这段代码不能被编译,因为参数List<Integer>和List<String>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。  泛型擦除成相同的原生类型只是无法重载的其中一部分原因,再看一段代码:

泛型遇见重载2

(上面代码只有在jdk1.6的Javac编译器进行编译才能通过)加入两个不同的返回值后,重载竟然成功了。即这段代码可以被编译和执行了。这是对Java语言中返回值不参与重载选择的基本认知的挑战。

上面代码的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个method方法加入了不同的返回值后才能共存于一个class文件中。 前面说过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在class文件格式中,只要描述符不完全一直就可以共存。  也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那他们也是可以合法共存于一个class文件。

由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。引入了注入Signature等新的属性用于解决伴随泛型而来的参数类型识别问题,signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息,虚拟机规范要求49以上版本的虚拟机都要能正确识别signature。

擦除法所谓的擦除,仅仅是对方法的code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射获取参数化类型的根本依据。

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

从纯技术角度讲,自动装箱、自动拆箱与循环遍历这些语法糖,无论是实现上还是思想上都不能和上文的泛型相比较,两者的难度与深度有很大差距。之所以特地讲一下,是因为它们是Java中使用得最多的语法糖。下面举例说明:

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

public class Test01 {
   public static void main(String[] args) {
         List<Integer> list = Arrays.asList(1,2,3,4);
         int sum = 0;
         for(int i:list){
            sum+=i;
         }
      System.out.println(sum);
   }
}

javac,再反编译

public class Test01
{
  public static void main(String[] paramArrayOfString)
  {
      List localList = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) });
      int i = 0;
    for (Iterator localIterator = localList.iterator(); localIterator.hasNext(); ) {
      int j = ((Integer)localIterator.next()).intValue();
      i += j;
    }
    System.out.println(i);
  }
}

上面代码包含了泛型、自动装箱、自动拆箱、遍历循环、变参5种语法糖,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf(装箱)  Integer.intValue(拆箱),而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看下变参,在调用时变成了一个数组类型的参数,在变参出现前,程序员就是使用数组来完成类似功能的。

自动装箱的陷阱

public class Test01 {
   public static void main(String[] paramArrayOfString)
   {
      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
   }
}

为什么结果是这样呢?

我们反编译下:

public class Test01
{
  public static void main(String[] paramArrayOfString)
  {
    Integer localInteger1 = Integer.valueOf(1);
    Integer localInteger2 = Integer.valueOf(2);
    Integer localInteger3 = Integer.valueOf(3);
    Integer localInteger4 = Integer.valueOf(3);
    Integer localInteger5 = Integer.valueOf(321);
    Integer localInteger6 = Integer.valueOf(321);
    Long localLong = Long.valueOf(3L);
    System.out.println(localInteger3 == localInteger4);
    System.out.println(localInteger5 == localInteger6);
    System.out.println(localInteger3.intValue() == localInteger1.intValue() + localInteger2.intValue());
    System.out.println(localInteger3.equals(Integer.valueOf(localInteger1.intValue() + localInteger2.intValue())));
    System.out.println(localLong.longValue() == localInteger1.intValue() + localInteger2.intValue());
    System.out.println(localLong.equals(Integer.valueOf(localInteger1.intValue() + localInteger2.intValue())));
  }
}

首先涉及到IntegerCache,在-128-127之间的数据valueOf,会返回IntegerCache,所以这个范围内的数==为true

然后如果是 对象 == 数字时,会通过intValue都转为数字

如果是对象.equals(数字)将会把数字的和转为对象

long对象 == 数字时,同理将对象转为数字,long.longValue返回数字

long对象.equals(int值) 由于是两个类中的缓存数组中的数据,所以必然是false

条件编译

许多语言都提供了条件编译,如C,C++中使用预处理器指示符来完成条件编译。而在Java语言中并没有使用预处理器,因为Java语言的天然编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。那Java语言是否有办法实现条件编译呢?

Java语言当然能实现条件编译,方法就是使用条件为常量的if语句。下面代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码中只包括System.out.println("block 1")一条语句

上述代码编译后反编译:

只有使用条件为if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能会在控制流分析中提示错误,被拒绝编译。

Java中条件编译的实现,也是语法糖,根据布尔值常量的真假,编译器将会把分支中不成立的代码块清除掉,这一工作将在编译器解除语法糖阶段完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block级别)的条件编译,而没有办法实现根据条件调整整个Java类的结构。

处理本节中介绍的泛型、自动装箱、自动拆箱、变量循环、变长参数、条件编译为,java语言中还有不少其他的语法糖,如内部类、枚举类等等

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值