You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

25 KiB

10 | Java编译器语法分析之后还要做些什么

你好,我是宫文学。

上一讲我带你了解了Java语言编译器的词法分析和语法分析功能这两项工作是每个编译器都必须要完成的。那么根据第1讲我对编译过程的介绍接下来就应该是语义分析和生成IR了。对于javac编译器来说生成IR也就是字节码以后编译器就完成任务了。也就是说javac编译器基本上都是在实现一些前端的功能。

不过由于Java的语法特性很丰富所以即使只是前端它的编译功能也不少。那么除了引用消解和类型检查这两项基础工作之外你是否知道注解是在什么时候处理的呢泛型呢还有各种语法糖呢

所以今天这一讲我就带你把Java编译器的总体编译过程了解一遍。然后我会把重点放在语义分析中的引用消解、符号表的建立和注解的处理上。当你学完以后你就能真正理解以下这些问题了

  • 符号表是教科书上提到的一种数据结构但它在Java编译器里是如何实现的编译器如何建立符号表
  • 引用消解会涉及到作用域那么作用域在Java编译器里又是怎么实现的
  • 在编译期是如何通过注解的方式生成新程序的?

为了方便你理解Java编译器内部的一些对象结构我画了一些类图如果你不习惯看类图的话可以参考下面的图表说明比如我用方框表示一个类用小圆圈表示一个接口几种线条分别代表继承关系、引用关系和接口实现

图1 :课程中用到的类图的图表说明

在课程开始之前,我想提醒几点:建议你在一个良好的学习环境进入今天的学习,因为你需要一步步地,仔细地跟住我的脚步,避免在探索过程中迷路;除此之外,你的手边还需要一个电脑,这样随时可以查看我在文章中提到的源代码。

了解整个编译过程

现在你可以打开jdk.compiler模块中的com.sun.tools.javac.comp包对应的源代码目录。

comp应该是Compile的缩写。这里面有一个com.sun.tools.javac.comp.CompileStates类它的意思是编译状态。其中有一个枚举类型CompileState里面列出了所有的编译阶段。

你会看到词法和语法分析只占了一个环节PARSE生成字节码占了一个环节而剩下的8个环节都可以看作是语义分析工作建立符号表、处理注解、属性计算、数据流分析、泛型处理、模式匹配处理、Lambda处理和去除其他语法糖

public enum CompileState {
    INIT(0),          //初始化
    PARSE(1),         //词法和语法分析 
    ENTER(2),         //建立符号表
    PROCESS(3),       //处理注解
    ATTR(4),          //属性计算
    FLOW(5),          //数据流分析
    TRANSTYPES(6),    //去除语法糖:泛型处理
    TRANSPATTERNS(7), //去除语法糖:模式匹配处理
    UNLAMBDA(8),      //去除语法糖LAMBDA处理(转换成方法)
    LOWER(9),         //去除语法糖内部类、foreach循环、断言等。
    GENERATE(10);     //生成字节码
    ...
}

另外,你还可以打开com.sun.tools.javac.main.JavaCompiler的代码,看看它的compile()方法。去掉一些细节,你会发现这样的代码主干,从中能看出编译处理的步骤:

processAnnotations(                             //3处理注解
  enterTrees(stopIfError(CompileState.PARSE,    //2建立符号表
    initModules(stopIfError(CompileState.PARSE, 
      parseFiles(sourceFileObjects))            //1词法和语法分析
    ))
  ),classnames);

...
case SIMPLE:
  generate(                                       //10生成字节码
    desugar(                                      //6~9去除语法糖 
      flow(                                       //5数据流分析
        attribute(todo))));                       //4属性计算  

其中PARSE阶段的成果就是生成一个AST后续的语义分析阶段会基于它做进一步的处理

  • enterTrees()对应ENTER这个阶段的主要工作是建立符号表。
  • processAnnotations()对应PROCESS阶段它的工作是处理注解。
  • attribute()对应ATTR阶段这个阶段是做属性计算我会在下一讲中给你做详细的介绍。
  • flow()对应FLOW阶段主要是做数据流分析。我在第7讲中就提到过数据流分析,那时候是用它来做代码优化。那么,难道在前端也需要做数据流分析吗?它会起到什么作用?这些问题的答案我也会在下一讲中为你揭晓。
  • desugar()去除语法糖其实这里包括了TRANSTYPES处理泛型、TRANSPATTERNS处理模式匹配、UNLAMBDA处理Lambda和LOWER处理其他所有的语法糖比如内部类、foreach循环等四个阶段我会在第12讲给你介绍。
  • generate()生成字节码对应了GENERATE阶段这部分内容我也会在第12讲详细介绍。

