本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。

记录日期:2022.1.4

大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。

JVM - 编译器优化机制(上)

参考《深入理解JVM虚拟机》第十章、第十一章。

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

  1. 前端编译器:把*.java文件转变为*.class文件的过程。如JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。
  2. 即时编译器(JIT编译器):运行期把字节码转变为本地机器码的过程。如HotSpot VM的C1、C2编译器,Graal编译器。
  3. 提前编译器(AOT编译器):直接把程序编译成与目标机器指令集相关的二进制代码的过程。如JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。

将前端编译器放到”前端编译“中讲,将JIT编译器、AOT编译器放到”后端编译“中讲。

前端编译

Javac编译器

历史

  1. JDK6以前,Javac并不属于标准Java SE API的一部分,它实现代码单独存放在tools.jar中,要在程序中使用的话就必须把这个库放到类路径上。
  2. 在JDK6在发布时通过了JSR199编译器API的提案,使得Javac编译器的实现代码晋升成为标准Java类库之一,它的源码被放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中。
  3. 在JDK9时,整个JDK所有的Java类库都采用模块化进行重构拆分,Javac编译器就被挪到了jdk.compiler 模块(路径为:JDK_SRC_HOME/src/jdk.compiler/share/classes/com/sun/tools/javac)里面。

本文将全部以JDK9之前的代码结构来说明。

编译过程

从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程3个处理过程,如下所示:

  1. 准备过程:初始化插入式注解处理器。

  2. 解析与填充符号表的过程,包括:

    → 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。

    → 填充符号表。产生符号地址和符号信息。

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

  4. 分析与字节码生成过程,包括:

    → 标注检查。对语法的静态信息进行检查。

    → 数据流及控制流分析。对程序动态运行过程进行检查。

    → 解语法糖。将简化代码编写的语法糖还原为原有的形式。

    → 字节码生成。将前面各个步骤生成的信息转化为字节码。

在上述的处理过程中,执行插入式注解时有可能会产生新的符号,如果有新的符号产生就必须转回之前的解析、填充符号表重新处理这些新符号,所以总体来看,之间的交互关系与交互顺序如图:

具体javac的源码以及解释内容还是自己看书或者去翻源码吧,我这是复习。。。感觉全部都是文字应该不容易理解,凑活着理解一下,画画图。

1. 解析与填充符号表

是处理过程的第一个过程,其中包括两点。

词法、语法分析

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

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

    比如“int a = b + 2”,这句代码中就包括6个标记,分别是inta=b+2,虽然关键字int是由3个字符组成,但是它是一个独立的标记,不可拆分,词法解析的过程是由com.sun.tools.javac.parser.Scanner类来实现。

  • 语法分析是根据标记序列来构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形标识方式,语法树的每个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等可以是一个语法结构。经过语法分析之后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上。

    比如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

填充符号表

完成词法分析和语法分析之后,下一步就是填充符号表的过程。

符号表是由一组符号地址和符号信息构成的表格,可以把它想象成哈希表中的K-V值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表和栈结构符号表等)。符号表所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

填充符号表的过程是由com.sun.tools.javac.comp.Enter类来实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级顶点,以及package-info-java(如果存在的话)的顶级顶点。

2. 注解处理器

是处理过程的第二个过程。

介绍

JDK1.5之后提供了对注解(Annotations)的支持,这些注解与普通的java代码一样,是在运行期间发挥作用的。

JDK1.6中又提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,我们可以把它看做是编译器的一组插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素,如果这些插件在处理注解期间对语法树进行了修改,那么编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有再对语法树进行修改为止,每一次回环称为一个Round,即前面图片的回环过程。

有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树的任何元素,设置代码注释都可以在插件之后访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。

举例

比如说lombok,通过注解自动生成getter/setter方法、进行置空检查、生成受查异常表等等,帮助开发人员消除Java的冗长代码,都是依赖插入式注解处理器来实现的。

源码实现

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

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

是处理过程的第三个过程,其中包括四点。

语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,譬如进行类型检查、控制流检查、数据流检查。语义分析分为标注检查数据及控制流分析两个步骤,生成字节码之前还会有解语法糖步骤。

标注检查

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

如果在代码中定义了int a = 1 + 2;,在语法树上仍然能够看到字面量“1”“2”“+”,但是经过常量折叠之后,他们将被折叠成“3”,这个插入式表达式的值已经在语法树上标注出来了。由于常量折叠的存在,在编译期直接定义int a = 1 + 2;与定义int a = 3;相比不会增加程序运行期的运算量。

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

数据流及控制流分析

数据及控制流分析是对程序上下文逻辑的更进一步验证,它可以检查出程序局部变量在使用前是否有赋值方法的每条路径是否都有返回值是否所有的受查异常都被正确处理了等问题。编译时期的数据及控制流分析与类加载时期的数据及控制流分析目的基本一样,但是校验范围有些区别,有些校验只能在编译期或者运行期才能进行。

