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.

21 KiB

09 | Java编译器手写的编译器有什么优势

你好,我是宫文学。

从今天开始呢,我会带着你去考察实际编译器的具体实现机制,你可以从中学习和印证编译原理的基础知识,进而加深你对编译原理的理解。

我们探险的第一站是很多同学都很熟悉的Java语言我们一起来看看它的编译器里都有什么奥秘。我从97年就开始用它算是比较早了。当时我就对它的“一次编译到处运行”留下了很深的印象我在Windows下写的程序编译完毕以后放到Solaris上就能跑。现在看起来这可能不算什么但在当年我在Windows和Unix下写程序用的工具可是完全不同的。

到现在Java已经是一门非常成熟的语言了而且它也在不断进化与时俱进泛型、函数式编程、模块化等特性陆续都增加了进来。在服务端编程领域它也变得非常普及。

与此同时Java的编译器和虚拟机中所采用的技术也比20年前发生了天翻地覆的变化。对于这么一门成熟的、广泛普及的、又不断焕发新生机的语言来说研究它的编译技术会带来两个好处一方面Java编译器所采用的技术肯定是比较成熟的、靠谱的你在实现自己的编译功能时完全可以去参考和借鉴另一方面你可以借此深入了解Java的编译过程借此去实现一些高级的功能比方说按需生成字节码就像Spring这类工具一样。

因此我会花4讲的时间跟你一起探索Java的前端编译器javac。然后再花4讲的时间在Java的JIT编译器上。

那么针对Java编译器你可能会提出下面的问题

  • Java的编译器是用什么语言编写的
  • Java的词法分析器和语法分析器是工具生成的还是手工编写的为什么会这样选择
  • 语法分析的算法分为自顶向下和自底向上的。那么Java的选择是什么呢有什么道理吗
  • 如何自己动手修改Java编译器

这些问题,在今天的旅程结束后,你都会获得解答。并且,你还会获得一些额外的启发:噢,原来这个功能是可以这样做的呀!这是对你探险精神的奖励。

好吧,让我们开始吧。

第一步我们先初步了解一下Java的编译器。

初步了解Java的编译器

大多数Java工程师是通过javac命令来初次接触Java编译器的。假设你写了一个MyClass类

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

你可以用javac命令把MyClass.java文件编译成字节码文件

javac MyClass.java

那这个javac的可执行文件就是Java的编译器吗并不是。javac只是启动了一个Java虚拟机执行了一个Java程序跟我们平常用“java”命令运行一个程序是一样的。换句话说Java编译器本身也是用Java写的。

这就很有趣了。我们知道,计算机语言是用来编写软件的,而编译器也是一种软件。所以,一门语言的编译器,竟然可以用自己来实现。这种现象,叫做“自举”(Bootstrapping),这就好像一个人抓着自己的头发,要把自己提起来一样,多么神奇!实际上,一门语言的编译器,一开始肯定是要用其他语言来实现的。但等它成熟了以后,就会尝试实现自举。

既然Java编译器是用Java实现的那意味着你自己也可以写一个程序来调用Java的编译器。比如运行下面的示例代码也同样可以编译MyClass.java文件生成MyClass.class文件

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;

public class CompileMyClass {
    public static void main(String[] args) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        int result = compiler.run(null, null, null, "MyClass.java");
        System.out.println("Compile result code = " + result);
    }
}

其中javax.tools.JavaCompiler就是Java编译器的入口属于java.compiler模块。这个模块包含了Java语言的模型、注解的处理工具以及Java编译器的API。

javax.tools.JavaCompiler的实现是com.sun.tools.javac.main.JavaCompiler。它在jdk.compiler模块中这个模块里才是Java编译器的具体实现。

不过在探索Java编译器的实现原理之前你还需要从openjdk.java.net下载JDK的源代码我使用的版本是JDK14。在IDE中跟踪JavaCompiler的执行过程你就会看到它一步一步地都是使用了哪个类的哪个方法。Java的IDE工具一般都比较友好给我们的探索提供了很多便利。

不仅如此你还可以根据openjdk的文档从源代码构建出JDK。你还可以修改源代码并构建你自己的版本。

获得了源代码以后我建议你重点关注这几个地方的源代码这能帮助你迅速熟悉Java编译器的源代码结构。

**首先是com.sun.source.tree包**这个包里面是Java语言的AST模型。我们在写一个编译器的时候肯定要设计一个数据结构来保存AST那你就可以去参考一下Java是怎么做的。接下来我就挑其中几个比较常用的节点给你解释一下

  • ExpressionTree指的是表达式各种不同的表达式继承了这个接口比如BinaryTree代表了所有的二元表达式
  • StatementTree代表了语句它的下面又细分了各种不同的语句比如IfTree代表了If语句而BlockTree代表的是一个语句块。

