一文读懂Java编译全过程

Posted gonghaiyu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文读懂Java编译全过程相关的知识,希望对你有一定的参考价值。

一文读懂Java编译全过程

语言处理器种类

  1. 编译器,如gcc、javac解释器。
  2. 如Ruby、Python等一些一些语言使用解析器来实现的。
  3. IDE,如Eclipse、NetBeans等。
  4. 代码分析器,如FindBugs等。
  5. 反编译器,如JD、Jad、Reflector.NET等。

Java编译过程

Java文件编译过程包括两个阶段,第一阶段是在编译阶段编译成Java字节码的过程,有些书籍中叫前端编译器,如Oracle的javac编译器;第二阶段是在运行时,通过JVM的编译优化组件,对代码中的部分代码编译成本地代码,即JIT编译,如HotSpot中的C1、C2编译器。JVM整个编译过如下图所示。

在这里插入图片描述

其中,编译状态有如下9种。

//编译状态 
public enum CompileState {
        INIT(0),//初始化
        PARSE(1),//解析
        ENTER(2),//处理符号表
        PROCESS(3),//核心处理
        ATTR(4),//符号解析
        FLOW(5),//流分析
        TRANSTYPES(6),//解泛型为非泛型等类型转换
        UNLAMBDA(7),//解LAMBDA表达式
        LOWER(8),//解语法糖
        GENERATE(9);//生成字节码
 }

下面是JIT编译器和C1(C2)编译器编译流程。
在这里插入图片描述

Javac编译器

当我们在控制台执行javac命令时,找到javac对应的环境变量的可执行文件,通过JNI方式调用com.sun.tools.javac.Main.java中的main方法进入。也就是说Javac编译工作是由Java代码完成的。像javap,javah等命令也都是通过Java代码完成的。

   /**
     * launcher的入口.
     * Note: 该方法调用了System.exit.
     * @param args 命令行参数
     */
    public static void main(String[] args) throws Exception {
        System.exit(compile(args));
    }

     //此代码段在Main#compile方法中,用于读取Java文件对象用于编译。
      if (!files.isEmpty()) {
                // add filenames to fileObjects
                comp = JavaCompiler.instance(context);
                List<JavaFileObject> otherFiles = List.nil();
                JavacFileManager dfm = (JavacFileManager)fileManager;
                for (JavaFileObject fo : dfm.getJavaFileObjectsFromFiles(files))
                    otherFiles = otherFiles.prepend(fo);
                for (JavaFileObject fo : otherFiles)
                    fileObjects = fileObjects.prepend(fo);
            }
            //调用JavaCompiler#compile方法
            comp.compile(fileObjects,//要编译的文件对象
                         classnames.toList(),//注解处理的类名
                         processors);//用户提供的注解处理器

最终调用JavaCompiler.compile()方法进行编译处理。如果自行编译,可以调用java中提供的工具类ToolProvider.getSystemJavaCompiler() 自行进行编译。如下是JavaCompiler.compiler()方法。

   /**
     * 主方法:要编译的文件列表,返回所有编译的类
     * @param sourceFileObjects 要编译的文件对象
     * @param classnames 为类中注解处理的类名
     * @param processors 用户提供的注解处理器,null意味着没有处理器提供。
     */
    public void compile(List<JavaFileObject> sourceFileObjects,
                        List<String> classnames,
                        Iterable<? extends Processor> processors)
    {
        if (processors != null && processors.iterator().hasNext())
            explicitAnnotationProcessingRequested = true;
        // 由于JavaCompiler只能使用一次,如果以前使用过,则抛出异常
        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);

            // (1)这些方法必须是链式调用以避免内存泄漏
            delegateCompiler =
                processAnnotations(
                    enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),
                    classnames);
            // (2)分析和生成字节码
            delegateCompiler.compile2();
            delegateCompiler.close();
            elapsed_msec = delegateCompiler.elapsed_msec;
        } catch (Abort ex) {
            if (devVerbose)
                ex.printStackTrace(System.err);
        } finally {
            if (procEnvImpl != null)
                procEnvImpl.close();
        }
    }

从上面的代码可知,编译真正处理的代码在(1)和(2)处。对代码分析,编译处理包括以下三个部分。分别为解析与填充符号表、注解处理、分析和生成字节码三个大阶段。

在这里插入图片描述

解析与填充符号表