下面参照书本内容使用关于final修饰符的数据及控制流分析的例子,代码如下。

//带有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修饰符的影响,不能再改变argvar变量的值。

但是这两段代码编译出来的字节码时相同的。

学习过Class文件结构就能明白,局部变量和类的字段的存储是有区别的,局部变量再常量池中并没有CONSTANT_Fieldref_info的符号引用,自然就不可能存储有访问标志的信息,甚至可能连变量名称都不一定会被保留下来,自然在Class文件中就不可能知道一个局部变量是不是被声明为final了。

因此把局部变量定义为final,对运行期是没有影响的,变量的不变性仅仅在编译期间来保障。

解语法糖(详细见→前端编译补充环节)

语法糖的概念、如何实现、被分解,见“前端编译补充环节”

在Javac的源码中,由com.sun.tools.javac.comp.Lower类中完成。

字节码生成

在Javac的源码中,由com.sun.tools.javac.jvm.Gen类来完成。

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

前面说的<init><cinit>就是在这个阶段被添加到语法树中的,这两个构造器实际是一个代码收敛的过程。

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

前端编译补充环节

Java语法糖

参考文章链接:JVM之javac编译器、java语法糖

语法糖(Syntactic Sugar),也称糖衣语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言,通常来说使用语法糖能够减少代码量、增加程序的可读性、从而减少程序代码出错的机会

编译期在生成字节码之前会将这些语法结构还原成基础语法结构,这个步骤称为解语法糖

java中的语法糖主要有泛型、自动拆装箱、遍历循环、变长参数、条件编译、内部类、枚举类、断言、switch 支持 String 与枚举(JAVA7)、try-with-resource(JAVA7)、数字字面量(JAVA7)、Lambda(JAVA8)。

泛型

泛型是JDK1.5的新特性,本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够在类、接口、方法的创建中,分别构成泛型类、泛型接口、泛型方法。

泛型擦除

在java中泛型只存在于程序源码中,在编译后的字节码文件中就已经替换为原来的原生类型(裸类型),并且在相应的地方插入了强制转型代码。

如对于运行期来说,ArrayList<Integer>ArrayList<String>都是ArrayList。所以对于java来说泛型就是语法糖,此种实现方式称为类型擦除。

其实类型擦除的真正含义是例如Map<Integer, Integer> map = new HashMap<Integer, Integer>()的泛型在javac编译后被擦除。但是由于类型擦除其实并不是彻底擦除。我们在元数据中依然保留了部分泛型的痕迹。因此推测反编译工具根据各种线索再次将泛型呈现了出来。但是对于赤裸裸的class文件,这个泛型应该是不存在的!所以其实擦除后就变成了Map map = new HashMap(),此时你会发现,泛型都不见了(是真的不见了,反编译工具太强大了强行呈现)。程序又变回了最开始的写法,泛型类型都变为了裸类型。只是在元素插入的时候进行了强转。

使用代码举例:

// 这是我们正常编写时的代码
public static void main(String args) {
   
    Map<String,String>map=new HashMap<String,String>();
    map.put("str1","你好");
    map.put("str2","你好呀")
    System.out.println(map.get("str1"));
    System.out.println(map.get("str2"));
}

泛型擦除后的样子:

// 泛型擦除后的样子,使用强转
public static void main(String args){
   
    Mapmap=new HashMap();
    map.put("str1","你好");
    map.put("str2","你好呀")
    System.out.println((String)map.get("str1"));
    System.out.println((String)map.get("str2"));
}
泛型与重载

由于Java泛型的引入,各种场景下的方法调用都可能对原有的基础产生影响并带来新的需求,如在泛型类中如何获取传入的参数化类型等。所以JCP组织对《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性作用于解决伴随泛型而来的参数类型的识别问题。

Signature是比较重要的一项,它存储一个方法字节码层面的特征签名(区别java层面特征签名,还包含返回值和受查异常表),这个属性中保存的参数类型并不是原生的类型,而是参数化的类型信息。

修改后的虚拟机要求所有能识别49.0以上版本的Class文件的虚拟机都能正确地识别Signature参数。

看下面的代码中的两个类:

// 类1,参数中List的泛型不同
public class GenericType {
   
    public static void method(List<String> list) {
   
        System.out.println("参数是List<String> list,无返回值");
    }
    public static void method(List<Integer> list) {
   
        System.out.println("参数是List<Integer> list,无返回值");
    }
}
// 类2,参数中的List泛型不同、返回值不同
public class GenericType {
   
    public static String method(List<String> list) {
   
        System.out.println("参数是List<String> list,返回值是String");
        return "";
    }
    public static int method(List<Integer> list) {
   
        System.out.println("参数是List<Integer> list,返回值是int");
        return 1;
    }
}