图1com.sun.source.tree包里的Java语言的AST模型

然后是com.sun.tools.javac.parser.Lexer词法解析器接口它可以把字符流变成一个个的Token具体的实现在Scanner和JavaTokenizer类中。

接下来是com.sun.tools.javac.parser.Parser语法解析器接口它能够解析类型、语句和表达式具体的实现在JavacParser类中。

总结起来Java语言中与编译有关的功能放在了两个模块中其中java.compiler模块主要是对外的接口而jdk.compiler中有具体的实现。**不过你要注意,**像com.sun.tools.javac.parser包中的类不是Java语言标准的组成部分如果你直接使用这些类可能导致代码在不同的JDK版本中不兼容。

现在我们已经熟悉了Java编译器的概要信息。在浏览这两个模块的代码时我们会发现里面的内容非常多。为了让自己不会迷失在其中我们需要找到一个方法。你已经知道编译器的前端分为词法分析、语法分析、语义分析等阶段那么我们就可以按照这个阶段一块一块地去探索。

首先我们看看Java的词法分析器。

词法分析器也是构造了一个有限自动机吗?

通过跟踪执行你会发现词法分析器的具体实现在JavaTokenizer类中。你可以先找到这个类在readToken()方法里打个断点,让程序运行到这里,然后查看词法分析的执行过程。

在学词法分析的时候,你肯定知道要构造一个有限自动机,而且当输入的字符发生变化的时候,自动机的状态也会产生变化。

图2一个有限自动机能够区分数字字面量状态1和标识符状态2

**那么实战中Java做词法分析的逻辑是什么呢**你可以先研究一下readToken()方法这个方法实现了主干的词法分析逻辑它能够从字符流中识别出一个个的Token来。

readToken的逻辑变成伪代码是这样的

循环读取字符
  case 空白字符
    处理,并继续循环
  case 行结束符
    处理,并继续循环
  case A-Za-z$_
    调用scanIden()识别标识符和关键字,并结束循环
  case 0之后是X或x或者1-9
    调用scanNumber()识别数字,并结束循环   
  case , ; ( ) [ ]等字符
    返回代表这些符号的Token并结束循环
  case isSpectial(),也就是% * + - | 等特殊字符
    调用scanOperator()识别操作符
  ...    

如果画成有限自动机,大致是这样的:

图3Java词法分析器的有限自动机

第2讲中我提到过,关键字和标识符的规则是冲突的:

  • 标识符的规则是以A-Za-z$_开头,后续字符可以是A-Za-z$_、数字和其他的合法字符;
  • 关键字比如if也符合标识符的规则可以说是标识符的子集。

这种冲突是词法分析的一个技术点因为不到最后你不知道读入的是一个关键字还是一个普通的标识符。如果单纯按照有限自动机的算法去做词法分析想要区分int关键字和其他标识符的话你就会得到图4那样的一个有限自动机。

当输入的字符串是“int”的时候它会进入状态4。如果这个时候遇到结束字符就会提取出int关键字。除此之外“i”状态2、“in”状态3和“intA”状态5都属于标识符。

图4能够处理int和标识符语法的有限自动机

但是关键字有很多if、else、int、long、class…如果按照这个方式构造有限自动机就会很啰嗦。那么java是怎么处理这个问题的呢

Java编译器的处理方式比较简单分成了两步首先把所有的关键字和标识符都作为标识符识别出来然后再从里面把所有预定义的关键字挑出来。这比构造一个复杂的有限自动机实现起来更简单

通过这样的代码分析你可以发现Java的词法解析程序在主干上是遵循有限自动机的算法的但在很多局部的地方为了让词法分析的过程更简单高效采用了手写的算法。

我建议你在IDE中采用调试模式跟踪执行看看每一步的执行结果这样你能对Java词法分析的过程和结果有更直观的理解。另外你还可以写一个程序直接使用词法分析器做解析并打印出一个个Token。这会很有趣你可以试试看

接下来我们进一步研究一下Java的语法分析器。

语法分析器采用的是什么算法?

跟所有的语法分析器一样Java的语法分析器会把词法分析器生成的Token流生成一棵AST。

下面的AST就是MyClass.java示例代码对应的AST其中的JCXXX节点都是实现了com.sun.source.tree中的接口比如JCBinary实现了BinaryTree接口而JCLiteral实现了LiteralTree接口