解析与填充符号表,对应图一的词法分析、语法分析、抽象语法树、填充符合表几个细节处理。在解释语法树之前,我们首先要说下什么是语法树,语法树在很多语言中都有采用,如java、sql源码阅读中都用到了语法树的概念。如下的英语句子的语法树。
在这里插入图片描述

根据上面源码中的(1)注解中的代码,解析与填充符号表包括以下几个步骤。

delegateCompiler =
                processAnnotations(
                    enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),
                    classnames);
  1. parseFiles方法会执行parserFactory#newParser方法,在该方法内部封装了Scanner类并借助于JavaTokenizer类实现词法分析(手写的ad-hoc方式构造的词法分析器),词法分析可以简单理解为java文件中的每个空格之间的字符当作一个标记。源文件经过Unicode转义处理,通过Scanner类转化为令牌流。Parser类读取令牌流,使用TreeMaker创建语法树。而语法树通过com.sun.source.Tree及其子类的JCTree类或其子类实现的。语法树可理解为JCTree中每个节点表示一个包、类型等语法结构。每个树最后传递给Enter类,为遇到的所有定义的符号传入符号字面量。这必须在解析树之前做好,因为可能引用这些符号。该阶段输出的是“待办”列表,其中包含需要分析并生成文件的树。而Parser#parseCompilationUnit方法用于语法分析。
  2. enterTrees方法主要用于填充符号表。主要由Enter类实现。Enter包含很多阶段,要编译的类通过队列从一个阶段传到下一个阶段。

在这里插入图片描述

  • 在第一个阶段,所有的类符号都进入到Enter的范围之内,树中其他类的成员变量都严格降序排列。类符号被赋予一个MemberEnter对象作为"完成者"。除此之外,如果任何package-info.java文件被找到,并且包含包注解。树节点的顶层将会为该文件添加到“代办”列表。

  • 将符号输入到符号表。com.sun.tools.javac.comp.Enter,每个编译单元的抽象语法树的顶局节点都先被放到待处理列表中,逐个处理列表中的节点,所有类符号被输入到外围作用域的符号表中,若找到package-info.java,将其顶局树节点加入到待处理列表中,确定类的参数(对泛型类型而言)、超类型和接口,根据需要添加默认构造器,将类中出现的符号输入到类自身的符号表中,分析和校验代码中的注解(annotation)。
    添加的默认构造器如下。
    在这里插入图片描述

  • 在第二阶段,类使用MemberEnter.complete()来完成。类是按需完成的,但是未按照此方式完成的类最终都会通过处理未完成的队列来完成。完成需要:(1)决定类的变量、超类和接口。(2)将类中定义的所有符号输入,但是在第一阶段已经完成的符号变量除外。(2)依赖于(1)中的类及其所有超类和封闭类已经完成。这就是为什么在(1)之后,我们将类放入到一个半完成的队列中。只有当我们对一个类及其所有超类和内部类执行了(1)之后,我们才继续执行(2)。

  • 输入所有的符号后,在这些符号上遇到的所有注解将会分析和验证。

    第一阶段是组织被遍历所有编译的语法树,而第二阶段是按需的,类的成员在第一次访问类的内容时输入,这是通过在编译类的类符号中使用completer对象来实现的,编译类调用对应类的树的MemberEnter阶段。

注解处理

注解是JDK1.5中引入的,对于注解的处理可以理解为编译器的一组插件,根据注解解析结果对抽象语法树进行修改,如lombok。方法processAnnotations是注解处理的入口,当由注解需要处理时,则由JavacProcessingEnvironment#doProcessing方法创建一个JavaCompiler对象来完成。从概念上来讲,注解处理是编译之前的一个初步步骤。这个初步动作由一系列的循环组成(如图2)。每个循环用于解析和输入源文件,然后确定和调用适当的注解处理器。在首次循环之后,如果被调用的任何注解处理器生成任何需要作为最后编译一部分的新原文件或类时,将需要执行后面的循环。最后,当所有必要的循环完成,执行实际编译。

在这里插入图片描述

在实际中,调用任何注解处理器的需要可能要等到要编译的文件被解析并且包含的声明被确定之后才能知道。因此,为了避免在不执行注解处理的情况下不必要地解析和输入源文件,JavacProcessingEnvironment对概念模型的执行有点不同,但是仍满足注解处理器作为一个整体在实际编译前执行。

