Skip to content

Latest commit

 

History

History
608 lines (470 loc) · 24.8 KB

8.前端编译与优化.md

File metadata and controls

608 lines (470 loc) · 24.8 KB

前端编译与优化

目录


1. 概述

编译期没有具体的上下文语境的话,其实是一个模糊的表述。它可能是下面3种情况:

  • 前端编译器(叫“编译器的前端“更准确)把.java文件转换成.class文件的过程
  • Java虚拟机的即时编译器(JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程
  • 使用静态的提前编译器(AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程

Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由javac产生的class文件(如JRuby、Groovy等语言的class文件)也同样能享受到编译器优化措施所带来的性能红利。

相当多新生的Java语法特性,都是靠编译器的语法糖来实现的,而不是依赖字节码或者Java虚拟机的底层改进来支持。

2. javac编译器

2.1 javac的编译过程

大致可以分成4个步骤:

  1. 准备过程:初始化插入式注解处理器
  2. 解析与填充符号表过程
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树
    • 填充符号表。产生符号地址和符号信息
  3. 插入式注解处理器的注解处理过程
  4. 分析与字节码生成过程
    • 标注检查:对语法的静态信息进行检查
    • 数据流及控制流分析:对程序动态运行过程进行检查

2.2 解析与填充符号表

词法、语法分析

词法分析是将源代码的字符流转变为标记集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记。

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

填充符号表

完成了语法分析和词法分析之后,下一阶段是对符号表进行填充的过程。符号表是由一组符号地址和符号信息构成的数据结构。

2.3 注解处理器

JDK6设计了一组被称为插入式注解处理器的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。

有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间(例如:编译时获取注解信息,并据此产生Java代码文件,无性能损失,如ButterKnife、Dagger2等)。

2.4 语义分析与字节码生成

经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查等等。

我们在编码时经常能在IDE中看到红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。

2.4.1 标注检查

javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤。

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等等。在标注检查中,还会顺便进行一个称为常量折叠的代码优化,这是javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。

int a = 1 + 2;

上面这段代码在抽象语法树上仍然能看到字面量12和操作符+号,但是经过常量折叠优化之后,它们将会被折叠为字面量3。由于编译期间进行了常量折叠,所以在代码里定义a=1+2比起直接定义a=3并不会增加程序运行期哪怕仅仅一个处理器时钟周期的处理工作量。

2.4.2 数据及控制流分析

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

2.4.3 解语法糖

语法糖指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。

Java中最常见的语法糖包括:泛型、变长参数、自动装箱拆箱等等。Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程被称为解语法糖。

2.4.4 字节码生成

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

例如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。

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

3. Java语法糖

语法糖可以方便程序员的代码开发,虽然它们不会提供实质性的功能改进,但是它们能提高效率。语法糖总体可以看做是前端编译器的一些小把戏,最后还是得还原才能在虚拟机上运行。

3.1 泛型

泛型的本质是参数化类型或参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成了泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。

Java选择的泛型实现方式叫作类型擦除式泛型。Java的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制转型代码。因此对于运行期的Java来说,ArrayList<int>ArrayList<String>其实是同一个类型。

下面是Java中不支持的泛型用法:

public class TypeErasureGenerics<E> {
    public void doSomething(Object item) {
        if(item instanceof E) {      //不合法,无法对泛型进行实例判断
            ......
        }
        E newItem = new E();         //不合法,无法使用泛型创建对象
        E[] itemArray = new E[10];   //不合法,无法使用泛型创建数组
    }
}

Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型。那Java为啥要选择擦除式泛型来实现:这里有一点历史原因,为了兼容老版本。Java泛型的唯一优势就在于实现这种泛型的影响范围上,擦除式泛型的实现几乎只需要在javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java5.0之上。

3.1.1 类型擦除

裸类型:应该被视为所有该类型泛型化实例的共同父类型,只有这样,像下面代码中的赋值才是被系统允许的从子类到父类的安全转型。

ArrayList<Integer> ilist = new ArrayList<>();
ArrayList<String> slist = new ArrayList<>();
ArrayList list; //裸类型
list = ilist;
list = slist;

下面举个例子来验证一下Java的泛型擦除。

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

把这段代码编译成class文件之后,再用JD-GUI工具将其反编译。将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从Object到String的强制转型代码。

//反编译出来的代码  泛型擦除
public static void main(String[] args) {
    HashMap map = new HashMap();
    map.put("hello", "你好");
    map.put("how are you", "吃了没");
    System.out.println((String)map.get("hello"));
    System.out.println((String)map.get("how are you"));
}

类型擦除的缺陷

首先,使用擦除法实现泛型直接导致了对原始类型数据的支持又成了新的麻烦。如下:

//原始类型的泛型(目前的Java不支持)
ArrayList<int> ilist = new ArrayList<int>(); 
ArrayList<long> llist = new ArrayList<long>(); 
ArrayList list;
list = ilist;
list = llist;

这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴:既然没法转换那就索性别支持原生类型的泛型了吧,都用ArrayList<Integer>ArrayList<Long>,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因。

第二,运行期无法取到泛型类型信息,会让一些代码变得想当啰嗦。

//不得不加入的类型参数
public static <T> T[] convert(List<T> list, Class<T> componentType) { 
    T[] array = (T[])Array.newInstance(componentType, list.size()); 
    ...
}

需要注意的是,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

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

自动装箱、拆箱与遍历循环(for-each循环)这些语法糖在Java语言里面是被使用最多的。举个简单例子:

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);
}

