深入理解Java虚拟机(周志明第三版)- 第十章:前端编译与优化

系列文章目录

第一章: 走近Java
第二章: Java内存区域与内存溢出异常
第三章: Java垃圾收集器与内存分配策略


从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰,这个过程犹如一场没有终点、永不停歇的F1方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车

一、概述

在Java技术下谈”编译器“而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是指一个前端编译器(或编译器的前端)把*.java文件转变成*.class文件的过程;也可能是指Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变为本地机器码的过程;还可能是指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Complier)直接把程序编译成与目标机器指令集相关的二进制代码的过程。

  • 前端编译器:JDK中的javac、Eclipse JDT中增量式编译器(ECJ)
  • 即时编译器:Hotspot虚拟机的C1、C2编译器、Graal编译器
  • 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET

Javac这类前端编译器对代码的运行效率几乎没有任何优化措施可言,因为Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由javac生成class文件(如Ruby、Grovoy等语言的class文件)也同样可以享受到编译器优化措施所带来的性能红利,但是如果把开发阶段的优化也计算进来的话,Javac确实是做了许多针对Java语言编码过程的优化措施来降低程序员的编码复杂度、提高编码效率,相当多新生的Java语法特性,都是靠编译器的”语法糖“来实现,而不是依赖字节码或者虚拟机底层改进来支持的。

可以认为Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译器的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高


二、Javac编译器

Javac源码分析,编译过程大致可以分为1个准备阶段和3个处理过程:

  1. 准备过程:初始化插入式注解处理器
  2. 解析与填充符号表过程:包括词法、语法分析(将源代码的字符流转变为标记集合,构造出抽象语法树),填充符号表(产生符号地址和符号信息)
  3. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
  4. 分析与字节码生成过程:标注检查(对语法的静态信息进行检查)、数据流及控制流分析(对程序动态运行过程进行检查)、解语法糖(将简化代码编写的语法糖还原为原有的形式)、字节码生成(将前面各个步骤所生成的信息准换成字节码)
    在这里插入图片描述
    上述3个处理过程中,执行插入式注解时有可能会产生新的符号,如果有新的符号产生,就必须回到之前的解析、符号填充的过程中重新处理这些新符号。
Javac编译动作入口是com.sun.tools.javac.main.JavaCompiler类,上述3个逻辑集中在该类的compile()compile2() 

    public void compile(List<JavaFileObject> sourceFileObjects,
                        List<String> classnames,
                        Iterable<? extends Processor> processors)
    {
        if (processors != null && processors.iterator().hasNext())
            explicitAnnotationProcessingRequested = true;
        // as a JavaCompiler can only be used once, throw an exception if
        // it has been used before.
        if (hasBeenUsed)
            throw new AssertionError("attempt to reuse JavaCompiler");
        hasBeenUsed = true;

        // forcibly set the equivalent of -Xlint:-options, so that no further
        // warnings about command line options are generated from this point on
        options.put(XLINT_CUSTOM.text + "-" + LintCategory.OPTIONS.option, "true");
        options.remove(XLINT_CUSTOM.text + LintCategory.OPTIONS.option);

        start_msec = now();

        try {
        	// 准备过程:初始化插入式注解处理器
            initProcessAnnotations(processors);

            // These method calls must be chained to avoid memory leaks
            delegateCompiler =
                processAnnotations(   // 过程2:执行注解处理
                    enterTrees(stopIfError(CompileState.PARSE,  // 过程1.2:输入到符号表
                    	parseFiles(sourceFileObjects))),        // 过程1.1:词法、语法分析
                    classnames);

			// 过程3:分析及字节码生成
            delegateCompiler.compile2();  
            delegateCompiler.close();
            elapsed_msec = delegateCompiler.elapsed_msec;
        } catch (Abort ex) {
            if (devVerbose)
                ex.printStackTrace(System.err);
        } finally {
            if (procEnvImpl != null)
                procEnvImpl.close();
        }
    }
    /**
     * The phases following annotation processing: attribution,
     * desugar, and finally code generation.
     */
    private void compile2() {
        try {
            switch (compilePolicy) {
            case ATTR_ONLY:
                attribute(todo);
                break;

            case CHECK_ONLY:
                flow(attribute(todo));
                break;

            case SIMPLE:
                generate(desugar(flow(attribute(todo))));
                break;

            case BY_FILE: {
                    Queue<Queue<Env<AttrContext>>> q = todo.groupByFile();
                    while (!q.isEmpty() && !shouldStop(CompileState.ATTR)) {
                        generate(desugar(flow(attribute(q.remove()))));
                    }
                }
                break;

            case BY_TODO:
                while (!todo.isEmpty())
                    generate(								   // 过程3.4:生成字节码
                    	desugar(							   // 过程3.3:解语法糖
                    		flow(							   // 过程3.2:数据流分析
                    			attribute(todo.remove()))));   // 过程3.1:标注
                break;

            default:
                Assert.error("unknown compile policy");
            }
        } catch (Abort ex) {
            if (devVerbose)
                ex.printStackTrace(System.err);
        }

        if (verbose) {
            elapsed_msec = elapsed(start_msec);
            log.printVerbose("total", Long.toString(elapsed_msec));
        }

        reportDeferredDiagnostics();

        if (!log.hasDiagnosticListener()) {
            printCount("error", errorCount());
            printCount("warn", warningCount());
        }
    }