在这里插入图片描述

当class文件被编译,并且已经解析和填充符号后。JavacProcessingEnvironment将会被调用。该类决定被编译的文件哪些注解需要被加载或被调用。通常,如果在整个编译过程中出现任何错误,该过程则在下一个合适的点停止编译。但是,如果在符号解析阶段出现丢失符号,则会抛出异常,因为定义这些符号可能作为注解处理器的结果。

如果要运行注释处理器,将在单独的类加载器中加载并运行它们。

当注解处理器运行时,JavacProcessingEnvironment决定是否需要另外一轮注解处理。如果需要,将会创建一个新的对象JavaCompiler。读取上步骤新生成的源文件进行解析。并且重新使用之前的语法树进行解析。所有的这些树都被输入到这个新编译器实例的符号表中,并且根据需要调用注解处理器。然后重复直到所有的注解编译完成。

最后,JavacProcessingEnvironment返回JavaCompiler对象用于编译剩下的部分。这个对象是用于解析和输入初始文件集的原始实例,或者是JavacProcessingEnvironment创建的用于开始最后一轮编译的最新实例。

下面以lombok为例说明

  1. 注解处理之前。

在这里插入图片描述
2. 注解处理后
在这里插入图片描述

分析和生成字节码

当命令行中指定的所有文件被解析并输入到编译器的符号表中,并且注解也已经处理,JavaCompiler能处理分析的语法树,以生成相应的class文件。由delegateCompiler.compile2()方法进入。

   /**
     * 注释处理之后的阶段:属性、解语法糖,最后是代码生成。
     */
    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(desugar(flow(attribute(todo.remove()))));
                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());
        }
    }

当分析树时,可以找到对成功编译所需的类的引用,但是这些类没有显示指定用于编译。根据编译选项,将在源路径和类路径中搜索此类的类定义。如果能在类文件中找到定义,将自动分析、输入源文件并将其放到待办事项列表中。这些在Attr.SourceCompleter类中实现。

分析树和生成类文件的工作由一系列的观察者来处理进入了编译器代办事项列表。这些观察者没有必要分步对所有的源文件处理。事实上,内存问题会使这极不可取。唯一的要求是,“代办”列表最终会被每一个观察者处理,除非编译因为错误而提前终止。

  1. Attr和Check

    顶层类是“Attribute",使用Attr,这意味着语法树中的名称、表达式和其他元素将被解析并与相对应的类型和符号相关联。这可以通过Attr类或Check类检查到许多语义错误。

语法分析的一个步骤,将语法树中名字、表达式等元素不变量、方法、类型等联系到一起,检查变量使用前是否已声明,推导泛型方法的类型参数,检查类型匹配性,迕行常量折叠。

下面举例说明。
(1)标注前。
在这里插入图片描述

(2)标注后。
在这里插入图片描述

  1. Flow

    如果到目前没有错误,将会使用Flow进行类的流分析。流分析用于检查变量的明确分配和不可到达语句。检查所有checked exception都被捕获或抛出;检查变量的确定性赋值(1)所有局部变量在使用前必项确定性赋值;(2)有返回值的方法必须确定性返回值;检查变量的确定性不重复赋值(1)为保证final的语义。

  2. TransTypes

    将泛型类型的类转变为TransTypes类(裸类型,普通的java类型),同时插入必要的类型转换代码。

    下面给个示例。
    (1)类型转换前。
    在这里插入图片描述
    (2)类型转化后。
    在这里插入图片描述

  3. Lower

    语法糖使用Lower类来处理,它重写语法树,通过替换等价、简单子树来消除特定类型的子树。这将会处理内部类和嵌套类,类字面量,断言,foreach循环等。对于每个被处理的类,Lower类返回已转变类及所有转变的嵌套类和内部类的树的列表。尽管Lower通常处理顶层类,但也处理package-info.java的顶层树。对于这种树,Lower类将创建合成类来包含包的任何注解。
    在这里插入图片描述

    削除if (false) { … }形式癿无用代码。满足下述所有条件的代码被认为是条件编译的无用代码◦if语句的条件表达式是Java语言规范定义的常量表达式◦并且常量表达式值为false则then块为无用代码;反之则else块为无用代码。

示例