这段代码编译之后是这样的:

public static void main(String[] args) {
    //1. 泛型没了
    //2. Arrays.asList()这里是变长参数,变长参数最终是会转换成数组的形式
    List list = Arrays.asList( new Integer[] {
        //3. 自动装箱了
        Integer.valueOf(1),
        Integer.valueOf(2),
        Integer.valueOf(3),
        Integer.valueOf(4) });
    int sum = 0;
    //4. for-each 其实是利用Iterator来进行的遍历
    for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
        //5. 自动拆箱了
        int i = ((Integer)localIterator.next()).intValue();
        sum += i;
    }
    System.out.println(sum);
}

自动装箱好是好,但也有一些陷阱,先来看段代码:

public class Temp {
    public static void main(String[] args) {
        Integer a = 1;
        Integer b = 2;

        //Integer默认是缓存-128到127的实例,有缓存则直接用已有的
        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 integer3.equals(Integer.valueOf(integer1.intValue() + integer2.intValue()))
        System.out.println(g == (a + b));//true 有算术运算,自动拆箱了
        System.out.println(g.equals(a + b)); //false  类型不一样
    }
}

//编译后
public class Temp {
  public static void main(String[] paramArrayOfString) {
    Integer integer1 = Integer.valueOf(1);
    Integer integer2 = Integer.valueOf(2);
    Integer integer3 = Integer.valueOf(3);
    Integer integer4 = Integer.valueOf(3);
    Integer integer5 = Integer.valueOf(321);
    Integer integer6 = Integer.valueOf(321);
    Long long_ = Long.valueOf(3L);
    System.out.println((integer3 == integer4));
    System.out.println((integer5 == integer6));
    System.out.println((integer3.intValue() == integer1.intValue() + integer2.intValue()));
    System.out.println(integer3.equals(Integer.valueOf(integer1.intValue() + integer2.intValue())));
    System.out.println((long_.longValue() == (integer1.intValue() + integer2.intValue())));
    System.out.println(long_.equals(Integer.valueOf(integer1.intValue() + integer2.intValue())));
  }
}

我已经将打印结果放注释里面了。包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系,建议在实际编码中尽量避免这样使用自动装箱与拆箱。

3.3 条件编译

Java语言中并没有预处理器,因为Java天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。

曲线救国,Java语言也是可以进行条件编译的,方法就是使用条件为常量的if语句。如下面代码所示,该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括if里面的那条语句,而else里面的就被舍弃掉了。