图5MyClass.java对应的AST

我想你应该知道,语法分析的算法分为自顶向下和自底向上两种:

  • 以LL算法为代表的自顶向下的算法比较直观、容易理解但需要解决左递归问题
  • 以LR算法为代表的自底向上算法能够避免左递归问题但不那么直观不太容易理解。

那么Java编译器用的是什么算法呢

你可以打开com.sun.tools.javac.parser.JavacParser这个类看一下代码。比如你首先查看一下parseExpression()方法(也就是解析一个表达式)。阅读代码,你会看到这样的调用层次:

图6解析表达式时的调用层次

我们以解析“2+3”这样一个表达式来一层一层地理解下这个解析过程。

第1步需要匹配一个term。

term是什么呢其实它就是赋值表达式比如“a=2”或“b=3”等。算法里把这样一个匹配过程又分为两部分赋值符号左边的部分是term1其他部分是termRest。其中term1是必须匹配上的termRest是可选的。如果匹配上了termRest那么证明这是个赋值表达式否则就只是左边部分也就是term1。

如果你比较敏感的话,那仅仅分析第一步,你差不多就能知道这是什么算法了。

另外你可能还会对Rest这个单词特别敏感。你还记得我们在什么地方提到过Rest这个词汇吗是的第3讲我把左递归改写成右递归的时候那个右递归的部分我们一般就叫做XXXRest或XXXTail。

不过没关系,你可以先保留着疑问,我们继续往下看,来印证一下看法是不是对的。

第2步匹配term1。

term1又是什么呢term1是一个三元表达式比如a > 3 ? 1 : 2。其中比较操作符左边的部分是term2剩下的部分叫做term1Rest。其中term2是必须匹配的term1Rest是可选的。

第3步匹配term2。

term2代表了所有的二元表达式。它再次分为term3和term2Rest两部分前者是必须匹配的后者是可选的。

第4步匹配term3。

term3往下我就不深究了总之是返回一个字面量2。

第5步匹配term2Rest。

首先匹配“+”操作符然后匹配一个term3()这里是返回一个字面量3。

第6步回到term1()方法试图匹配term1Rest没有匹配上。

第7步回到term()方法试图匹配termRest也没有匹配上。

第8步从term()方法返回一个代表“2+3”的AST,如下图所示:

图7“2+3”对应的AST

讲到这儿,我想问问你:你从这样的分析中,得到了什么信息?

**第一,这是一个递归下降算法。**因为它是通过逐级下降的方法来解析从term到term1、term2、term3直到最后是字面量这样最基础的表达式。

在第3讲里我说过递归下降算法是每个程序员都应该掌握的语法分析算法。你看像Java这么成熟的语言其实采用的也是递归下降算法。

**第二Java采用了典型的消除左递归的算法。**我带你回忆一下,对于:

add -> add + mul

这样的左递归的文法,它可以改成下面的非左递归文法:

add -> mul add'
add' -> + add' | ε

如果我再换一下表达方式就会变成Java语法解释器里的代码逻辑

term2 -> term3 term2Rest
term2Rest -> + term3 | ε

第三Java编译器对优先级和结合性的处理值得深究。

首先看看优先级。我们通常是通过语法逐级嵌套的方式来表达优先级的。比如按照下面的语法规则生成的AST乘法节点会在加法节点下面因此先于加法节点计算从而优先级更高。实际上Java做语法分析的时候term1->term2->term3的过程也是优先级逐步提高的过程。

add -> mul add'
add' -> + mul add' | ε
mul -> pri mul'
mul' -> * pri mul' | ε

可是在term2中实际上它解析了所有的二元表达式在语法规则上它把使用“&&”“ >”“+”“*” 这些不同优先级的操作符的表达式,都同等看待了。

term2 -> term3 term2Rest
term2Rest -> (&& | > | + | * |...) term3 | ε

不过,这里面包含了多个优先级的运算符,却并没有拆成很多个级别,这是怎么实现的呢?

我们再来看看结合性。对于“2+3+4”这样一个表达式我在第3讲,是把右递归调用转换成一个循环,让新建立的节点成为父节点,从而维护正确的结合性。

如果你阅读term2Rest的代码就会发现它的处理逻辑跟第3讲是相同的也就是说它们都是用循环的方式来处理连续加法或者连续乘法并生成结合性正确的AST。

不过Java编译器的算法更厉害。它不仅能用一个循环处理连续的加法和连续的乘法对于“2+3*5”这样采用了多种不同优先级的操作符的表达式也能通过一个循环就处理掉了并且还保证了优先级的正确性。

