详解Javac编译原理

1,618 阅读3分钟

本文已参与好文召集令活动,点击查看:[后端、大前端双赛道投稿,2万元奖池等你挑战!]

前言

之前在说Android资源打包的时候,提到过Android编译打包成apk的整体流程,其中从java文件到class文件是其中非常重要的一个环节。本文就来探究一下这个转换的过程,通过了解这些,可以对java文件的编译过程有一个更加清晰的认识,同时也可以帮助我们对于APT相关的知识有一个更深层次的掌握。

java代码的执行流程

image.png

java代码的执行主要分为三个步骤:

  • java文件到class文件(编译期)
  • 通过类加载器将class文件加载到内存中(加载运行)
  • 执行代码(运行期)

javac是什么?代码的编译流程概述

释义

javac全称java compliler,是一种编译器,可以将一种语言规范转换成另一种语言规范,javac的任务就是将源码转换为jvm可以识别的二进制码,从表面上看,就是转变为class文件,实际上就是将java源文件转换为一连串的二进制数字,而这些二进制数字只有jvm能识别其含义。

如何获取javac源码

点击下载javac源码网站

点击 zip 下载源码,

image.png

解压后使用编译器导入

image.png

有了源码,我们就可以愉快的玩耍了,一步一步揭开它的面纱。

编译流程概览

javac编译动作的入口在com.sun.tools.javac.main.JavaCompiler,主要的代码逻辑在compile()compile2()方法中,

image.png

com.sun.tools.javac.main.JavaCompiler.java

public void compile(List<JavaFileObject> sourceFileObjects,
                        List<String> classnames,
                        Iterable<? extends Processor> processors)
    {
       ...
        try {
            //初始化插入式注解处理器
            initProcessAnnotations(processors);

            // These method calls must be chained to avoid memory leaks
            delegateCompiler =
            
                //processAnnotations---执行注解处理
                //parseFiles---语法分析、词法分析
                //enterTrees---输入到符号表
                processAnnotations(
                    enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),
                    classnames);
            //分析以及代码生成
            delegateCompiler.compile2();
            delegateCompiler.close();
            elapsed_msec = delegateCompiler.elapsed_msec;
        } catch (Abort ex) {
            if (devVerbose)
                ex.printStackTrace(System.err);
        } finally {
            if (procEnvImpl != null)
                procEnvImpl.close();
        }
    }
    
    private void compile2() {
        try {
            switch (compilePolicy) {
            ...
            case BY_TODO:
                while (!todo.isEmpty())
                
                    //generate---生成字节码
                    //desugar---解语法糖
                    //flow---数据流分析
                    //attribute---标注检查
                    generate(desugar(flow(attribute(todo.remove()))));
                break;
            }
        }
        ...
    }

以上就是javac编译的几个步骤

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

图解一下:

image.png

接下来将一一进行详细的讲解

解析与填充符号表

词法分析

词法分析:通过词法分析器将源码文件转换为token流,也就是将源码划分为一个个的token,