//java语言的条件编译
public static void main(String[] args) {
    if (true) {
        System.out.println("block 1");
    } else {
        System.out.println("block 2");
    }
}

//编译之后
public static void main(String[] args) {
    System.out.println("block 1");
}

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

//不能使用其他条件语句来完成条件编译
public static void main(String[] args) {
    // 编译器将会提示“Unreachable code”
    while (false) {
        System.out.println("");
    }
}

这其实也是Java中的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。 这种方式只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。

3.4 其他语法糖

除上述泛型、自动装箱、自动拆箱、循环遍历、变长参数和条件编译外,还有一些其他的语法糖,一一来简单看一下。

3.4.1 内部类

在使用非静态内部类时,内部类自动持有外部类的引用。而且编译器会为内部类生成一个新的class文件。

public class InnerClass {

    public void test() {
        Builder builder = new Builder();

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(builder);
            }
        };
        runnable.run();
    }

    public class Builder {
        private String name;
    }

}

上面是一段简单的代码,编译后会生成3个class文件InnerClass$1.classInnerClass$Builder.classInnerClass.class。它们编译之后的class反编译出来的代码分别如下:

//InnerClass$1.class
class InnerClass$1 implements Runnable {
    final InnerClass this$0;
    final InnerClass.Builder val$builder;

    InnerClass$1(InnerClass this$02, InnerClass.Builder builder) {
        this.this$0 = this$02;
        this.val$builder = builder;
    }

    public void run() {
        System.out.println(this.val$builder);
    }
}

//InnerClass$Builder.class
public class InnerClass$Builder {
    private String name;
    final InnerClass this$0;

    public InnerClass$Builder(InnerClass this$02) {
        this.this$0 = this$02;
    }
}

//InnerClass.class
public class InnerClass {
    public void test() {
        new 1(this, new Builder(this)).run();
    }
}

从上面我们可以得出一些结论:

  1. 外部类的引用通过构造方法传进去的,内部类一直持有着
  2. 非静态内部类里面使用了外部的数据,也是通过构造方法传进去的
  3. 非静态内部类会自动生成一个新的class文件
3.4.2 枚举类

其实enum就是一个普通的类,它继承自java.lang.Enum类。在JVM字节码文件结构中,并没有枚举这个类型,编译器会在编译期将其编译成一个普通的类。

public enum Fruit {
    APPLE,ORINGE
}

//编译之后
//继承java.lang.Enum并声明为final
public final class Fruit extends Enum
{

    public static Fruit[] values() {
        return (Fruit[])$VALUES.clone();
    }

    public static Fruit valueOf(String s) {
        return (Fruit)Enum.valueOf(Fruit, s);
    }

    private Fruit(String s, int i) {
        super(s, i);
    }
    //枚举类型常量
    public static final Fruit APPLE;
    public static final Fruit ORANGE;
    private static final Fruit $VALUES[];//使用数组进行维护

    static {
        APPLE = new Fruit("APPLE", 0);
        ORANGE = new Fruit("ORANGE", 1);
        $VALUES = (new Fruit[] {
            APPLE, ORANGE
        });
    }
}
3.4.3 数值字面量

Java支持的数值字面量:十进制、八进制(整数之前加0)、十六进制(整数之前加0x或0X)、二进制(整数之前加0b或0B)。在JDK7中,数值字面量的数字之间是允许插入任意多个下划线的,本身没有意义,只为方便阅读。

public class Test {
    public static void main(String[] args) {
        //十进制
        int a = 10;
        //二进制
        int b = 0B1010;
        //八进制
        int c = 012;
        //十六进制
        int d = 0XA;

        double e = 12_234_234.23;
        System.out.println("a:"+a);
        System.out.println("b:"+b);
        System.out.println("c:"+c);
        System.out.println("d:"+d);
        System.out.println("e:"+e);
    }
}

上面一段示例代码在编译之后是下面这样:

public class Test {

    public Test() {
    }