(1)Lower前
在这里插入图片描述
(2)Lower后
在这里插入图片描述

  1. Gen

    Gen类用于方法代码的编译,它创建包含字节码的Code属性,通过JVM实例来执行方法。如果该步骤成功,则编译后的类由ClassWriter类写出。

一旦一个类作为类文件被写出来,它的许多语法树和生成的字节码就不再需要了。为了节省内存,对树的这些部分和符号的引用将为空,以允许垃圾收集器恢复内存。

  1. 将实例成员初始化器收集到构造器中成为();将静态成员初始化器收集为();
  2. 从抽象语法树生成字节码。(1)后序遍历语法树(如下);(2)进行最后的少量代码转换,如String的+被生成为StringBuilder操作;x++/x–在条件允许时被优化为++x/–x
  3. 从符号表生成Class文件◦生成Class文件的结构信息。生成元数据(包括常量池)
    在这里插入图片描述

整个前端编译过程如下图所示。

以上步骤已经生成了.class文件。在运行期间,编译器将会进一步优化,即JIT优化。

JIT编译

JIT是即时编译器(Just In Time Compiler)的缩写,Hotspot中有两个即时编译器,分别为Client Compiler(C1)和Server Compiler(C2),C1和C2都是将字节码编译成本地代码,区别可以理解为C1是局部优化,而C2可以理解为专门面向服务端的。JVM有三种运行模式,分别是解释(interpreted mode)、编译模式(compiled mode)和混合模式(mixed mode)三种模式。Java1.8中默认的解释器与其中一个JIT编译器直接配合的方式执行,即采用混合模式。用户可以通过参数"-Xint"强制虚拟机运行在解释模式,此时编译器不工作。当然也可以使用参数"-Xcomp"强制虚拟机运行于“编译模式”。这时优先采用编译方式执行,但在某些情况下,解释器不得不介入才能执行。

编译条件

编译优化的条件主要针对热点代码,而热点代码主要有两种情况:

  1. 多次被调用的方法
  2. 多次执行的循环体

无论第一种情况还是第二种情况,都是以整个方法作为编译对象。第二种情况而不是以循环体作为编译对象。只是处理方式不同,因为第二种编译方式发生在方法执行体中,而在运行时表现为方法栈,通过替换方法栈中的部分代码为编译后的本地代码,即通过栈上替换(On Stack Replacement,OSR)的方式进行JIT编译。

很显然,无论采用哪种方法,编译器都需要识别哪些代码为热点代码。目前热点代码探测的方式有两种。

  1. 基于采样的热点探测。虚拟机启动一个检测线程周期性检查各个线程的栈顶,如果发现某个方法经常在栈顶,则认为是"热点代码"。这种方式简单但是不能精确统计某个方法的热点,且容易受线程阻塞等外界因素影响,当线程阻塞时,某个方法就会一直处于栈顶,从而不能精确统计方法的执行次数。
  2. 基于计数器的热点探测。虚拟机会为每个方法建立计数器,如果该方法超过规定的次数则认为是热点代码。

在HotSpot中采用的是第二种方式,且对同一个方法采用了两个计数器。一个是记录在某段时间内方法调用次数的计数器,当某段时间内不满足编译时,则次数会衰减一半,所以是某段时间内的相对次数。另一个是记录方法中的循环体的计数器(称为回边计数器),而这个计数器会一直往上增长,是绝对计数,当溢出时,则调整计数器的值为溢出状态。当该两个计数器超过默认的阈值,则发生JIT编译。下面表格是不同编译模式下的默认值。两个计数器都可以通过虚拟机参数进行设定。

方法调用计数器回边计数器
1500次13995次
C210000次10700次

编译过程

默认情况下,当虚拟机中的编译线程编译完成后,才能替换到JIT编译请求。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译。

C1编译优化主要在AdvancedThresholdPolicy.cpp文件中。

编译优化技术

公共子表达式消除、方法内联、逃逸分析

参考

http://openjdk.java.net/groups/compiler/doc/compilation-overview/index.html

以上是关于一文读懂Java编译全过程的主要内容,如果未能解决你的问题,请参考以下文章

MySQL 自定义函数一文读懂

一文读懂 Java 异常体系

风格迁移!一文读懂StyleGAN进化过程!

「Java基本功」一文读懂Java内部类的用法和原理

Makefile入门(超详细一文读懂)

一文读懂《Effective Java》第41条:慎用重载