规范的token包括:

  1. java关键字:package、import、public、private、class、int、for等等
  2. 自定义单词:包名、类名、方法名、变量名等
  3. 符号:+、-、*、/、(、{、&等

javac源码中,词法分析过程由com.sun.tools.javac.parser这个包下的类实现

image.png

  • Lexer和Parser:Javac的主要词法分析器的接口类
  • Scanner:Lexer的默认实现类,逐个读取java源文件的单个字符,对读取到的词法进行归类
  • JavacParser:规定哪些词是符合Java语言规范的
  • Token:规定了所有Java语言的合法关键词
  • Names:存储和表示解析后的词法

看一下Token中TokenKind是如何定义关键字的:

image.png

词法分析过程是在JavacParser的parseCompilationUnit方法中完成的:

public JCTree.JCCompilationUnit parseCompilationUnit() {
        Token firstToken = token;
        JCExpression pid = null;
        JCModifiers mods = null;
        boolean consumedToplevelDoc = false;
        boolean seenImport = false;
        boolean seenPackage = false;
        List<JCAnnotation> packageAnnotations = List.nil();
        // 解析修饰符 "@"
        if (token.kind == MONKEYS_AT)
            mods = modifiersOpt();
            
        // 解析package声明
        if (token.kind == PACKAGE) {
            seenPackage = true;
            if (mods != null) {
                checkNoMods(mods.flags);
                packageAnnotations = mods.annotations;
                mods = null;
            }
            nextToken();
            pid = qualident(false);
            accept(SEMI);
        }
        ListBuffer<JCTree> defs = new ListBuffer<JCTree>();
        boolean checkForImports = true;
        boolean firstTypeDecl = true;
        while (token.kind != EOF) {
            if (token.pos > 0 && token.pos <= endPosTable.errorEndPos) {
                // error recovery
                skip(checkForImports, false, false, false);
                if (token.kind == EOF)
                    break;
            }
            // 解析import声明
            if (checkForImports && mods == null && token.kind == IMPORT) {
                seenImport = true;
                defs.append(importDeclaration());
            } else {
                // 解析class主体
                Comment docComment = token.comment(CommentStyle.JAVADOC);
                if (firstTypeDecl && !seenImport && !seenPackage) {
                    docComment = firstToken.comment(CommentStyle.JAVADOC);
                    consumedToplevelDoc = true;
                }
                JCTree def = typeDeclaration(mods, docComment);
                if (def instanceof JCExpressionStatement)
                    def = ((JCExpressionStatement)def).expr;
                defs.append(def);
                if (def instanceof JCClassDecl)
                    checkForImports = false;
                mods = null;
                firstTypeDecl = false;
            }
        }
       
    }

词法分析从源文件的第一个字符开始,按照Java语法规范一次找出package、import、类定义,以及属性和方法定义等,最后构建一个抽象语法树。

举个例子

package test; 
public class Cifa { 
    int a; 
    int b = a + 1;
}

看一下这段代码,经过词法分析以后

image.png

在这个token流中,除了java定义的关键字之外,还有一个特殊的token:Token.IDENTIFIER,这个token表示自定义的名称,如类名、方法名、变量名等。Javac在进行词法分析时会由JavacParser根据Java语言规范来控制什么顺序、什么地方应该出现什么Token。

语法分析

语法分析:通过语法分析器将token流转换为抽象语法树(Abstract Syntax Tree),可以理解为将词法分析得到的token组合成一句话,并检查是否有语法问题。

  • AST是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表程序代码中的一个语法结构,例如包、类型、修饰符、接口、返回值等等。

image.png

  • com.sun.tools.javac.tree.TreeMaker --- 所有语法节点都是由它生成的,根据Name对象构建一个语法节点
  • com.sun.tools.javac.tree.JCTree$JCIf --- 每个语法节点都会实现一个 xxxTree 接口,这个接口继承自 Tree 接口,如 IfTree语法节点表示一个if类型的表达式, public static class JCIf extends JCStatement implements IfTree {},所有的JCxx类都作为一个静态内部类定义在JCTree中

image.png

  • com.sun.tools.javac.tree.JCTree的三个属性

    • Tree tag:每个语法节点都会以整数的形式表示,下一个节点在上一个节点上加1;
    • pos:也是一个整数,它存储的是这个语法节点在源代码中的起始位置,一个文件的位置是0,而-1表示不存在
    • type:它代表的是这个节点是什么java类型,如int,float,还是string等

语法分析实例:

package compile;

public class Yufa {
    int a;
    private int b = a + 1;
    
    //getter
    public int getB() {
        return b;
    }
    //setter
    public void setB(int b) {
        this.b = b;
    }
}

这段代码经过语法分析后得到抽象语法树

image.png

每一个从JCClassDecl发出的分支都是一个完整的代码块,上述是四个分支,对应我们代码中的两行属性操作语句和两个方法块代码块,这样其实就完成了语法分析器的作用:将一个个Token单词组成了一句句话(或者说成一句句代码块)

填充符号表

完成词法分析和语法分析之后,就是填充符号表的过程,符号表是由一组符号地址符号信息构成的表格,符号表中所登记的信息在编译的不同阶段需要用到。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码;在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

在javac的源码中,符号表填充由com.sun.tools.javac.comp.Enter类实现

image.png

注解处理器

jdk1.5以后,java提供了对注解的支持,这些注解与普通的java代码一样,是在运行期间发挥作用的。在jdk1.6中实现了JSR-269规范,提供了一组插入式注解处理器的api,在编译期对注解进行处理,可以将它看作是一组编译器的插件,通过这些插件,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析和填充符号表的过程重新处理,一直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round。

通过注解处理器的api,我们就可以干涉编译器的行为,语法树中的任意元素,甚至包括代码注释都可以在插件中访问到,所以通过插入式注解处理器可以实现很多操作。这里就不详细说明了。

语义分析

语法分析之后,编译器就获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序抽象,但无法保证源程序是符合逻辑的,而语义分析的主要任务就是对结构上正确的源程序进行上下文有关性质的审查,如类型审查等。

int a = 1;
boolean b = false;
char c = 2;
...
//赋值运算
int d = a+c;
int d = b+c;
char d = a+c;

如上述代码,如果出现了如上的3种赋值运算,它们都是可以构成结构正确的语法树的,但是只有第一种写法在语义上是正确的,其余两种是不符合逻辑的,无法编译。

标注检查

javac的编译过程中,语义分析分为标注检查和数据及控制流分析两个步骤,也就是开头所提到的 attribute 和 flow 方法。

image.png

标注检查的内容包括:变量使用之前是否被声明、变量与赋值之间的数据类型是否匹配等。

image.png 具体可以查看comp包中的 Attr和Check两个类中进行查看。

数据以及控制流分析

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

编译期的数据及控制流分析与类加载时的数据及控制流分析的目的基本是一致的,但校验范围有所不同,有些校验项只有在编译期或者运行期才能进行。

解语法糖

  • 语法糖:语法糖(syntactic sugar)是指编程语言中可以更容易的表达一个操作的语法,它可以使程序员更加容易去使用这门语言:操作可以变得更加清晰、方便,或者更加符合程序员的编程习惯。
  • 解语法糖:语法糖的存在主要是为了方便开发人员使用,但是jvm虚拟机并不认识这些语法糖,这些语法糖会在编译阶段被还原成简单的基础语法,在javac源码中com.sun.tools.javac.main.JavaCompiler中有一个步骤 desugar()就是专门来解语法糖的。会解除哪些语法糖呢?泛型擦除、自动拆箱和装箱、for-each增强for循环、方法边长参数、内部类等,具体实现可参考 Lower和TransTypes两个类

image.png

字节码生成

字节码生成是javac编译过程的最后一个阶段,在javac源码中由 com.sun.tools.javac.jvm.Gen 类来完成 image.png

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

类的实例构造器 init() 方法和类构造器 clinit() 方法就是在这个阶段添加到语法树中的。除了生成构造器,还有其他的一些优化操作,比如把字符串的 + 操作替换为StringBuffer或者StringBuilder的append操作。

完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,生成最后的class文件。

 public JavaFileObject writeClass(ClassSymbol c)
        throws IOException, PoolOverflow, StringOverflow
    {
        JavaFileObject outFile
            = fileManager.getJavaFileForOutput(CLASS_OUTPUT,
                                               c.flatname.toString(),
                                               JavaFileObject.Kind.CLASS,
                                               c.sourcefile);
        OutputStream out = outFile.openOutputStream();
        try {
            writeClassFile(out, c);
            if (verbose)
                log.printVerbose("wrote.file", outFile);
            out.close();
            out = null;
        } finally {
            if (out != null) {
                // if we are propagating an exception, delete the file
                out.close();
                outFile.delete();
                outFile = null;
            }
        }
        return outFile; // may be null if write failed
    }

结语

除去注解处理器,我们再回头看一下javac编译的整体流程:

image.png

从上图可以看到,编译大致可以分为三个部分:

  • 词法分析:通过此法分析器得到token流
  • 语法分析:通过语法分析器得到抽象语法树,并进行语法树结构验证
  • 语义分析:通过语义分析器得到注解语法树,进行语法树的逻辑验证

经过以上三步之后,最终生成字节码文件,编译结束。

javac的编译流程是一个复杂的过程,如果能够对其有所了解,对我们掌握java和jvm虚拟机还是有很大的帮助的。

参考资料