在今天这一讲,我会给你介绍前两个阶段的工作:建立符号表和处理注解。

首先我们来看看Enter阶段也就是建立符号表的过程。

ENTER阶段建立符号表

Enter阶段的主要代码在com.sun.tools.javac.comp.Enter类中。在这个阶段,会把程序中的各种符号加到符号表中。

建立符号表

第5讲中,我已经介绍了符号表的概念。符号表是一种数据结构,它保存了程序中所有的定义信息,也就是你定义的每个标识符,不管是变量、类型,还是方法、参数,在符号表里都有一个条目。

那么,我们再深入看一下,什么是符号。

其实符号代表了一门语言的基础构成元素。在java.compiler模块中定义了Java语言的构成元素Element包括模块、包、类型、可执行元素、变量元素等。这其中的每个元素都是一种符号。

图2Java语言的基本构成元素

而在jdk.compiler模块中定义了这些元素的具体实现也就是Symbol符号。

图3Symbol及其子类

符号里记录了一些重要的属性信息比如名称name、类型type、分类kind、所有者owner还有一些标记位标志该符号是否是接口、是否是本地的、是否是私有的等等这些信息在语义分析和后续编译阶段都会使用。另外不同的符号还有一些不同的属性信息比如变量符号会记录其常数值constValue这在常数折叠优化时会用到。

**那么Enter过程是怎样发生的呢**你可以看一下com.sun.tools.javac.comp.MemberEnter类中的 visitVarDef()方法

实际上当看到一个方法使用visit开头的时候你应该马上意识到这个方法是被用于一个Visitor模式的调用中。也就是说Enter过程是一个对AST的遍历过程遍历的时候会依次调用相应的visit方法。visitVarDef()是用于处理变量声明的。

我们还以MyClass的编译为例来探索一下。MyClass有一个成员变量a在Enter阶段编译器就会为a建立符号。

我们来看看它的创建过程:

public class MyClass {
    public int a = 2+3;
    public int foo(){
        int b = a + 10;
        return b;
    }
}

我从visitVarDef()中挑出了最重要的三行代码,需要你着重关注。

...
//创建Symbol
VarSymbol v = new VarSymbol(0, tree.name, vartype, enclScope.owner); 
...
tree.sym = v;       //关联到AST节点
...
enclScope.enter(v); //添加到Scope中
...

第一行,是创建Symbol

第二行,是把Symbol关联到对应的AST节点这里是变量声明的节点JCVaraibleDecl

你可以看一下各个AST节点的定义其中的类、方法、变量声明、编译单元以及标识符都带有一个sym成员变量用来关联到一个符号。这样后续在遍历树的时候你就很容易查到这个节点对应的Symbol了。

不过你要注意,**各种声明节点类声明、方法声明等对应的符号是符号的定义。而标识符对应的Symbol是对符号的引用。**找到每个标识符对应的定义就是语义分析中的一项重要工作引用消解。不过引用消解不是在Enter阶段完成的而是在ATTR阶段。

你在跟踪编译器运行的时候可以在JCClassDecl等AST节点的sym变量上打个中断标记这样你就会知道sym是什么时候被赋值的从而也就了解了整个调用栈这样会比较省事。

延伸一句:当你调试一个大的系统时,选择好恰当的断点很重要,会让你事半功倍。

图4一些重要的AST节点的属性和方法其中多个AST节点中都有对Symbol的引用

最后来看一下第三行代码,这行代码是把Symbol添加到Scope中

什么是ScopeScope就是作用域。也就是说在Enter过程中作用域也被识别了出来每个符号都是保存在相应的作用域中的。

第4讲我们曾经说过符号表可以采用与作用域同构的带层次的表格。Java编译器就是这么实现的。符号被直接保存进了它所在的词法作用域。