无论第一个类还是第二个类,在JDK1.7中的javac都不能编译成功,但是第二个类中的方法加上返回类型就在JDK1.6中可以编译成功,其他的却不行,都是提示在类型檫除后方法重复。

那么可以得出结论,檫除类型只是檫除Code属性中的字节码,实际上元数据还保留了泛型信息,这也是我们在编码时能通过反射取得参数化类型的根本依据。

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

自动装箱、拆箱与遍历循环这些语法糖不能与泛型相比(思想与实现上),但他们确是使用最多的的语法糖。

引用书中的代码来看一下,这是编译之前的代码:

public static void main(String []args) {
   
    List<Integer> list = ArrayList.aslist(1,2,3,4);
    //在1.7中还可以进一步简化List<Integer> list=[1,2,3,4] 
    int sum = 0;
    for(int i : list) {
   
    sum += i;
	System.out.println(sum);
}

这是编译之后的代码:

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

上面的代码实现了泛型自动装箱自动拆箱遍历循环变长参数这5种语法糖。

遍历循环把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。

变长参数在调用的时候变成了一个数组类型的参数,在变长参数出来之前,程序员使用数组来完成类似功能。

自动装箱的错误用法

下面来看看自动装箱的错误用法:

public class AutoBox {
   
    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,是因为c 和 d 都是基于Integer的缓存(-128 ~ 127),地址相等
        System.out.println(e==f); // false,是因为超出缓存范围,通过new来创建包装类型,地址不相等
        System.out.println(c==(a+b)); // true,是因为包装类遇到运算符自动拆箱为数值进行比较,数值相等
        System.out.println(c.equals(a+b)); // true,包装类重写了equals的方法,在进行比较时会自动拆箱,但是并不会进行类型转换,数值相等
        System.out.println(g==(a+b)); // true,是因为遇到算数运算符会自动拆箱(long) 3(int)3
        System.out.println(g.equals(a+b)); // false, 从equals()方法可以看出类似的,首先比较a+b类型是不是Long,不是则直接返回false
    }
}

上面给出了结果和解释,再来看一下Integer中重写的equals()方法:

public boolean equals(Object obj) {
    
if (obj instanceof Integer) {
    //首先看比较的类型是不是同一个类型,如果是,则比较值是否相等,否则是Long则直接返回false 
	return value == ((Integer)obj).intValue(); 
} 
return false; 
} 

包装类型在遇到“==”情况下,没有遇到算数运算符不会自动拆箱,遇到会自动拆箱比较值。

而在equals情况下,即使遇到算数运算符也不会进行拆箱操作,也不会进行类型装换(从equals方法可以看出,只要包装类型不相等,即使值相等也是返回false。)

条件编译

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

例如我们看一段if语句编译前后的代码:

// 编译前
if(true) {
   
    System.out.println(111);
} else {
   
    System.out.println(222);
}
// 编译后
System.out.println(111);

而 while(false){ } 这样的代码块是通过不了编译的,会提示“Unreachable code”。

可变参数

刚才在"自动装箱、拆箱与遍历循环"有提到过。

变长参数其实就是参数使用数组,使用的时候先创建一个数组,数组的长度就是参数的个数,最终将数据传入到被调用的方法中。

使用代码举例:

public class test2 {
   
    public static void main(String[] args) {
   
        test("sss","ddd","eee");
    }

    public static void test(String... strs) {
   // 传入可变参数
        for (int i = 0; i < strs.length; i++) {
   
            System.out.println(strs[i]);
        }
    }
}

反编译的结果:

public class test2 {
   
    public static void main(String[] args) {
   
        test(new String[]{
   "sss","ddd","eee"});
    }
	...
}

内部类

拥有内部类的文件outer.class在编译后会生成一个名为outer$inner.class的内部类文件。

枚举类

枚举类编译后会生成一个final类型的class。

编写测试代码:

public enum testEnum {
   
    SPRING,SUMMER;
}

反编译的结果:

public final class testEnum extends Enum {
   

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

    public static testEnum valueOf(String name) {
   
        return (testEnum)Enum.valueOf(com/glt/compile/testEnum, name);
    }

    private testEnum(String s, int i) {
   
        super(s, i);
    }

    public static final testEnum SPRING;
    public static final testEnum SUMMER;
    private static final testEnum $VALUES[];

    static {
   
        SPRING = new testEnum("SPRING", 0);
        SUMMER = new testEnum("SUMMER", 1);
        $VALUES = (new testEnum[] {
   
            SPRING, SUMMER
        });
    }
}

断言语句

java中断言默认不开启,开启需要使用JVM添加参数-ea,断言的底层实现是使用if语句,如果断言结果为true就继续执行,如果断言失败抛出AssertionError异常。