解析与填充符号表

    解析过程包含词法分析和语法分析两个步骤:

  • 词法分析:将源代码的字符流转换为标记(Token)集合的过程,单个字符是程序编写的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,例“int a = b+2”包含了6个标记,关键字int只是一个独立的标记,不可拆分。词法分析过程由com.sun.tools.javac.parser.Scanner类来实现
  • 语法分析:根据标记序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示形式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构。例包、类型、修饰符、运算符、接口、返回值甚至是代码注释都可以是一种特定的语法结构

在Javac的源码中,语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树是以com.sun.tools.javac.tree.JCTree 类表示的。
经过词法和语法分析生成抽象语法树后,编译器就不会再对源代码字符流进行操作了,后续的操作都建立在抽象语法树上。

填充符号表

        符号表是一组符号地址和符号信息构成的数据结构。符号表中所登记的信息在编译的不同阶段都要被用到,例在语义分析过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用与先前声明是否一致)和生成中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

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

注解处理器

        Jdk5之后,Java语言提供了对注解(Annotations)的支持,注解在设计上原本是与普通Java代码一样的,都只会在程序运行期间发挥作用。但在Jdk6中又提出并通过JSR-269提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译器对代码中的特定注解进行处理,从而影响到前端编译器的工作过程,我们可以把插入式注解处理器当作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加语法抽象树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,知道所有插入式注解处理器都没有再对语法树进行修改为止,每一个循环称为一次轮次。
        有了注解处理器的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大发挥空间。例Lombok插件,它可以通过注解实现自动生成get/set方法等等,帮助开发人员消除冗长代码

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

语义分析与字节码生成

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

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

后续可能出现的赋值操作
int d = a + c; 
int d = b + c; 
char d = a + c;

上述3种赋值操作都能构成结构正确的语法树,但只有第一种写法在语义上是正确的,能够通过检查和编译。

1、标注检查
Javac编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤。标注检查步骤检查的内容包括诸如变量使用前是否被声明、变量与赋值之间的数据类型是否匹配等。(在标注检查中,还会顺便进行一个称为“常量折叠”的代码优化,例int a = 1+2; 则在语法树上仍然能看到字面量“1”、“2”、“+”,但在经过常量折叠优化后,它们会被折叠为字面量“3”)

2、数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、是否所有的受查异常都被正确处理等等。编译期的数据及控制流分析与类加载时的数据及控制流分析目的基本可以看作一致,但校验范围有所差别,有一些校验项只有在编译期或运行期才能进行。

3、解语法糖
语法糖也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能没有实际影响,但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序可阅读性,从而减少程序代码出错的机会。

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

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

例实例构造器方法和类构造器方法方法就是在这个阶段被添加到语法树中的。请注意这里的实例构造器并不等同于默认构造函数,默认构造函数是在填充符号表阶段就已经完成。和两个构造器的产生实际是一种代码收敛的过程,编译器会把语句块、变量初始化、调用父类的实例构造器等操作收敛到和方法中,并且保证无论代码出现的顺序如何,都一定会先执行父类的构造器,然后初始化变量,最后执行语句块的顺序进行。除了生成构造器以外,还有一些其他的代码替换工作用于优化程序某些逻辑的实现方式,如把字符串的加操作替换为StringBuffer或StringBuilder的append()操作等


三、Java语法糖的味道

1、泛型

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

Java选择的泛型实现方式叫做“类型擦除式泛型”,而C#选择的泛型实现方式是“具现化泛型”。C#里面泛型无论在程序源码里面、编译后的中间语言表示里面、或运行期的CLR里面都是切实存在的,List和List就是两个不同的类型,它们由系统在运行期间生成,有着自己独立的虚方法表和类型数据;而Java语言中的泛型只在程序源码中存在,在编译后的Class字节码文件中,全部泛型被替换为原来的裸类型,并且在相应的地方插入强制转型代码,因此对于运行期的Java语言来说,ArrayList与ArrayList其实是同一种类型。

Java泛型实际使用中的一些限制

代码清单10-2 Java中不支持的泛型用法

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

还有性能上的差距,C#使用平台提供的容器类型时,无须像Java那样频繁的拆箱和装箱操作。

Java的了类型参数式泛型无论使用效果还是运行效率上都全面落后于C#的具现化式泛型,唯一的优势在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java5.0之上

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

自动装箱的陷阱

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


输出结果:

true
false
true
true
true
false

3、条件编译

Java语言的条件编译

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

编译后Class文件的反编译结果

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

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

除此之外,Java语言还有其他的语法糖:内部类、枚举类、断言断句、数值字面量、对枚举和字符串的switch支持、try语句中定义和关闭资源、Lambda表达式等等。


四、自定义插入式注解处理器

// TODO


五、附录

javac源码笔记与简单的编译原理
Javac 源码调试教程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值