在具体实现上Java的作用域所涉及的类比较多我给你整理了一个类图你可以参考一下

图5与Scope有关的类

其中有几个关键的类和接口,需要给你介绍一下。

首先是com.sun.tools.javac.code.Scope$ScopeImpl这是真正用来存放Symbol的容器类。通过next属性来指向上一级作用域形成嵌套的树状结构。

图6AST中的作用域

但是在处理AST时如何找到当前的作用域呢这就需要一个辅助类Env< AttrContext>。Env的意思是环境用来保存编译过程中的一些上下文信息其中就有当前节点所处的作用域Env.info.scope。下图展示的是在编译过程中所使用的Env的情况这些Env也构成了一个树状结构。

图7Env< AttrContext>构成的树状结构

然后是**com.sun.source.tree.Scope接口**这是对作用域的一个抽象,可以获取当前作用域中的元素、上一级作用域、上一级方法以及上一级类。

好了,这就是与符号表有关的数据结构,后续的很多处理工作都要借助这个数据结构。比如,你可以思考一下,如何基于作用域来做引用消解?在下一讲,我会给你揭晓这个问题的答案。

两阶段的处理过程

前面讨论的是符号表的数据结构,以及建立符号表的大致过程。接下来,我们继续深究一下建立符号表算法的一个重要特点:Enter过程是分两个阶段完成的

你可以打开Enter类看看Enter类的头注释里面对这两个阶段做了说明。

  1. 第一个阶段:只是扫描所有的类(包括内部类),建立类的符号,并添加到作用域中。但是每个类定义的细节并没有确定,包括类所实现的接口、它的父类,以及所使用的类型参数。类的内部细节也没有去扫描,包括其成员变量、方法,以及方法的内部实现。
  2. 第二个阶段:确定一个类所缺失的所有细节信息,并加入到符号表中。

这两个阶段,第一个阶段做整个程序的扫描,把所有的类都识别出来。而第二个阶段是在需要的时候才进行处理的。

这里的问题是:为什么需要两个阶段?只用一个阶段不可以吗?

我借一个例子给你解释一下原因。你看看下面这段示例代码在Enter过程中编译器遍历了MyClass1的AST节点JCClassDecl并建立了一个ClassSymbol。但在遍历到它的成员变量a的时候会发现它不认识a的类型MyClass2因为MyClass2的声明是在后面的。

public class MyClass1{
   MyClass2 a;  
}
class MyClass2{
}

怎么办呢我们只好分成两个阶段去完成扫描。在第一个阶段我们为MyClass1和MyClass2都建立符号并且都放到符号表中第二阶段我们再去进一步扫描MyClass1的内部成员的时候就能为成员变量a标注正确的类型也就是MyClass2。

我在第4讲中说过,语义分析的特点是上下文相关的。通过对比,你会发现,处理上下文相关情况和上下文无关情况的算法,它们是不一样的。

语法解析算法处理的是上下文无关的情况因此无论自顶向下还是自底向上整个算法其实是线性执行的它会不断地消化掉Token最后产生AST。对于上下文相关的情况算法就要复杂一些。对AST各个节点的处理会出现相互依赖的情况并且经常会出现环形依赖因为两个类互相引用在Java语言里是很常见的。加上这些依赖关系以后AST就变成了一张图。

而语义分析算法,实质上就是对图的遍历算法。我们知道,图的遍历算法的复杂度是比较高的。编译器一般要采用一定的**启发式Heuristic**的算法人为地找出代价较低的遍历方式。Java编译器里也采用了启发式的算法我们尽量把对图的遍历简化为对树的遍历这样工作起来就会简单得多。

对AST的遍历采用了Visitor模式。下图中我列出了一些采用Visitor模式对AST进行处理的程序。Enter程序是其中的一个。

图8对AST进行处理的Visitor模式的部分程序

所以语义分析就是由各种对AST进行遍历的算法构成的。在跟踪Java编译器执行的过程中你还会发现多个处理阶段之间经常发生交错。比如对于方法体中声明的局部变量它的符号不是在ENTER阶段创建的而是在ATTR阶段又回过头来调用了与建立符号表有关的方法。你可以先想想这又是什么道理。这里留下一个伏笔我会在下一讲中给你解答。

