JVM性能优化系列-(5) 早期编译优化

5. 早期编译优化

早起编译优化主要指编译期进行的优化。

java的编译期可能指的以下三种:

  1. 前端编译器 :将 .java文件变成 .class文件,例如Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)
  2. JIT编译器(Just In Time Compiler) :将字节码变成机器码,例如HotSpot VM的C1、C2编译器
  3. AOT编译器(Ahead Of Time Compiler) :直接把*.java文件编译成本地机器码,例如GNU Compiler for the Java(GCJ)、Excelsior JET

本文中涉及到的编译器都仅限于第一类,第二类编译器跟java语言的关系不大。javac这类编译器对代码的运行效率几乎没有任何优化措施,但javac做了许多针对java语言代码过程的优化措施来改善程序员的编码风格和提高编码效率,java许多的语法特性都是靠编译器的语法糖来实现的。

5.1 javac编译器工作流程

Sun javac编译器的编译过程可以分为3个过程:

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

1. 解析与填充符号表

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

词法、语法分析:词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。

填充符号表:符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,可以想象成K-V的形式。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据

2. 注解处理器

注解处理器是用于提供对注解的支持,可以将其看成一组编译器的插件。

3. 语义分析与字节码生成

语法分析后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。

这部分主要分如下几步,完成语义分析与字节码生成:

  1. 标注检查

标注检查检查的内容包括变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查中,还有一个重要的动作称为常量折叠,这使得a=1+2比起a=3不会增加任何运算量

  1. 数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证,可以检查出诸如程序局部变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理等

  1. 解语法糖

语法糖(Syntactic Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但方便使用。java在现代编程语言中属于低糖语言,java中的主要语法糖包括泛型、可变参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖

  1. 字节码生成

字节码生成阶段不仅仅时把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作

5.2 Java语法糖

语法糖主要是为了方便程序员的代码开发,这些语法糖并不会提供实质性的功能改进,但是他们能提高效率。

以下介绍了Java中常用的语法糖。

泛型与类型擦除

Java中的参数化类型只在源码中存在,在编译后的字节码中,已经被替换为原来的原生类型了,并且在相应的地方插入了强制转换代码。对于运行期的Java 语言来说,ArrayList

和ArrayList

就是同一个类。所以说泛型技术实际上就是 Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

以下两个方法,在编译时,由于类型擦除,变成了一样的原生类型List

,因此方法的特征签名变得一致,导致无法编译。

void method(List list);
void method (List list);

但是如果两者的返回值不一致,在JDK1.6中则可以编译通过,并不是因为返回值不同,所以重载成功。只是因为加入返回值后,两个方法的字节码特征签名不一样了,所以可以共存。但是在JDK1.7和1.8中,依然无法通过,会报两个方法在类型擦除后具有相同的特征签名。

Java代码中的方法特征签名只包含方法名称、参数顺序和参数类型,而在字节码中的特征签名还包括方法返回值及受查异常表。

方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择。但是在Class字节码文件中,只要描述符不是完全一致的两个方法就可以共存。

自动装箱和拆箱

自动装箱和拆箱实现了基本数据类型与对象数据类型之间的隐式转换。

public void autobox() {
    Integer one = 1;
    if (one == 1) {
        System.out.println(one);
    }
}

下面对自动装箱和自动拆箱进行详细介绍:

自动装箱就是Java自动将原始类型值转换成对应的对象,比如将int的变量转换成Integer对象,这个过程叫做装箱,反之将Integer对象转换成int类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型byte,short,char,int,long,float,double和boolean对应的封装类为Byte,Short,Character,Integer,Long,Float,Double,Boolean。

何时发生自动装箱和拆箱,

  1. 赋值:
Integer iObject = 3; //autobxing - primitive to wrapper conversion
int iPrimitive = iObject; //unboxing - object to primitive conversion
  1. 方法调用:当我们在方法调用时,我们可以传入原始数据值或者对象,同样编译器会帮我们进行转换。
public static Integer show(Integer iParam){
   System.out.println("autoboxing example - method invocation i: " + iParam);
   return iParam;
}

//autoboxing and unboxing in method invocation
show(3); //autoboxing
int result = show(3); //unboxing because return type of method is Integer

自动装箱的弊端,

自动装箱有一个问题,那就是在一个循环中进行自动装箱操作的时候,如下面的例子就会创建多余的对象,影响程序的性能。

Integer sum = 0;
 for(int i=1000; i<5000; i++){
   sum+=i;
}

自动装箱与比较:

下面程序的输出结果是什么?

public class Main {
    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;
        Long h = 2L;
         
        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));
        System.out.println(g.equals(a+h));
    }
}

在解释具体的结果时,首先必须明白如下两点:

  • 当”==”运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)。
  • 对于包装器类型,equals方法并不会进行类型转换。

下面是程序的具体输出结果:

true
false
true
true
true
false
true

注意到对于Integer和Long,Java中,会对-128到127的对象进行缓存,当创建新的对象时,如果符合这个这个范围,并且已有存在的相同值的对象,则返回这个对象,否则创建新的Integer对象。

对于上面的结果:

c==d:指向相同的缓存对象,所以返回true;

e==f:不存在缓存,是不同的对象,所以返回false;

c==(a+b):直接比较的数值,因此为true;

c.equals(a+b):比较的对象,由于存在缓存,所以两个对象一样,返回true;

g==(a+b):直接比较的数值,因此为true;

g.equals(a+b):比较对象,由于equals也不会进行类型转换,a+b为Integer,g为Long,因此为false;

g.equals(a+h):和上面不一样,a+h时,a会进行类型转换,转成Long,接着比较两个对象,由于Long存在缓存,所以两个对象一致,返回true。

关于equals和==:

.equals(...)
equals(Object o)
Object#equals(Object o)

遍历循环

遍历循环语句是java5的新特征之一,在遍历数组、集合方面,为开发人员提供了极大的方便。

public void circle() {
    Integer[] array = { 1, 2, 3, 4, 5 };

    for (Integer i : array) {

    System.out.println(i);

    }
}

在编译后的版本中,代码还原成了迭代器的实现,这也是为遍历循环需要被遍历的类实现Iterable接口的原因。

变长参数

Arrays.asList(1, 2, 3, 4, 5);

条件编译

条件编译也是java语言的一种语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉。

public void ifdef() {if (true) {

System.out.println("true");

} else {//此处有警告--DeadCode

System.out.println("false");

}

}

对枚举和字符串的switch支持

public void enumStringSwitch() {

String str = "fans";

switch (str) {

case "fans":

break;case "leiwen":

break;default:

break;

}

}

try-with-resources

在try语句中定义和关闭资源 jdk7提供了try-with-resources,可以自动关闭相关的资源(只要该资源实现了AutoCloseable接口,jdk7为绝大部分资源对象都实现了这个接口)。

staticStringreadFirstLineFromFile(Stringpath)throwsIOException{

try(BufferedReaderbr=newBufferedReader(newFileReader(path))){

returnbr.readLine();
}
}

本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,立刻获取最新文章和 价值2000元的BATJ精品面试课程