    public static void main(String args[]) {
        int a = 10;
        //编译器已经将二进制,八进制,十六进制数转换成了10进制数
        int b = 10;
        int c = 10;
        int d = 10;
        //编译器已经将下滑线删除
        double e = 12234234.23D;
                           //字符串+号替换成了StringBuilder的append
        System.out.println((new StringBuilder()).append("a\uFF1A").append(a).toString());
        System.out.println((new StringBuilder()).append("b\uFF1A").append(b).toString());
        System.out.println((new StringBuilder()).append("c\uFF1A").append(c).toString());
        System.out.println((new StringBuilder()).append("d\uFF1A").append(d).toString());
        System.out.println((new StringBuilder()).append("e\uFF1A").append(e).toString());
    }
}

在编译之后,全部都转换成了十进制,下划线也没了。同时,字符串+号替换成了StringBuilder的append。

3.4.4 对枚举和字符串的switch支持

switch对枚举和String的支持原理其实是差不多的。switch关键字原生只能支持整数类型,如果switch后面是String类型的话,编译器会将其转换成该字符串的hashCode的值,然后switch就通过这个hashCode的值进行case。

如果switch后面是Enum类型,则编译器会将其转换为枚举定义的下标,也还是整数类型。

String str = "world";
switch(str) {
    case "hello":
        System.out.println("hello");
        break;
    case "world":
        System.out.println("world");
        break;
    default:
        break;
}

编译之后的class再反编译之后的代码:

String str = "world";
String s;
switch((s = str).hashCode()) {
    default:
        break;
    case 99162322:
        //再次通过equals方法进行判断,因为不同字符串的hashCode值是可能相同的,比如“Aa”和“BB”的hashCode就是一样的
        if(s.equals("hello"))
            System.out.println("hello");
        break;
    case 113318802:
        if(s.equals("world"))
            System.out.println("world");
        break;
}
3.4.5 try语句中定义和关闭资源

当一个外部资源的句柄对象实现了AutoCloseable接口,JDK 7中便可以利用try-with-resource语法更优雅的关闭资源。

try (FileInputStream inputStream = new FileInputStream(new File("test"))) {
    System.out.println(inputStream.read());
} catch (IOException e) {
    throw new RuntimeException(e.getMessage(), e);
}

当这个try-catch代码块执行完毕后,Java会确保外部资源的close方法被调用。代码瞬间非常简洁,但是这只是语法糖,并不是JVM新增的功能。下面是编译之后的代码

try {
    FileInputStream inputStream = new FileInputStream(new File("test"));
    Throwable var2 = null;
    try {
        System.out.println(inputStream.read());
    } catch (Throwable var12) {
        var2 = var12;
        throw var12;
    } finally {
        if (inputStream != null) {
            if (var2 != null) {
                try {
                    inputStream.close();
                } catch (Throwable var11) {
                    var2.addSuppressed(var11);
                }
            } else {
                inputStream.close();
            }
        }
    }

} catch (IOException var14) {
    throw new RuntimeException(var14.getMessage(), var14);
}

编译器帮我们做了关闭资源的操作。

3.4.6 Lambda表达式

Lambda表达式用着很舒服,代码看着也简洁。它其实也是语法糖,由编译器推断并将其转换成常规的代码。

public class LambdaTest{

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        list.add("I");

        list.forEach( e -> System.out.println("输出:"+e));
    }
}

反编译后的代码:

public class LambdaTest {
    public static void main(String[] arrstring) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("I");
        arrayList.forEach((Consumer<String>)LambdaMetafactory.metafactory(null, null, null, (Ljava/lang/Object;)V, lambda$main$0(java.lang.String ), (Ljava/lang/String;)V)());
    }

    private static /* synthetic */ void lambda$main$0(String string) {
        System.out.println("\u6748\u64b3\u56ad:" + string);
    }
}

上面的Lambda表达式最终是被换成了Consumer(一个接口),然后在forEach方法里面循环调用Consumer的accept()方法。