系统符号表

前面说的符号表保存了用户编写的程序中的符号。可是还有一些符号是系统级的可以在不同的程序之间共享比如原始数据类型、java.lang.Object和java.lang.String等基础对象、缺省的模块名称、顶层的包名称等。

Java编译器在Symtab类中保存这些系统级的符号。系统符号表在编译的早期就被初始化好并用于后面的编译过程中。

以上就是ENTER阶段的所有内容。接着编译器就会进入下一个阶段PROCESS阶段也就是处理注解。

PROCESS阶段处理注解

注解是Java语言中的一个重要特性它是Java元编程能力的一个重要组成部分。所谓元编程简单地说就是用程序生成或修改程序的能力。

Java的注解需要在编译期被解析出来。在Java编译器中注解被看作是符号的元数据所以你可以看一下SymbolMetadata类它记录了附加在某个符号上的各种注解信息。

然后呢,编译器可以在三个时机使用这些注解:一是在编译时,二是在类加载时,三是在类运行时。

对于后两者编译器需要做的工作比较简单把注解内容解析出来放到class文件中。这样的话PROCESS阶段不需要做什么额外的工作。

而有些注解是要在编译期就处理的这些注解最后就没必要保存到class文件。因为它们的使命在编译阶段就完成了。

那在编译阶段会利用注解做什么呢最主要的用法是根据注解动态生成程序并且也被编译器编译。在后面探索Java的JIT编译器时你会看到采用这种思路来生成程序的实例。你可以用简单的注解就让注解处理程序生成很长的、充满“刻板代码”的程序。

我写了一个非常简单的示例程序来测试Java编译器处理注解的功能。该注解叫做HelloWorld

@Retention(RetentionPolicy.SOURCE)  //注解用于编译期处理
@Target(ElementType.TYPE)           //注解是针对类型的
public @interface HelloWorld {
}

针对这个注解需要写一个注解处理程序。当编译器在处理该注解的时候就会调用相应的注解处理程序。你可以看一下HelloWorldProcessor.java程序。它里面的主要逻辑是获取被注解的类的名称比如说叫Foo然后生成一个HelloFoo.java的程序。这个程序里有一个sayHello()方法能够打印出“Hello Foo”。如果被注解的类是Bar那就生成一个HelloBar.java并且打印“Hello Bar”。

我们看一下Foo的代码。你注意这里面有一个很有意思的现象在Foo里调用了HelloFoo但HelloFoo其实当前并没有生成

@HelloWorld
public class Foo {
    //HelloFoo类是处理完注解后才生成的。
    static HelloFoo helloFoo = new HelloFoo();
    public static void main(String args[]){
        helloFoo.sayHello(); 
    }
}

你可以在命令行编译这三个程序。其中编译Foo的时候要用-processor选项指定所采用的注解处理器。

javac HelloWorld.java    
javac HelloWorldProcessor.java
javac -processor HelloWorldProcessor Foo.java

在这个编译过程中你会发现当前目录下生成了HelloFoo.java文件并且在编译Foo.java之前就被编译了这样在Foo里才能调用HelloFoo的方法。

你可以在IDE里跟踪一下编译器对注解的处理过程。借此你也可以注意一下编译器是如何管理编译顺序的因为HelloFoo一定要在Foo之前被编译。

扩展Debug对注解的处理过程需要有一定的技巧请参考我为你整理的配置指南

你会发现在Enter之后声明helloFoo这个成员变量的语句的vartype节点的类型是ErrorType证明这个时候编译器是没有找到HelloFoo的定义的。

图9在处理注解之前还没有生成HelloFoo

不过在编译器处理完毕注解以后HelloFoo就会被生成Foo类的ENTER过程还会重走一遍这个时候相关类型信息就正确了。

课程小结