编写测试代码:

public class testAssert {
   

    public static void main(String args[]) {
   
        int a = 1;
        int b = 1;
        assert a == b;
        System.out.println("true");
        assert a != b : "aaa";
        System.out.println("false");
    }
}

反编译的结果:

public class testAssert
{
   

    public testAssert() {
   
    }

    public static void main(String args[]) {
   
        int a = 1;
        int b = 1;
        if(!$assertionsDisabled && a != b) {
   
            throw new AssertionError();
        }
        System.out.println("true");
        if(!$assertionsDisabled && a == b) {
   
            throw new AssertionError("aaa");
        } else {
   
            System.out.println("false");
            return;
        }
    }

    static final boolean $assertionsDisabled = !com/glt/compile/testAssert.desiredAssertionStatus();

}

数值字面量(Java7)

在java 7中新增的数字字面量定义:不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读

int v = 10_00_00;

对 String 与枚举的 switch 支持 (Java7)

java编译器中switch支持byte、short、char、int、String,最终的比较都是转换成整型,代码编译后就会将各种类型转换为整型。

编写测试代码:

public class TestSwitch {
   
    public static void main(String[] args) {
   
        String str = "a";
        switch (str) {
   
            case "a":
                System.out.println("aaaa");
                break;
            case "b":
                System.out.println("bbbb");
                break;
            default:
                break;
        }
    }
}

反编译的结果:

public class TestSwitch {
   

    public TestSwitch() {
   
    }

    public static void main(String args[]) {
   
        String str = "a";
        String s = str;
        byte byte0 = -1;
        switch(s.hashCode()) {
   
        case 97: // 'a'
            if(s.equals("a"))
                byte0 = 0;
            break;

        case 98: // 'b'
            if(s.equals("b"))
                byte0 = 1;
            break;
        }
        
        switch(byte0) {
   
        case 0: // '\0'
            System.out.println("aaaa");
            break;

        case 1: // '\001'
            System.out.println("bbbb");
            break;
        }
    }
}

try语句中定义和关闭资源(Java7)

通常我们读文件或者写连接池等都需要手动在finally里面关闭流或者连接,但是java7的try-with-resource为我们关闭连接或者流,不用我们自己再调用关闭连接或者关闭流。

编写测试代码:

public class TestTryWithResource {
   

    public static void main(String[] args) {
   
        try (BufferedReader br = new BufferedReader(new FileReader("input.txt"))) {
   
            String line;
            while ((line = br.readLine()) != null) {
   
                System.out.println(line);
            }
        } catch (IOException e) {
   
            e.printStackTrace();
        }
    }
}

反编译的结果:

public class TestTryWithResource {
   

    public TestTryWithResource() {
   
    }

    public static void main(String args[]) {
   
        BufferedReader br;
        Throwable throwable;
        br = new BufferedReader(new FileReader("input.txt"));
        throwable = null;
        String line;
        try {
   
            while((line = br.readLine()) != null) 
                System.out.println(line);
        }
        catch(Throwable throwable1) {
   
            throwable = throwable1;
            throw throwable1;
        }
        if(br != null)
            if(throwable != null)
                try
                {
   
                    br.close();
                }
                catch(Throwable x2)
                {
   
                    throwable.addSuppressed(x2);
                }
            else
                br.close();
        break MISSING_BLOCK_LABEL_117;
        Exception exception;
        exception;
        if(br != null)
            if(throwable != null)
                try
                {
   
                    br.close();
                }
                catch(Throwable x2)
                {
   
                    throwable.addSuppressed(x2);
                }
            else
                br.close();
        throw exception;
        IOException e;
        e;
        e.printStackTrace();
    }
}

Lambda表达式(Java8)

Lambda表达式为JAVA8新增的新特性,Lambda表达式的实现其实是依赖了一些底层的api,在编译阶段,编译器会把lambda表达式进行解糖,转换成调用内部api的方式。

编写测试代码:

public class LambdaTest {
   

    public static void main(String[] args) {
   
        List<String> list = new ArrayList<String>();
        list.add("I");
        list.add("Love");
        list.add("You");
        list.forEach( str -> System.out.println("输出:" + str));
    }
}

反编译的结果(我不理解。。):

import java.io.PrintStream;
import java.lang.invoke.LambdaMetafactory;
import java.util.ArrayList;
import java.util.function.Consumer;

public class LambdaTest {
   
    public static void main(String[] arrstring) {
   
        ArrayList<String> arrayList = new ArrayList<String>();
        arrayList.add("I");
        arrayList.add("Love");
        arrayList.add("You");
        
        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表达式不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作。

插入式注解处理器实现(了解)

参考博客链接:深入理解Java虚拟机 插入式注解处理器实战总结

待补充,详情见《深入理解JVM虚拟机》10.4节。