在term2Rest中可以使用多个优先级的操作符从低到高的顺序如下

 "||"
 "&&"
 "|"
 "^"
 "&"
 "==" | "!="
 "<" | ">" | "<=" | ">="
 "<<" | ">>" | ">>>"
 "+" | "-"
 "*" | "/" | "%"

如果按照常规的写法我们处理上面10级优先级的操作符需要写10级嵌套的结构。而Java用一级就解决了。这个秘密就在term2Rest()的实现中。我们以“2*3+4*5”为例分析一下。

term2Rest()算法维护了一个操作数的栈odStack和操作符的栈opStack作为工作区。算法会根据odStack、opStack和后续操作符这三个信息决定如何生成优先级正确的AST。我把解析“2*3+4*5”时栈的变化,画成了一张图。

图8解析“2*3+4*5”的时候odStack、opStack和后续操作符的变化

在一步一步解析的过程中当opStack的栈顶运算符的优先级大于等于后续运算符的优先级时就会基于odStack栈顶的两个元素创建一棵二元表达式的子树就像第2步那样。

反过来的话栈顶运算符的优先级小于后续运算符的优先级像第4步那样就会继续把操作数和操作符入栈而不是创建二元表达式。

这就可以保证优先级高的操作符形成的子树总会在最后的AST的下层从而优先级更高。

再仔细研究一下这个算法你会发现它是借助一个工作区自底向上地组装AST。**是不是觉得很眼熟是不是想到了LR算法**没错这就是一个简单LR算法。操作数栈和操作符栈是工作区然后要向后预读一个运算符决定是否做规约。只不过做规约的规则比较简单依据相邻的操作符的优先级就可以了。

其实,这种处理表达式优先级的解析方法,有一个专有的名字,就叫做**“运算符优先级解析器Operator-Precedence Parser”**。Java编译器用这一个算法处理了10个优先级的二元表达式的解析同时又不用担心左递归问题确实很棒

课程小结

本节课我带你揭秘了Java编译器的一角我想强调这样几个重点。

第一你要大致熟悉一下Java语言中与编译有关的模块、包和类。这样在你需要的时候可以通过编程来调用编译器的功能在运行时动态编译Java程序并动态加载运行。

第二Java的词法分析总体上是遵循有限自动机的原理但也引入了不少的灵活性。比如在处理标识符和关键字的词法规则重叠的问题上是先都作为标识符识别出来然后再把其中的关键词挑出来。

第三Java的语法分析总体上是自顶向下的递归下降算法。在解决左递归问题时,也采用了标准的改写文法的方法。但是,在处理二元表达式时,局部采用了自底向上运算符优先级解析器,使得算法更简洁。

当然了我没有覆盖所有的词法解析和语法解析的细节。但你按照今天这一讲的分析思路完全能看懂其他部分的代码。通过我帮你开的这个头我期待你继续钻研下去搞清楚Java的词法和语法解析功能的每个细节。

比如,递归下降算法中最重要的是要减少试错次数,一下子就能精准地知道应该采用哪个产生式。**而你通过阅读代码会了解Java的编译器是如何解决这个问题的**它在一些语法上会预读一个Token在另外的语法上会预读两个、三个Token以及加上一些与上下文有关的代码通过种种方式来减少回溯提高编译性能。这实际上就是采用了LL(k)算法的思路而k值是根据需要来增加的。

通过今天的分析你会发现Java编译器在做词法和语法分析的时候总体上遵循了编译原理中的知识点比如构造有限自动机、改写左递归文法等等但又巧妙地引入了不少的变化包括解决词法规则冲突、融合了自顶向下算法和自底向上算法、根据情况灵活地预读1到多个Token等。我相信对你会大有启发像这样的实战知识恐怕只有分析实际编译器才能获得更进一步地你以后也可以用这样漂亮的方法解决问题。这就是对你这次探险的奖励。

我把这一讲的知识点用思维导图整理出来了,供你参考:

一课一思

运算符优先级解析器非常实用我们通过练习巩固一下对它的认识。你能推导一下解析“a>b*2+3”的时候odStack、opStack和后续运算符都是什么吗你也可以跟踪Java编译器的执行过程验证一下你的推导结果。

你可以在留言区交一下作业。比如像这样:

step1: a
step2: a,b  >  *     //用逗号分隔栈里的多个元素
...

我会在下一讲的留言区,通过置顶的方式公布标准答案。好了,这节课就到这里,感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。