好了本讲我们首先对Java的编译过程做了一个顶层的介绍然后分析了ENTER和PROCESS阶段所做的工作。希望你能有以下收获

  1. 对前端编译过程可以有更加细致的了解特别是对语义分析阶段会划分成多个小阶段。由于语法分析的本质是对图做处理所以实际执行过程不是简单地逐个阶段顺序执行而是经常交织在一起特别是ENTER阶段和ATTR阶段经常互相交错。
  2. ENTER阶段建立了符号表这样一个重要的数据结构。我们现在知道Java的符号表是按照作用域的结构建立的而AST的每个节点都会对应某个作用域。
  3. PROCESSOR阶段完成了对注解的处理。你可以在代码里引用将要生成的类做完注解处理后这些类会被生成并编译从而使得原来的程序能够找到正确的符号不会报编译错误。

在最后,为了帮你将今天的内容做一个梳理,我提供一张思维导图,供你参考,整理知识:

一课一思

在Java语言中对于下面的示例代码会产生几个作用域你觉得它们分别是什么

public class ScopeTest{
    public int foo(int a){
        if(a>0){
          //一些代码
        } 
        else{
          //另一些代码
        }
    }
}

欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

参考资料

关于注解的官方教程,你可以参考这个链接

扩展知识

Java编译器的功能很多。如果你有精力还可以探索一些有趣的细节功能比如你知道Java在编译阶段会自动生成缺省的构造函数吗

ENTER的第二个阶段任务是由TypeEnter类来完成的你可以查看一下这个类的说明。它内部划分成了4个小的阶段每个阶段完成一个更小一点的任务。其中的MemberPhase阶段会把类的成员都建立符号但MemberPhase还会做一件有趣的事情就是生成缺省的构造函数

为什么说这个细节很有趣呢因为这是你第一次遇到在语义分析的过程中还要对AST做修改。下面我们看看这个过程。

首先,你需要重新回顾一下缺省构造函数的意思。

在编写Java程序时你可以不用写构造函数。对于下面这个MyClass5类我们没有写构造函数也能正常地实例化

public class MyClass5{
}

但在语义分析阶段实际上编译器会修改AST插入一个缺省构造函数相当于下面的代码。缺省的构造函数不带参数并且调用父类的一个不带参数构造方法对于MyClass5类来说父类是java.lang.Object类“super()”引用的就是Object类的不带参数的构造方法

public class MyClass3{
    public MyClass3(){
        super();
    }  
}

对应的AST如下其中JCMethodDecl这棵子树,就是语义分析程序插入的。

图10生成缺省构造函数之后的AST以及关联的Symbol

新插入的构造方法是以JCMethodDecl为根节点的一棵子树。对于这个JCMethodDecl节点在属性标注做完以后形成了下面的属性。

  • 名称:<init>
  • 类型:()void也就是没有参数返回值为void。
  • 符号生成了一个方法型的符号sym属性它的名称是<init>如果调用sym.isConstructor()方法返回true也就是说这个符号是一个构造方法。

在这个缺省构造方法里调用了“super();”这样一个语句,让父类有机会去做初始化工作,进而也让父类的父类有机会去做初始化工作,依次类推。

“super()”语句的叶子节点是一个JCIndent节点也就是标识符。这个标识符的名称是”super“而符号sym属性则通过变量引用消解指向了Object类的构造方法。

最后我们声明MyClass5类的时候也并没有声明它继承自Object。这个信息也是自动推断出来的并且会在类节点JCClassDecl的type属性中标注清楚。在图中你可以看到type.supertype_field指向了Object这个类型。

除了在自动生成的缺省构造函数里会调用super(),你还知道,当我们手写一个构造函数的时候,也可以在第一句里调用父类的一个构造方法(并且必须是在第一句)。

public class MyClass4 extends MyClass3{
    public MyClass4(int a){
        super(a);   //这句可以省略,编译器可以自动生成
        ...
    }  
}

如果你不显式地调用super()编译器也会自动加入这样的一个调用并生成相应的AST。这个时候父类和子类的构造方法的参数也必须一致。也就是说如果子类的构造方法的签名是int, String那么父类也必须具备相同签名的一个构造方法否则没有办法自动生成对父类构造方法的调用语句编译器就会报错。我相信你很可能在编程时遇到过这种编译信息,不过现在你应该就能清晰地了解,为什么编译器会报这些类型的编译错误了。

总体来说Java的编译器会根据需要加入一些AST节点实现一些缺省的功能。其中包括缺省的构造方法、对父类构造方法的缺省调用以及缺省的父类Object