mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/09 | Java编译器(一):手写的编译器有什么优势?.md
Normal file
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/09 | Java编译器(一):手写的编译器有什么优势?.md
Normal file
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="09 | Java编译器(一):手写的编译器有什么优势?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/a7/0f267cc3e8a87343eec1425225b971a7.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
从今天开始呢,我会带着你去考察实际编译器的具体实现机制,你可以从中学习和印证编译原理的基础知识,进而加深你对编译原理的理解。
|
||||
|
||||
我们探险的第一站,是很多同学都很熟悉的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的[文档](https://hg.openjdk.java.net/jdk/jdk11/raw-file/tip/doc/building.html),从源代码构建出JDK。你还可以修改源代码并构建你自己的版本。
|
||||
|
||||
获得了源代码以后,我建议你重点关注这几个地方的源代码,这能帮助你迅速熟悉Java编译器的源代码结构。
|
||||
|
||||
**首先是com.sun.source.tree包,**这个包里面是Java语言的AST模型。我们在写一个编译器的时候,肯定要设计一个数据结构来保存AST,那你就可以去参考一下Java是怎么做的。接下来,我就挑其中几个比较常用的节点,给你解释一下:
|
||||
|
||||
- ExpressionTree指的是表达式,各种不同的表达式继承了这个接口,比如BinaryTree代表了所有的二元表达式;
|
||||
- StatementTree代表了语句,它的下面又细分了各种不同的语句,比如,IfTree代表了If语句,而BlockTree代表的是一个语句块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/0e/d69600c7a6b2c6aa288277690eaacb0e.jpg" alt="">
|
||||
|
||||
**然后是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()方法里打个断点,让程序运行到这里,然后查看词法分析的执行过程。
|
||||
|
||||
在学词法分析的时候,你肯定知道要构造一个有限自动机,而且当输入的字符发生变化的时候,自动机的状态也会产生变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/14/ac4f8932b2488ad0f3815853a4180114.jpg" alt="">
|
||||
|
||||
**那么实战中,Java做词法分析的逻辑是什么呢?**你可以先研究一下readToken()方法,这个方法实现了主干的词法分析逻辑,它能够从字符流中识别出一个个的Token来。
|
||||
|
||||
readToken的逻辑变成伪代码是这样的:
|
||||
|
||||
```
|
||||
循环读取字符
|
||||
case 空白字符
|
||||
处理,并继续循环
|
||||
case 行结束符
|
||||
处理,并继续循环
|
||||
case A-Za-z$_
|
||||
调用scanIden()识别标识符和关键字,并结束循环
|
||||
case 0之后是X或x,或者1-9
|
||||
调用scanNumber()识别数字,并结束循环
|
||||
case , ; ( ) [ ]等字符
|
||||
返回代表这些符号的Token,并结束循环
|
||||
case isSpectial(),也就是% * + - | 等特殊字符
|
||||
调用scanOperator()识别操作符
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如果画成有限自动机,大致是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/aa/9df3399ca53df76ca9b5e5b4f4d953aa.jpg" alt="">
|
||||
|
||||
在[第2讲](https://time.geekbang.org/column/article/243685)中我提到过,关键字和标识符的规则是冲突的:
|
||||
|
||||
- 标识符的规则是以`A-Za-z$_`开头,后续字符可以是`A-Za-z$_`、数字和其他的合法字符;
|
||||
- 关键字(比如if)也符合标识符的规则,可以说是标识符的子集。
|
||||
|
||||
这种冲突是词法分析的一个技术点,因为不到最后你不知道读入的是一个关键字,还是一个普通的标识符。如果单纯按照有限自动机的算法去做词法分析,想要区分int关键字和其他标识符的话,你就会得到图4那样的一个有限自动机。
|
||||
|
||||
当输入的字符串是“int”的时候,它会进入状态4。如果这个时候遇到结束字符,就会提取出int关键字。除此之外,“i”(状态2)、“in”(状态3)和“intA”(状态5)都属于标识符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/1c/31d083e8e33b25c1b6ea721f20e7ce1c.jpg" alt="">
|
||||
|
||||
但是关键字有很多,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接口)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/10/540c482ffb8914ebfb2b17dbb7584410.jpg" alt="">
|
||||
|
||||
我想你应该知道,语法分析的算法分为自顶向下和自底向上两种:
|
||||
|
||||
- 以LL算法为代表的自顶向下的算法,比较直观、容易理解,但需要解决左递归问题;
|
||||
- 以LR算法为代表的自底向上算法,能够避免左递归问题,但不那么直观,不太容易理解。
|
||||
|
||||
**那么,Java编译器用的是什么算法呢?**
|
||||
|
||||
你可以打开com.sun.tools.javac.parser.JavacParser这个类看一下代码。比如,你首先查看一下parseExpression()方法(也就是解析一个表达式)。阅读代码,你会看到这样的调用层次:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/9d/53181f61a99adb806a11b836feae7d9d.jpg" alt="">
|
||||
|
||||
我们以解析“2+3”这样一个表达式,来一层一层地理解下这个解析过程。
|
||||
|
||||
**第1步,需要匹配一个term。**
|
||||
|
||||
term是什么呢?其实,它就是赋值表达式,比如“a=2”或“b=3”等。算法里把这样一个匹配过程又分为两部分,赋值符号左边的部分是term1,其他部分是termRest。其中,term1是必须匹配上的,termRest是可选的。如果匹配上了termRest,那么证明这是个赋值表达式;否则就只是左边部分,也就是term1。
|
||||
|
||||
如果你比较敏感的话,那仅仅分析第一步,你差不多就能知道这是什么算法了。
|
||||
|
||||
另外,你可能还会对Rest这个单词特别敏感。你还记得我们在什么地方提到过Rest这个词汇吗?是的,在[第3讲](https://time.geekbang.org/column/article/244906)中,我把左递归改写成右递归的时候,那个右递归的部分,我们一般就叫做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**,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/cb/891f0fe35a9df5ddc02c773229298bcb.jpg" alt="">
|
||||
|
||||
讲到这儿,我想问问你:你从这样的分析中,得到了什么信息?
|
||||
|
||||
**第一,这是一个递归下降算法。**因为它是通过逐级下降的方法来解析,从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讲](https://time.geekbang.org/column/article/244906),是把右递归调用转换成一个循环,让新建立的节点成为父节点,从而维护正确的结合性。
|
||||
|
||||
如果你阅读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`”时栈的变化,画成了一张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/01/e44da65b1941adf47423b9550eccf101.jpg" alt="">
|
||||
|
||||
在一步一步解析的过程中,当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等。我相信对你会大有启发!像这样的实战知识,恐怕只有分析实际编译器才能获得!更进一步地,你以后也可以用这样漂亮的方法解决问题。这就是对你这次探险的奖励。
|
||||
|
||||
我把这一讲的知识点用思维导图整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/c9/9cfa983900dddad7c9146efbacc35cc9.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
运算符优先级解析器非常实用,我们通过练习巩固一下对它的认识。你能推导一下解析“a>b*2+3”的时候,odStack、opStack和后续运算符都是什么吗?你也可以跟踪Java编译器的执行过程,验证一下你的推导结果。
|
||||
|
||||
你可以在留言区交一下作业。比如像这样:
|
||||
|
||||
```
|
||||
step1: a
|
||||
step2: a,b > * //用逗号分隔栈里的多个元素
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
我会在下一讲的留言区,通过置顶的方式公布标准答案。好了,这节课就到这里,感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。
|
||||
372
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/10 | Java编译器(二):语法分析之后,还要做些什么?.md
Normal file
372
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/10 | Java编译器(二):语法分析之后,还要做些什么?.md
Normal file
@@ -0,0 +1,372 @@
|
||||
<audio id="audio" title="10 | Java编译器(二):语法分析之后,还要做些什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/a0/b4687fc5a4aa5977a03a68f2b77bd1a0.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我带你了解了Java语言编译器的词法分析和语法分析功能,这两项工作是每个编译器都必须要完成的。那么,根据[第1讲](https://time.geekbang.org/column/article/242479)我对编译过程的介绍,接下来就应该是语义分析和生成IR了。对于javac编译器来说,生成IR,也就是字节码以后,编译器就完成任务了。也就是说,javac编译器基本上都是在实现一些前端的功能。
|
||||
|
||||
不过,由于Java的语法特性很丰富,所以即使只是前端,它的编译功能也不少。那么,除了引用消解和类型检查这两项基础工作之外,你是否知道注解是在什么时候处理的呢?泛型呢?还有各种语法糖呢?
|
||||
|
||||
所以,今天这一讲,我就带你把Java编译器的总体编译过程了解一遍。然后,我会把重点放在语义分析中的引用消解、符号表的建立和注解的处理上。当你学完以后,你就能真正理解以下这些问题了:
|
||||
|
||||
- **符号表是教科书上提到的一种数据结构,但它在Java编译器里是如何实现的?编译器如何建立符号表?**
|
||||
- **引用消解会涉及到作用域,那么作用域在Java编译器里又是怎么实现的?**
|
||||
- **在编译期是如何通过注解的方式生成新程序的?**
|
||||
|
||||
为了方便你理解Java编译器内部的一些对象结构,我画了一些类图(如果你不习惯看类图的话,可以参考下面的图表说明,比如我用方框表示一个类,用小圆圈表示一个接口,几种线条分别代表继承关系、引用关系和接口实现)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/7e/27802f28fe7ee30308bc28edcc45997e.jpg" alt="">
|
||||
|
||||
在课程开始之前,我想提醒几点:建议你在一个良好的学习环境进入今天的学习,因为你需要一步步地,仔细地跟住我的脚步,避免在探索过程中迷路;除此之外,你的手边还需要一个电脑,这样随时可以查看我在文章中提到的源代码。
|
||||
|
||||
## 了解整个编译过程
|
||||
|
||||
现在,你可以打开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讲](https://time.geekbang.org/column/article/248770)中就提到过数据流分析,那时候是用它来做代码优化。那么,难道在前端也需要做数据流分析吗?它会起到什么作用?这些问题的答案我也会在下一讲中为你揭晓。
|
||||
- **desugar()**:去除语法糖,其实这里包括了TRANSTYPES(处理泛型)、TRANSPATTERNS(处理模式匹配)、UNLAMBDA(处理Lambda)和LOWER(处理其他所有的语法糖,比如内部类、foreach循环等)四个阶段,我会在第12讲给你介绍。
|
||||
- **generate()**:生成字节码,对应了GENERATE阶段,这部分内容我也会在第12讲详细介绍。
|
||||
|
||||
在今天这一讲,我会给你介绍前两个阶段的工作:建立符号表和处理注解。
|
||||
|
||||
首先,我们来看看Enter阶段,也就是建立符号表的过程。
|
||||
|
||||
## ENTER阶段:建立符号表
|
||||
|
||||
Enter阶段的主要代码在**com.sun.tools.javac.comp.Enter类**中。在这个阶段,会把程序中的各种符号加到符号表中。
|
||||
|
||||
### 建立符号表
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我已经介绍了符号表的概念。符号表是一种数据结构,它保存了程序中所有的定义信息,也就是你定义的每个标识符,不管是变量、类型,还是方法、参数,在符号表里都有一个条目。
|
||||
|
||||
那么,我们再深入看一下,什么是符号。
|
||||
|
||||
其实,符号代表了一门语言的基础构成元素。在java.compiler模块中定义了Java语言的构成元素(Element),包括模块、包、类型、可执行元素、变量元素等。这其中的每个元素,都是一种符号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/e5/6aacb5d391a4343677c35c3c12dedee5.jpg" alt="">
|
||||
|
||||
而在jdk.compiler模块中,定义了这些元素的具体实现,也就是Symbol,符号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/d6/04e06cec9075d4c3955007daf67853d6.jpg" alt="">
|
||||
|
||||
符号里记录了一些重要的属性信息,比如名称(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是什么时候被赋值的,从而也就了解了整个调用栈,这样会比较省事。
|
||||
|
||||
延伸一句:当你调试一个大的系统时,选择好恰当的断点很重要,会让你事半功倍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/49/1843fac0c5689b4f9f0cf5fd7dbffa49.jpg" alt="">
|
||||
|
||||
最后来看一下第三行代码,这行代码是**把Symbol添加到Scope中**。
|
||||
|
||||
什么是Scope?Scope就是作用域。也就是说,在Enter过程中,作用域也被识别了出来,每个符号都是保存在相应的作用域中的。
|
||||
|
||||
在[第4讲](https://time.geekbang.org/column/article/245754),我们曾经说过,符号表可以采用与作用域同构的带层次的表格。Java编译器就是这么实现的。符号被直接保存进了它所在的词法作用域。
|
||||
|
||||
在具体实现上,Java的作用域所涉及的类比较多,我给你整理了一个类图,你可以参考一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/d1/61074a180a7dabcf1642c9c6ed8af8d1.jpg" alt="">
|
||||
|
||||
其中有几个关键的类和接口,需要给你介绍一下。
|
||||
|
||||
首先是**com.sun.tools.javac.code.Scope$ScopeImpl**类:这是真正用来存放Symbol的容器类。通过next属性来指向上一级作用域,形成嵌套的树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/d4/b188f2baa5d8f2e9c8185971921925d4.jpg" alt="">
|
||||
|
||||
但是,在处理AST时,如何找到当前的作用域呢?这就需要一个辅助类:**Env< AttrContext>**。Env的意思是环境,用来保存编译过程中的一些上下文信息,其中就有当前节点所处的作用域(Env.info.scope)。下图展示的是在编译过程中,所使用的Env的情况,这些Env也构成了一个树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/f5/014f30bb63f82e109f63a93ee7f41ef5.jpg" alt="">
|
||||
|
||||
然后是**com.sun.source.tree.Scope接口:**这是对作用域的一个抽象,可以获取当前作用域中的元素、上一级作用域、上一级方法以及上一级类。
|
||||
|
||||
好了,这就是与符号表有关的数据结构,后续的很多处理工作都要借助这个数据结构。比如,你可以思考一下,如何基于作用域来做引用消解?在下一讲,我会给你揭晓这个问题的答案。
|
||||
|
||||
### 两阶段的处理过程
|
||||
|
||||
前面讨论的是符号表的数据结构,以及建立符号表的大致过程。接下来,我们继续深究一下建立符号表算法的一个重要特点:**Enter过程是分两个阶段完成的**。
|
||||
|
||||
你可以打开Enter类,看看Enter类的头注释,里面对这两个阶段做了说明。
|
||||
|
||||
1. 第一个阶段:只是扫描所有的类(包括内部类),建立类的符号,并添加到作用域中。但是每个类定义的细节并没有确定,包括类所实现的接口、它的父类,以及所使用的类型参数。类的内部细节也没有去扫描,包括其成员变量、方法,以及方法的内部实现。
|
||||
1. 第二个阶段:确定一个类所缺失的所有细节信息,并加入到符号表中。
|
||||
|
||||
这两个阶段,第一个阶段做整个程序的扫描,把所有的类都识别出来。而第二个阶段是在需要的时候才进行处理的。
|
||||
|
||||
这里的问题是:**为什么需要两个阶段?只用一个阶段不可以吗?**
|
||||
|
||||
我借一个例子给你解释一下原因。你看看下面这段示例代码,在Enter过程中,编译器遍历了MyClass1的AST节点(JCClassDecl),并建立了一个ClassSymbol。但在遍历到它的成员变量a的时候,会发现它不认识a的类型MyClass2,因为MyClass2的声明是在后面的。
|
||||
|
||||
```
|
||||
public class MyClass1{
|
||||
MyClass2 a;
|
||||
}
|
||||
class MyClass2{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
怎么办呢?我们只好分成两个阶段去完成扫描。在第一个阶段,我们为MyClass1和MyClass2都建立符号,并且都放到符号表中;第二阶段,我们再去进一步扫描MyClass1的内部成员的时候,就能为成员变量a标注正确的类型,也就是MyClass2。
|
||||
|
||||
我在[第4讲](https://time.geekbang.org/column/article/245754)中说过,语义分析的特点是上下文相关的。通过对比,你会发现,处理上下文相关情况和上下文无关情况的算法,它们是不一样的。
|
||||
|
||||
语法解析算法处理的是上下文无关的情况,因此无论自顶向下还是自底向上,整个算法其实是线性执行的,它会不断地消化掉Token,最后产生AST。对于上下文相关的情况,算法就要复杂一些。对AST各个节点的处理,会出现相互依赖的情况,并且经常会出现环形依赖,因为两个类互相引用在Java语言里是很常见的。加上这些依赖关系以后,AST就变成了一张图。
|
||||
|
||||
而语义分析算法,实质上就是对图的遍历算法。我们知道,图的遍历算法的复杂度是比较高的。编译器一般要采用一定的**启发式(Heuristic)**的算法,人为地找出代价较低的遍历方式。Java编译器里也采用了启发式的算法,我们尽量把对图的遍历简化为对树的遍历,这样工作起来就会简单得多。
|
||||
|
||||
对AST的遍历采用了Visitor模式。下图中我列出了一些采用Visitor模式对AST进行处理的程序。Enter程序是其中的一个。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/6a/733cee5d3744f2675966c3a6c0a33f6a.jpg" alt="">
|
||||
|
||||
所以,语义分析就是由各种对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对注解的处理过程需要有一定的技巧,请参考我为你整理的[配置指南](https://github.com/RichardGong/CompilersInPractice/tree/master/javac)。
|
||||
|
||||
你会发现,在Enter之后,声明helloFoo这个成员变量的语句的vartype节点的类型是ErrorType,证明这个时候编译器是没有找到HelloFoo的定义的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/ca/9235588eb06fc348f7d049ab2472cfca.jpg" alt="">
|
||||
|
||||
不过,在编译器处理完毕注解以后,HelloFoo就会被生成,Foo类的ENTER过程还会重走一遍,这个时候相关类型信息就正确了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
好了,本讲我们首先对Java的编译过程做了一个顶层的介绍,然后分析了ENTER和PROCESS阶段所做的工作。希望你能有以下收获:
|
||||
|
||||
1. 对前端编译过程可以有更加细致的了解,特别是对语义分析阶段,会划分成多个小阶段。由于语法分析的本质是对图做处理,所以实际执行过程不是简单地逐个阶段顺序执行,而是经常交织在一起,特别是ENTER阶段和ATTR阶段经常互相交错。
|
||||
1. ENTER阶段建立了符号表这样一个重要的数据结构。我们现在知道Java的符号表是按照作用域的结构建立的,而AST的每个节点都会对应某个作用域。
|
||||
1. PROCESSOR阶段完成了对注解的处理。你可以在代码里引用将要生成的类,做完注解处理后,这些类会被生成并编译,从而使得原来的程序能够找到正确的符号,不会报编译错误。
|
||||
|
||||
在最后,为了帮你将今天的内容做一个梳理,我提供一张思维导图,供你参考,整理知识:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/ed/2d98c9a7536e598d3ee7c86322e772ed.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在Java语言中,对于下面的示例代码,会产生几个作用域,你觉得它们分别是什么?
|
||||
|
||||
```
|
||||
public class ScopeTest{
|
||||
public int foo(int a){
|
||||
if(a>0){
|
||||
//一些代码
|
||||
}
|
||||
else{
|
||||
//另一些代码
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
关于注解的官方教程,你可以参考[这个链接](https://docs.oracle.com/javase/tutorial/java/annotations/)。
|
||||
|
||||
## 扩展知识
|
||||
|
||||
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**这棵子树,就是语义分析程序插入的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/81/f64c5134193e489023d694b747216281.jpg" alt="">
|
||||
|
||||
新插入的构造方法是以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)。
|
||||
421
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/11 | Java编译器(三):属性分析和数据流分析.md
Normal file
421
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/11 | Java编译器(三):属性分析和数据流分析.md
Normal file
@@ -0,0 +1,421 @@
|
||||
<audio id="audio" title="11 | Java编译器(三):属性分析和数据流分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/d3/eac7851daf0e66333c09ddacd31bbed3.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在上一讲,我们主要讨论了语义分析中的ENTER和PROCESS阶段。今天我们继续往下探索,看看ATTR和FLOW两个阶段。
|
||||
|
||||
**ATTR的字面意思是做属性计算。**在[第4讲](https://time.geekbang.org/column/article/245754)中,我已经讲过了属性计算的概念,你应该还记得什么是S属性,什么是I属性。那么,Java编译器会计算哪些属性,又会如何计算呢?
|
||||
|
||||
**FLOW的字面意思是做数据流分析。**通过[第7讲](https://time.geekbang.org/column/article/248770),你已经初步了解了数据流分析的算法。但那个时候是把数据流分析用于编译期后端的优化算法,包括删除公共子表达式、变量传播、死代码删除等。而这里说的数据流分析,属于编译器前端的工作。那么,前端的数据流分析会做什么工作呢?
|
||||
|
||||
这些问题的答案,我今天都会为你一一揭晓。好了,我们进入正题,首先来看看ATTR阶段的工作:属性分析。
|
||||
|
||||
## ATTR:属性分析
|
||||
|
||||
现在,你可以打开**com.sun.tools.javac.comp.Attr类**的代码。在这个类的头注释里,你会发现原来ATTR做了四件事,分别在4个辅助类里实现:
|
||||
|
||||
1. Check:做类型检查。
|
||||
1. Resolve:做名称的消解,也就是对于程序中出现的变量和方法,关联到其定义。
|
||||
1. ConstFold:常量折叠,比如对于“2+3”这种在编译期就可以计算出结果的表达式,就直接计算出来。
|
||||
1. Infer:用于泛型中的类型参数推导。
|
||||
|
||||
我们首先来看Check,也就是类型检查。
|
||||
|
||||
### 类型检查
|
||||
|
||||
类型检查是语义分析阶段的一项重要工作。静态类型系统的语言,比如Java、C、Kotlin、Swift,都可以通过类型检查,避免很多编译错误。
|
||||
|
||||
那么,一个基础的问题是:**Java都有哪些类型?**
|
||||
|
||||
你是不是会觉得这个问题挺幼稚?Java的类型,不就是原始数据类型,再加上类、接口这些吗?
|
||||
|
||||
说得对,但是并不全面。你已经看到,Java编译器中每个AST节点都有一个type属性。那么,一个模块或者一个包的类型是什么?一个方法的类型又是什么呢?
|
||||
|
||||
在java.compile模块中,定义了Java的语言模型,其中有一个包,是对Java的类型体系做了设计,你可以看一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/ae/81e4126c7d121c4239ed5d96a31430ae.jpg" alt="">
|
||||
|
||||
这样你就能理解了:原来模块和包的类型是NoType,而方法的类型是可执行类型(ExecutableType)。你可以看一下源代码,会发现要刻画一个可执行类型是比较复杂的,竟然需要5个要素:
|
||||
|
||||
- returnType:返回值类型;
|
||||
- parameterTypes:参数类型的列表;
|
||||
- receiverType:接收者类型,也就是这个方法是定义在哪个类型(类、接口、枚举)上的;
|
||||
- thrownTypes:所抛出异常的类型列表;
|
||||
- typeVariables:类型参数的列表。
|
||||
|
||||
如果你学过C语言,你应该记得描述一个函数的类型只需要这个列表中的前两项,也就是返回值类型和参数类型就可以了。通过这样的对比,想必你会对Java的可执行类型理解得更清楚。
|
||||
|
||||
然而,通过一个接口体系来刻画类型还是不够细致,Java又提供了一个TypeKind的枚举类型,把某些类型做进一步的细化,比如原始数据类型进一步细分为BOOLEAN、BYTE、SHORT等。这种设计方式可以减少接口的数量,使类型体系更简洁。你也可以在编程中借鉴这种设计方式,避免产生过多的、没有什么实际意义的子类型。
|
||||
|
||||
同样,在jdk.compiler模块中,有一些具体的类实现了上述类型体系的接口:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/e2/b06b582d22336658ffa412fc7905d8e2.jpg" alt="">
|
||||
|
||||
好了,现在你已经了解了Java的类型体系。**那么,编译器是如何实现类型检查的呢?**
|
||||
|
||||
我用一个Java程序的例子,来给你做类型检查的说明。在下面这段代码中,变量a的声明语句是错误的,因为等号右边是一个字符串字面量“Hello”,类型是java.lang.String,跟变量声明语句的类型“int”不相符。在做类型检查的时候,编译器应该检查出这个错误来。
|
||||
|
||||
而后面那句“`float b = 10`”,虽然变量b是float型的,而等号右边是一个整型的字面量,但Java能够自动把整型字面量转化为浮点型,所以这个语句是合法的。
|
||||
|
||||
```
|
||||
public class TypeCheck{
|
||||
int a = "Hello"; //等号两边的类型不兼容,编译报错
|
||||
float b = 10; //整型字面量可以赋值给浮点型变量
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于“`int a = "hello"`”这个语句,它的类型检查过程分了四步,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/30/52a37499a6503c390c832b569b355830.jpg" alt="">
|
||||
|
||||
**第1步,计算vartype子节点的类型。**这一步是在把a加入符号表的时候(MemberEnter)就顺便一起做了(调用的是“Attr.attribType()方法”)。计算结果是int型。
|
||||
|
||||
**第2步,在ATTR阶段正式启动以后,深度优先地遍历整棵AST,自底向上计算每个节点的类型。**自底向上是S属性的计算方式。你可以看一下Attr类中的各种attribXXX()方法,大多数都是要返回一个类型值,也就是处理完当前子树后的类型。这个时候,能够知道init部分的类型是字符串型(java.lang.String)。
|
||||
|
||||
**第3步,检查init部分的类型是否正确**。这个时候,比对的就是vartype和init这两棵子树的类型。具体实现是在Check类的**checkType()**方法,这个方法要用到下面这两个参数。
|
||||
|
||||
- **final Type found**:“发现”的类型,也就是“Hello”字面量的类型,这里的值是java.lang.String。这个是自底向上计算出来的,属于S属性。
|
||||
- **final Type req**:“需要”的类型,这里的值是int。也就是说,a这个变量需要初始化部分的类型是int型的。这个变量是自顶向下传递下来的,属于I属性。
|
||||
|
||||
所以你能看出,所谓的类型检查,就是所需类型(I属性)和实际类型(S属性)的比对。
|
||||
|
||||
这个时候,你就会发现类型不匹配,从而记录下错误信息。
|
||||
|
||||
下面是在做类型检查时整个的调用栈:
|
||||
|
||||
```
|
||||
JavaCompiler.compile()
|
||||
->JavaCompiler.attribute()
|
||||
->Attr.attib()
|
||||
->Attr.attribClass() //计算TypeCheck的属性
|
||||
->Attr.attribClassBody()
|
||||
->Attr.attribStat() //int a = "Hello";
|
||||
->Attr.attribTree() //遍历声明成员变量a的AST
|
||||
->Attr.visitVarDef() //访问变量声明节点
|
||||
->Attr.attribExpr(TCTree,Env,Type)//计算"Hello"的属性,并传入vartype的类型
|
||||
->Attr.attribTree() //遍历"Hello"AST,所需类型信息在ResultInfo中
|
||||
->Attr.visitLiteral() //访问字面量节点,所需类型信息在resultInfo中
|
||||
->Attr.check() //把节点的类型跟原型类型(需要的类型)做比对
|
||||
->Check.checkType() //检查跟预期的类型是否一致
|
||||
|
||||
```
|
||||
|
||||
**第4步:继续自底向上计算类型属性。**这个时候会把变量声明语句JCVariableDecl的类型设置为vartype的类型。
|
||||
|
||||
上面是对变量a的声明语句的检查过程。对于“`float b = 10`”的检查过程也类似,但整型是允许赋值给浮点型的,所以编译器不会报错。
|
||||
|
||||
说完了类型检查,我们继续看一下Resolve,也就是引用的消解。
|
||||
|
||||
### 引用消解
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我就介绍过了引用消解的概念。给你举个例子,当我们在程序中用到一个变量的时候,必须知道它确切的定义在哪里。比如下面代码中,第4行和第6行都用到了一个变量a,但它们指的不是同一个变量。**第4行的a是类的成员变量,第6行的a是foo()函数中的本地变量。**
|
||||
|
||||
```
|
||||
public class RefResolve extends RefResolveParent {
|
||||
int a = 2;
|
||||
void foo(int d){
|
||||
int b = a + f; //这里的a是RefResolve的成员变量
|
||||
int a = 3; //本地变量a,覆盖了类的成员变量a
|
||||
int c = a + 10; //这里的a是前一句中声明的本地变量
|
||||
}
|
||||
}
|
||||
|
||||
class RefResolveParent{
|
||||
int f = 4; //父类中的成员变量
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在编译器中,这两行中的a变量,都对应一个标识符(JCIdent)节点,也都会关联一个Symbol对象。但这两个Symbol对象不是同一个。第4行的a指的是类的成员变量,而第6行的a指的是本地变量。
|
||||
|
||||
**所以,具体到Java编译器,引用消解实际上就是把标识符的AST节点关联到正确的Symbol的过程。**
|
||||
|
||||
引用消解不仅仅针对变量,还针对类型、包名称等各种用到标识符的地方。如果你写了“System.out.println()”这样一个语句,就要引用正确的包符号。
|
||||
|
||||
你可以打开com.sun.tools.javac.comp.Resolve类的**findIdentInternal方法**,能看到对几种不同的符号做引用消解的入口。
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
if (kind.contains(KindSelector.VAL)) { //变量消解
|
||||
sym = findVar(env, name);
|
||||
...
|
||||
}
|
||||
|
||||
if (kind.contains(KindSelector.TYP)) { //类型消解
|
||||
sym = findType(env, name);
|
||||
...
|
||||
}
|
||||
|
||||
if (kind.contains(KindSelector.PCK)) //包名称消解
|
||||
return lookupPackage(env, name);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
引用消解的实现思路也很清晰。在上一讲,你知道编译器在Enter阶段已经建立了作用域的嵌套结构。那么在这里,**编译器只需要沿着这个嵌套结构逐级查找就行了**。
|
||||
|
||||
比如,对于“`int b = a + f`”这个变量声明语句,在查找变量a时,沿着Scope的嵌套关系往上查找两级就行。但对于变量f,还需要沿着类的继承关系,在符号表里找到父类(或接口),从中查找有没有名称为f的成员变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/f3/d875ececc422f5e102f73cd5c14a37f3.jpg" alt="">
|
||||
|
||||
不过,这里还有一个细节需要深究一下。还记得我在前一讲留了一个问题吗?这个问题是:**对于方法体中的本地变量,不是在ENTER阶段创建符号,而是在ATTR阶段。**具体来说,就是在ATTR的Resolve环节。这是为什么呢?为什么不在ENTER环节把所有的符号都识别出来,并且加到作用域中就行了?
|
||||
|
||||
我来解答一下这个问题。我们把RefResolve类中的“`int a = 2;`”这行注释掉,会发生什么事情呢?foo()函数的第一行“`int b = a + f`”应该报错,因为找不到a的定义。
|
||||
|
||||
```
|
||||
public class RefResolve extends RefResolveParent{
|
||||
//int a = 2; //把这行注释掉
|
||||
void foo(int d){
|
||||
int b = a + f; //这里找不到a,应该报错
|
||||
int a = 3; //本地变量a,覆盖了类的成员变量a
|
||||
int c = a + 10; //这里的a是前一句中声明的本地变量
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,如果编译器在ENTER阶段就把所有的符号建立起来了,**那么会发生什么情况呢**?foo()的方法体所对应的Scope就会有一个符号a。按照前面描述的逐级查找算法,它就会认为“`int b = a + f`”里的这个a,就是本地变量a。这当然是错误的。
|
||||
|
||||
所以,为了保证消解算法不出错,必须保证在做完“`int b = a + f`”这句的引用消解之后,才会启动下一句“`int a = 3`”的ENTER过程,把符号a添加的foo()方法体的作用域中。引用消解都处理完毕以后,符号表才会填充完整,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/cc/1fc9eae0c6941834d276c4b071ea04cc.jpg" alt="">
|
||||
|
||||
### 常数折叠
|
||||
|
||||
在ATTR阶段,还会做一项优化工作:Constant Fold,即常数折叠。
|
||||
|
||||
我们知道,优化工作通常是在编译器的后端去做的。但因为javac编译器只是个前端编译器,生成字节码就完成任务了。不过即使如此,也要保证字节码是比较优化的,减少解释执行的消耗。
|
||||
|
||||
因为常数折叠借助属性计算就可以实现,所以在ATTR阶段顺便就把这个优化做了。
|
||||
|
||||
**Java在什么情况下会做常数折叠呢?**我们来看看下面这个例子。变量a和b分别是一个整型和字符串型的常数。这样的话,“`c=b+a*3`”中c的值,是可以在编译期就计算出来的。这要做两次常数折叠的计算,最后生成一个“`Hello 6`”的字符串常数。
|
||||
|
||||
```
|
||||
public class ConstFold {
|
||||
public String foo(){
|
||||
final int a = 2; //int类型的常数
|
||||
final String b = "Hello "; //String类型的常数
|
||||
String c = b + a * 3; //发生两次折叠
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
触发上述常数折叠的代码,在com.sun.tools.javac.comp.Attr类的**visitBinary()方法**中,具体实现是在com.sun.tools.javac.comp.ConstFold类。它的计算逻辑是:针对每个AST节点的type,可以通过Type.constValue()方法,看看它是否有常数值。如果二元表达式的两个子节点都有常数值,那么就可以做常数折叠,计算出的结果保存在父节点的type属性中。你可以看看下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0y/d7/0yyd23af4a5fff193bb37c4ecd9753d7.jpg" alt="">
|
||||
|
||||
扩展:你看了这个图,可能会有一个疑问:常数值为什么不是保存在AST节点中,而是保存在类型对象中?类型带上一个值是什么意思?常数值为2的整型和常数值为3的整型是不是一个类型?<br>
|
||||
这是因为Type里保存的信息本来就比较杂。我们前面分析过,一个可执行类型(比如方法)里包含返回值、参数类型等各种信息。一个类型的元数据信息(通常指标注),也是存在Type里面的。所以,一个方法的类型信息,跟另一个方法的类型信息,是迥然不同的。在这里,不要把Type叫做“类型”,而是叫“类型信息”比较好。每个类型信息对象只针对某个AST节点,包含了该节点与类型有关的各种信息。因此,在这里面再多放一个常数值,也就无所谓了。
|
||||
|
||||
你能看出,常数折叠实质上是针对AST节点的常数值属性来做属性计算的。
|
||||
|
||||
### 推导类型参数
|
||||
|
||||
ATTR阶段做的最后一项工作,也是跟类型相关,那就是对泛型中的类型参数做推导。
|
||||
|
||||
这是什么意思呢?在Java语言中,如果你前面声明了一个参数化类型的变量,那么在后面的初始化部分,你不带这个参数化类型也是可以的,编译器会自动推断出来。
|
||||
|
||||
比如下面这句:
|
||||
|
||||
```
|
||||
List<String> lines = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
你可以去掉初始化部分中的类型参数,只保留一对尖括号就行了:
|
||||
|
||||
```
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
```
|
||||
|
||||
甚至更复杂的参数化类型,我们也可以这样简化:
|
||||
|
||||
```
|
||||
Map<String, List<String>> myMap = new HashMap<String, List<String>>();
|
||||
//简化为:
|
||||
Map<String, List<String>> myMap = new HashMap<>();
|
||||
|
||||
```
|
||||
|
||||
你可以在Infer.instantiateMethod()方法中打个断点,观察一下泛型的推断。关于泛型这个主题,我会在“揭秘泛型编程的实现机制”这一讲,去展开讲一些关于类型计算的算法,这里就不详细展开了。
|
||||
|
||||
好了,到这里,你已经知道了属性分析所做的四项工作,它们分别针对了四个属性:
|
||||
|
||||
- 类型检查针对的是类型属性;
|
||||
- 引用消解是针对标识符节点的符号(sym)属性,也就是要找到正确的符号定义;
|
||||
- 常数折叠针对的是常数值属性;
|
||||
- 类型参数的推导,针对的是类型参数属性。
|
||||
|
||||
所以,现在你就可以解答出学教科书时通常会遇到的一个疑问:属性计算到底是计算了哪些属性。我们用实战知识印证了理论 。
|
||||
|
||||
接下来我们看看编译器下一个阶段的工作:数据流分析。
|
||||
|
||||
## FLOW:数据流分析
|
||||
|
||||
Java编译器在FLOW阶段做了四种数据流分析:活跃性分析、异常分析、赋值分析和本地变量捕获分析。我以其中的活跃性分析方法为例,来给你做讲解,这样其他的几个分析方法,你就可以举一反三了。
|
||||
|
||||
**首先,我们来看看活跃性分析方法对return语句的检测。**
|
||||
|
||||
举个最简单的例子。下面这段代码里,foo函数的返回值是int,而函数体中,只有在if条件中存在一个return语句。这样,代码在IDE中就会报编译错误,提示缺少return语句。
|
||||
|
||||
```
|
||||
public class NoReturn{
|
||||
public int foo(int a){ //在a<=0的情况下,不会执行return语句
|
||||
if (a> 0){
|
||||
return a;
|
||||
}
|
||||
/*
|
||||
else{
|
||||
return -a;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
想要检查是否缺少return语句,我们就要进行活跃性分析。活跃性分析的具体实现是在Flow的一个内部类LiveAnalyzer中。
|
||||
|
||||
在分析过程中,编译器用了一个**alive变量**来代表代码是否会执行到当前位置。打开**Flow$LiveAnalyzer类**,你会看到**visitMethodDef**中的部分代码,如下所示。如果方法体里有正确的return语句,那么扫描完方法体以后,alive的取值是“DEAD”,也就是这之后不会再有可执行的代码了;否则就是“ALIVE”,这意味着AST中并不是所有的分支,都会以return结束。
|
||||
|
||||
```
|
||||
public void visitMethodDef(JCMethodDecl tree) {
|
||||
...
|
||||
alive = Liveness.ALIVE; //设置为ALIVE
|
||||
scanStat(tree.body); //扫描所有的语句
|
||||
|
||||
//如果仍然是ALIVE,但返回值不是void,那么说明缺少Return语句
|
||||
if (alive == Liveness.ALIVE && !tree.sym.type.getReturnType().hasTag(VOID))
|
||||
log.error(TreeInfo.diagEndPos(tree.body), Errors.MissingRetStmt);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以看到下面的代码示例中,当递归下降地扫描到if语句的时候,只有同时存在**then**的部分和**else**的部分,并且两个分支的活跃性检查的结果都是“DEAD”,也就是两个分支都以return语句结束的时候,if节点执行后alive就会变成“DEAD”,也就是后边的语句不会再被执行。除此之外,都是“ALIVE”,也就是if后边的语句有可能被执行。
|
||||
|
||||
```
|
||||
public void visitIf(JCIf tree) {
|
||||
scan(tree.cond); //扫描if语句的条件部分
|
||||
//扫描then部分。如果这里面有return语句,alive会变成DEAD
|
||||
scanStat(tree.thenpart);
|
||||
if (tree.elsepart != null) {
|
||||
Liveness aliveAfterThen = alive;
|
||||
alive = Liveness.ALIVE;
|
||||
scanStat(tree.elsepart);
|
||||
//只有then和else部分都有return语句,alive才会变成DEAD
|
||||
alive = alive.or(aliveAfterThen);
|
||||
} else { //如果没有else部分,那么把alive重新置为ALIVE
|
||||
alive = Liveness.ALIVE;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看代码还是比较抽象。我把数据流分析的逻辑用控制流图的方式表示出来,你看着会更直观。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/2c/300d7c8abb0d9bbb97e08eab49d9712c.jpg" alt="">
|
||||
|
||||
我们通过活跃性分析,可以学习到数据流分析框架的5个要素:
|
||||
|
||||
1. **V**:代表被分析的值,这里是alive,代表了控制流是否会到达这里。
|
||||
1. **I**:是V的初始值,这里的初始值是LIVE;
|
||||
1. **D**:指分析方向。这个例子里,是从上到下扫描基本块中的代码;而有些分析是从下往上的。
|
||||
1. **F**:指转换函数,也就是遇到每个语句的时候,V如何变化。这里是在遇到return语句的时候,把alive变为DEAD。
|
||||
1. **Λ**:meet运算,也就是当控制流相交的时候,从多个值中计算出一个值。你看看下图,在没有else块的时候,两条控制流中alive的值是不同的,最后的取值是LIVE。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/f5/486f781695d928dab6acf76bcf5c1bf5.jpg" alt="">
|
||||
|
||||
在做meet运算的时候,会用到一个叫做半格的数学工具。你可以参考本讲末尾的链接。
|
||||
|
||||
好了,我借助活跃性分析给你简要地讲解了数据流分析框架,我们接着往下看。
|
||||
|
||||
**再进一步,活跃性分析还可以检测不可到达的语句**。
|
||||
|
||||
如果我们在return语句后面再加一些代码,那么这个时候,alive已经变成“DEAD”,编译器就会报“语句不可达”的错误。
|
||||
|
||||
Java编译器还能检测更复杂的语句不可达的情况。比如在下面的例子中,a和b是两个final类型的本地变量,final修饰词意味着这两个变量的值已经不会再改变。
|
||||
|
||||
```
|
||||
public class Unreachable{
|
||||
public void foo(){
|
||||
final int a=1;
|
||||
final int b=2;
|
||||
while(a>b){ //a>b的值可以在编译期计算出来
|
||||
System.out.println("Inside while block");
|
||||
}
|
||||
System.out.println("Outside while block");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种情况下,在扫描 **while语句**的时候,条件表达式“`a>b`”会被计算出来,是false,这意味着while块内部的代码不会被执行。注意,在第7讲的优化算法中,这种优化叫做**稀疏有条件的常数折叠**。因为这里是用于编译器前端,所以只是报了编译错误。如果是在中后端做这种优化,就会直接把不可达的代码删除。
|
||||
|
||||
```
|
||||
//Flow$AliveAnalyzer
|
||||
public void visitWhileLoop(JCWhileLoop tree) {
|
||||
ListBuffer<PendingExit> prevPendingExits = pendingExits;
|
||||
pendingExits = new ListBuffer<>();
|
||||
scan(tree.cond); //扫描条件
|
||||
alive = Liveness.from(!tree.cond.type.isFalse()); //如果条件值为false,那么alive为DEAD
|
||||
scanStat(tree.body); //扫描while循环体
|
||||
alive = alive.or(resolveContinues(tree));
|
||||
alive = resolveBreaks(tree, prevPendingExits).or(
|
||||
!tree.cond.type.isTrue());
|
||||
}
|
||||
|
||||
void scanStat(JCTree tree) { //扫描语句
|
||||
//如果在扫描语句的时候,alive是DEAD,那么该语句就不可到达了
|
||||
if (alive == Liveness.DEAD && tree != null) {
|
||||
log.error(tree.pos(), Errors.UnreachableStmt);
|
||||
if (!tree.hasTag(SKIP)) alive = Liveness.RECOVERY;
|
||||
}
|
||||
scan(tree);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
还有一种代码不可达的情况,就是无限循环后面的代码。你可以思考一下,在上面的例子中,**如果把while条件的“a>b”改成“a<b”,会发生什么情况呢?**
|
||||
|
||||
编译器会扫描while里面有没有合适的break语句(通过**resolveBreaks()方法**)。如果找不到,就意味着这个循环永远不会结束,那么循环体后面的语句就永远不会到达,从而导致编译器报错。
|
||||
|
||||
除了活跃性分析,Flow阶段还做了其他三项分析:**异常分析、赋值分析和本地变量捕获分析。**
|
||||
|
||||
为了方便你的学习,我把Java编译器用到的几个数据流分析方法整理了一下,放在下面的表格中:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/91/762b26278939d25b7354fbcbfdc39891.jpg" alt="">
|
||||
|
||||
这几种分析方法,我建议你可以做几个例子,跟踪代码并研究一下,会加深你对数据流分析的直观理解。
|
||||
|
||||
异常分析、赋值分析和本地变量捕获的思路,与活跃性分析类似,它们都是按照数据流分析框架来处理的。也就是说,对于每个分析方法,你都要注意识别出它的五大要素:值、初始值、转换规则、扫描方向,以及meet运算规则。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们研究了Java编译过程中的属性分析和数据流分析两个阶段。
|
||||
|
||||
**在属性分析阶段**,你能够看到Java是如何做类型检查、引用消解、常量折叠和推导类型参数的,它们实际上是对类型(type)、符号(sym)、常量值(constValue)和类型参数这4类属性的处理工作。
|
||||
|
||||
我们也见识到了在编译器前端的**数据流分析阶段**,是如何使用数据流分析方法的。通过数据流分析,编译器能够做一些更加深入的语义检查,比如检查控制流是否都经过了return语句,以及是否有不可到达的代码、每个异常是否都被处理,变量在使用前是否肯定被赋值,等等。
|
||||
|
||||
总体来说,在ATTR和FLOW这两个阶段,编译器完成了主要的语义检查工作。如果你在设计一门语言的时候,遇到了如何做语义检查的问题,那你就可以参考一下这一讲的内容。
|
||||
|
||||
在最后,是本节课程知识点的思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/8f/fcc07e719bdef1a3c5d7b7fef11b0c8f.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
数据流分析框架很重要,你可以借助实例对它熟悉起来。那么,你能针对赋值分析,把它的5个元素列出来吗?欢迎在留言区分享你的思考,我会在下一讲的留言区,通过置顶的方式公布标准答案。
|
||||
|
||||
如果你觉得有收获,欢迎你把今天的内容分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 关于数据流分析的理论性内容,可以参考龙书(Compilers Principles, Techniques and Tools)第二版的9.2和9.3节。你也可以参考《编译原理之美》 的第[27](https://time.geekbang.org/column/article/155338)、[28](https://time.geekbang.org/column/article/156878)讲,那里进行了比较直观的讲述。
|
||||
1. 关于半格这个数学工具,你可以参考龙书第二版的9.3.1部分,也同样可以参考《编译原理之美》的[第28讲](https://time.geekbang.org/column/article/156878)。
|
||||
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/12 | Java编译器(四):去除语法糖和生成字节码.md
Normal file
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/12 | Java编译器(四):去除语法糖和生成字节码.md
Normal file
@@ -0,0 +1,298 @@
|
||||
<audio id="audio" title="12 | Java编译器(四):去除语法糖和生成字节码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/13/68de7726b468e4yy962e90f89ac04413.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天是Java编译器的最后一讲,我们来探讨编译过程最后的两个步骤:**去除语法糖和生成字节码**。
|
||||
|
||||
其实今天要讲的这两个编译步骤,总体上都是为生成字节码服务的。在这一阶段,编译器首先会把语法糖对应的AST,转换成更基础的语法对应的AST,然后基于AST和符号表,来生成字节码。
|
||||
|
||||
从AST和符号表,到变成字节码,这可是一个很大的转变,就像把源代码转化成AST一样。**那么,这个过程的实现思路是什么?有什么难点呢?**
|
||||
|
||||
今天这一讲,我们就一起来解决以上这些问题,在这个过程中,你对Java编译器的认识会变得更加完整。
|
||||
|
||||
好了,我们首先来看看去除语法糖这一处理步骤。
|
||||
|
||||
## 去除语法糖(Syntactic Sugar)
|
||||
|
||||
Java里面提供了很多的语法糖,比如泛型、Lambda、自动装箱、自动拆箱、foreach循环、变长参数、内部类、枚举类、断言(assert),等等。
|
||||
|
||||
你可以这么理解语法糖:它就是提高我们编程便利性的一些语法设计。既然是提高便利性,那就意味着语法糖能做到的事情,用基础语法也能做到,只不过基础语法可能更啰嗦一点儿而已。
|
||||
|
||||
不过,我们最终还是要把语法糖还原成基础语法结构。比如,foreach循环会被还原成更加基础的for循环。那么,问题来了,**在编译过程中,究竟是如何去除语法糖的?基础语法和语法糖又有什么区别呢?**
|
||||
|
||||
在[第10讲](https://time.geekbang.org/column/article/252828)中,我提到过,在JDK14中,去除语法糖涵盖了编译过程的四个小阶段。
|
||||
|
||||
- **TRANSTYPES**:泛型处理,具体实现在TransTypes类中。
|
||||
- **TRANSPATTERNS**:处理模式匹配,具体实现在TransPattern类中。
|
||||
- **UNLAMBDA**:把LAMBDA转换成普通方法,具体实现在LambdaToMethod类中。
|
||||
- **LOWER**:其他所有的语法糖处理,如内部类、foreach循环、断言等,具体实现在Lower类中。
|
||||
|
||||
以上去除语法糖的处理逻辑都是相似的,它们的**本质都是对AST做修改和变换**。所以,接下来我挑选了两个比较有代表性的语法糖,泛型和foreach循环,和你分析它们的处理过程。
|
||||
|
||||
**首先是对泛型的处理。**
|
||||
|
||||
Java泛型的实现比较简单,`LinkedList<String>`和`LinkedList`对应的字节码其实是一样的。泛型信息`<String>`,只是用来在语义分析阶段做类型的检查。检查完之后,这些类型信息就会被去掉。
|
||||
|
||||
所以,Java的泛型处理,就是把AST中与泛型有关的节点简单地删掉(相关的代码在TransTypes类中)。
|
||||
|
||||
对于“ `List<String> names = new ArrayList<String>()` ”这条语句,它对应的AST的变化过程如下,其中,橙色的节点就是被去掉的泛型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/6d/06a1e54b561b09dce936834ec311816d.jpg" alt="">
|
||||
|
||||
**然后,我们分析下对foreach循环的处理。**
|
||||
|
||||
foreach循环的意思是“遍历每一个成员”,它能够以更简洁的方式,遍历集合和数组等数据结构。在下面的示例代码中,foreach循环和基础for循环这两种处理方式的结果是等价的,但你可以看到,foreach循环会更加简洁。
|
||||
|
||||
```
|
||||
public static void main(String args[]) {
|
||||
List<String> names = new ArrayList<String>();
|
||||
...
|
||||
//foreach循环
|
||||
for (String name:names)
|
||||
System.out.println(name);
|
||||
|
||||
|
||||
//基础for循环
|
||||
for ( Iterator i = names.iterator(); i.hasNext(); ) {
|
||||
String name = (String)i.next();
|
||||
System.out.println(name);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Java编译器把foreach循环叫做**增强for循环**,对应的AST节点是**JCEnhancedForLoop**。
|
||||
|
||||
针对上面的示例代码,我们来对比一下增强for循环的AST和去除语法糖之后的AST,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/90/3ee6304103f7e41e3fc32c668f1e3490.jpg" alt="">
|
||||
|
||||
你可以通过**反编译**,来获得这些没有语法糖的代码,它跟示例代码中用到的**基础for循环语句**是一样的。
|
||||
|
||||
对foreach循环的处理,是在**Lower类的visitForeachLoop方法**中。
|
||||
|
||||
其实,你在阅读编译技术相关的文献时,应该经常会看到Lower这个词。它的意思是,让代码从对人更友好的状态,变换到对机器更友好的状态。比如说,语法糖对编程人员更友好,而基础的语句则相对更加靠近机器实现的一端,所以去除语法糖的过程是Lower。除了去除语法糖,**凡是把代码向着机器代码方向所做的变换,都可以叫做Lower。**以后你再见到Lower的时候,是不是就非常清楚它的意思了呢。
|
||||
|
||||
好了,通过对泛型和foreach循环的处理方式的探讨,现在你应该已经大致了解了去除语法糖的过程。总体来说,去除语法糖就是把AST做一些变换,让它变成更基础的语法要素,从而离生成字节码靠近了一步。
|
||||
|
||||
那么接下来,我们看看编译过程的最后一个环节:生成字节码。
|
||||
|
||||
## 生成字节码(Bytecode Generation)
|
||||
|
||||
一般来说,我们会有一个错觉,认为生成字节码比较难。
|
||||
|
||||
实际情况并非如此,因为通过前面的建立符号表、属性计算、数据流分析、去除语法糖的过程,我们已经得到了一棵标注了各种属性的AST,以及保存了各种符号信息的符号表。最难的编译处理工作,在这几个阶段都已经完成了。
|
||||
|
||||
在[第8讲](https://time.geekbang.org/column/article/249261)中,我就介绍过目标代码的生成。其中比较难的工作,是指令选择、寄存器分配和指令排序。而这些难点工作,在生成字节码的过程中,基本上是不存在的。在少量情况下,编译器可能会需要做一点指令选择的工作,但也都非常简单,你在后面可以看到。
|
||||
|
||||
我们通过一个例子,来看看生成字节码的过程:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int foo(int a){
|
||||
return a + 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个例子中,foo函数对应的字节码有四个指令:
|
||||
|
||||
```
|
||||
public int foo(int);
|
||||
Code:
|
||||
0: iload_1 //把下标为1的本地变量(也就是参数a)入栈
|
||||
1: iconst_3 //把常数3入栈
|
||||
2: iadd //执行加法操作
|
||||
3: ireturn //返回
|
||||
|
||||
```
|
||||
|
||||
生成字节码,基本上就是对AST做深度优先的遍历,逻辑特别简单。我们在[第5讲](https://time.geekbang.org/column/article/246281)曾经介绍过栈机的运行原理,也提到过栈机的一个优点,就是生成目标代码的算法比较简单。
|
||||
|
||||
你可以看一下我画的示意图,里面有生成字节码的步骤:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/21/05461yy0f34c9db1d35b83yyf4049721.jpg" alt="">
|
||||
|
||||
- 第1步,把a的值入栈(iload_1)。
|
||||
- 第2步,把字面量3入栈(iconst_3)。
|
||||
- 第3步,生成加法运算指令(iadd)。这个操作会把前两个操作数出栈,把结果入栈。
|
||||
- 第4步,生成return指令(ireturn)。
|
||||
|
||||
这里面有没有指令选择问题?有的,但是很简单。
|
||||
|
||||
首先,你注意一下iconst_3指令,这是把一个比较短的操作数压缩到了指令里面,这样就只需要生成一个字节码。如果你把3改成一个稍微大一点的数字,比如7,那么它所生成的指令就要改成“bipush 7”,这样就需要生成两个字节的字节码,一个字节是指令,一个字节是操作数。但这个操作数不能超过“2^7-1”,也就是127,因为一个字节只能表示-128~127之间的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/f2/9b88397b574e8f37d415ab1byycbecf2.jpg" alt="">
|
||||
|
||||
如果字面量值变成128,那指令就要变成“sipush 128”,占据三个字节,表示往栈里压入一个short数据,其中操作数占据两个字节。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/26/cd9dc78269b769f56ff236e9b2e3b426.jpg" alt="">
|
||||
|
||||
如果该常数超过了两个字节能表示的范围,比如“32768”,那就要改成另一个指令“ldc #2”,这是把常数放到常量池里,然后从常量池里加载。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/76/53ebd4d4be1fdcc0bdddf847d0463a76.jpg" alt="">
|
||||
|
||||
这几个例子反映了**由于字面量的长度不同,而选用了不同的指令**。接着,我们再来看看**数据类型对指令的影响。**
|
||||
|
||||
前面例子中生成的这四个指令,全部都是针对整数做运算的。这是因为我们已经在语义分析阶段,计算出了各个AST节点的类型,它们都是整型。但如果是针对长整型或浮点型的计算,那么生成的字节码就会不一样。下面是针对单精度浮点型所生成的字节码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/41/e9c61861bc83c3e58390741649fa6b41.jpg" alt="">
|
||||
|
||||
**第三,数据类型影响指令生成的另一个情况,是类型转换。**
|
||||
|
||||
**一方面,阅读字节码的规范,你会发现对byte、short、int这几种类型做运算的时候,使用的指令其实是一样的,都是以i开头的指令。**比如,加载到栈机都是用iload指令,加法都是用iadd指令。
|
||||
|
||||
在示例代码中,我们把foo函数的参数a的类型改成byte,生成的字节码与之前也完全一样,你可以自己去试一下。
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int foo(byte a){
|
||||
return a + 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**另一方面,在Java里把整型和浮点型做混合运算的时候,编译器会自动把整型转化成浮点型。**比如我们再把示例代码改成下面这样:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public double foo(int a){
|
||||
return a + 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个时候,foo函数对应的字节码如下,其中 **i2d指令**就是把参数a从int型转换成double型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/64/6cfd01bdfea327ba064fbb71b2f93464.jpg" alt="">
|
||||
|
||||
OK,到这里,我已经总结了影响指令生成的一些因素,包括字面量的长度、数据类型等。你能体会到,这些指令选择的逻辑都是很简单的,基于当前AST节点的属性,编译器就可以做成正确的翻译了,所以它们基本上属于“直译”。而我们在[第8讲](https://time.geekbang.org/column/article/249261)中介绍指令选择算法的时候,遇到的问题通常是结合了多个AST节点生成一条指令,它的难度要高得多。所以在第16讲,讲解Java的JIT编译器生成目标代码的时候,我会带你去看看这种复杂的指令选择算法的实现方式。
|
||||
|
||||
现在你对生成字节码的基本原理搞清楚了以后,再来看Java编译器的具体实现,就容易多了。
|
||||
|
||||
生成字节码的程序入口在**com.sun.tools.javac.jvm.Gen类**中。这个类也是AST的一个visitor。这个visitor把AST深度遍历一遍,字节码就生成完毕了。
|
||||
|
||||
**在com.sun.tools.javac.jvm包中,有两个重要的辅助类。**
|
||||
|
||||
**第一个辅助类是Item。**包的内部定义了很多不同的Item,代表了在字节码中可以操作的各种实体,比如本地变量(LocalItem)、字面量(ImmediateItem)、静态变量(StaticItem)、带索引的变量(IndexedItem,比如数组)、对象实例的变量和方法(MemberItem)、栈上的数据(StackItem)、赋值表达式(AssignItem),等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/12/afda81ece1d912be6effd6570fa09f12.jpg" alt="">
|
||||
|
||||
每种Item都支持一套标准的操作,能够帮助生成字节码。我们最常用的是load()、store()、invoke()、coerce()这四个。
|
||||
|
||||
- **load():生成把这个Item加载到栈上的字节码。**
|
||||
|
||||
我们刚才已经见到了两种Item的load操作,一个是本地变量a的(LocalItem),一个是立即数3的(ImmediateItem。在字节码和汇编代码里,如果一个指令的操作数是一个常数,就叫做立即数)。
|
||||
|
||||
你可以看一下ImmediateItem的load()方法,里面准确反映了我们前面分析的指令选择逻辑:根据字面量长度的不同,分别选择iconst_X、bipush、sipush和ldc指令。
|
||||
|
||||
```
|
||||
Item load() {
|
||||
switch (typecode) {
|
||||
//对int、byte、short、char集中类型来说,生成的load指令是相同的。
|
||||
case INTcode: case BYTEcode: case SHORTcode: case CHARcode:
|
||||
int ival = numericValue().intValue();
|
||||
if (-1 <= ival && ival <= 5)
|
||||
code.emitop0(iconst_0 + ival); //iconst_X指令
|
||||
else if (Byte.MIN_VALUE <= ival && ival <= Byte.MAX_VALUE)
|
||||
code.emitop1(bipush, ival); //bipush指令
|
||||
else if (Short.MIN_VALUE <= ival && ival <= Short.MAX_VALUE)
|
||||
code.emitop2(sipush, ival); //sipush指令
|
||||
else
|
||||
ldc(); //ldc指令
|
||||
break;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
load()方法的返回值,是一个StackItem,代表加载到栈上的数据。
|
||||
|
||||
- **store():生成从栈顶保存到该Item的字节码。**
|
||||
|
||||
比如LocalItem的store()方法,能够把栈顶数据保存到本地变量。而MemberItem的store()方法,则会把栈顶数据保存到对象的成员变量中。
|
||||
|
||||
<li>
|
||||
**invoke() : 生成调用该Item代表的方法的字节码。**
|
||||
</li>
|
||||
<li>
|
||||
**coerce():强制类型转换。**
|
||||
</li>
|
||||
|
||||
我们之前讨论的类型转换功能,就是在coerce()方法里完成的。
|
||||
|
||||
**第二个辅助类是Code类。<strong>它里面有各种**emitXXX()方法,会生成各种字节码的指令。</strong>
|
||||
|
||||
总结起来,字节码生成的总体框架如下面的类图所示:
|
||||
|
||||
- Gen类以visitor模式访问AST,生成字节码;最后生成的字节码保存在Symbol的code属性中。
|
||||
- 在生成字节码的过程中,编译器会针对不同的AST节点,生成不同的Item,并调用Item的load()、store()、invoke()等方法,这些方法会进一步调用Code对象的emitXXX()方法,生成实际的字节码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/d8/52d4abb0eb9654c22024378ba91cb5d8.jpg" alt="">
|
||||
|
||||
好了,这就是生成字节码的过程,你会发现它的思路是很清楚的。你可以写一些不同的测试代码,观察它生成的字节码,以及跟踪生成字节码的过程,很快你就能对各种字节码是如何生成的了然于胸了。
|
||||
|
||||
## 代码优化
|
||||
|
||||
到这里,我们把去除语法糖和生成字节码两部分的内容都讲完了。但是,在Java编译器里,还有一类工作是分散在编译的各个阶段当中的,它们也很重要,这就是代码优化的工作。
|
||||
|
||||
总的来说,Java编译器不像后端编译器那样会做深度的优化。比如像下面的示例代码,“`int b = a + 3`”这行是无用的代码,用一个“死代码删除”的优化算法就可以去除掉。而在Java编译器里,这行代码照样会被翻译成字节码,做一些无用的计算。
|
||||
|
||||
```
|
||||
int foo(){
|
||||
int a = 2;
|
||||
int b = a + 3; //这行是死代码,可以优化掉
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,Java编译器还是在编译过程中,顺便做了一些优化:
|
||||
|
||||
**1.ATTR阶段:常数折叠**
|
||||
|
||||
在属性分析阶段做了常数折叠优化。这样,在生成字节码的时候,如果一个节点有常数值,那么就直接把该常数值写入字节码,这个节点之下的子树就都被忽略。
|
||||
|
||||
**2.FLOW阶段:不可达的代码**
|
||||
|
||||
在FLOW阶段,通过活跃性分析,编译器会发现某些代码是不可达的。这个时候,Java编译器不是悄悄地优化掉它们,而是会报编译错误,让程序员自己做调整。
|
||||
|
||||
**3.LOWER阶段:代数简化**
|
||||
|
||||
在LOWER阶段的代码中,除了去除语法糖,你还能看到一些代数简化的行为。给你举个例子,在Lower.visitBinary()方法中,也就是处理二元操作的AST的时候,针对逻辑“或(OR)”和“与(AND)”运算,有一些优化代码。比如,针对“或”运算,如果左子树的值是true,那么“或”运算对应的AST用左子树代替;而如果左子树是的值是false,那么AST可以用右子树代替。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/97/43ba9cc3f2dd4e8b3ca61624e7306c97.jpg" alt="">
|
||||
|
||||
**4.GEN阶段:代数简化和活跃性分析**
|
||||
|
||||
在生成字节码的时候,也会做一些代数简化。比如在**Gen.visitBinary()方法中**,有跟Lower.visitBinary()类似的逻辑。而整个生成代码的过程,也有类似FLOW阶段的活跃性分析的逻辑,对于不可达的代码,就不再生成字节码。
|
||||
|
||||
看上去GEN阶段的优化算法是冗余的,跟前面的阶段重复了。但是这其实有一个好处,也就是可以把生成字节码的部分作为一个单独的库使用,不用依赖前序阶段是否做了某些优化。
|
||||
|
||||
总结起来,Java编译器在多个阶段都有一点代码优化工作,但总体来看,代码优化是很不足的。真正的高强度的优化,还是要去看Java的JIT编译器。这些侧重于做优化的编译器,有时就会被叫做“优化编译器(Optimizing Compiler)”。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天,我带你分析了Java编译过程的最后两个步骤:去除语法糖和字节码生成。你需要记住以下几点:
|
||||
|
||||
- 语法糖是现代计算机语言中一个友好的特性。Java语言很多语法上的升级,实际上都只是增加了一些语法糖而已。语法糖在Java编译过程中的去除语法糖环节会被还原成基础的语法。**其实现机制,是对AST做修改和转换。**
|
||||
- 生成字节码是一个比较机械的过程,**编译器只需要对AST进行深度优先的遍历即可。<strong>在这个过程中会用到前几个阶段形成的属性信息,特别是**类型信息</strong>。
|
||||
|
||||
我把本讲的知识点整理成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/ff/b1a76404ecb1ab138e3d9bce9b6949ff.jpg" alt="">
|
||||
|
||||
之所以我花了4讲去介绍Java编译器的核心机制,是因为像Java这样成熟的静态类型语言,它的编译器的实现思路有很多借鉴意义,比如词法分析和语法分析采用的算法、语义分析中多个阶段的划分和之间的相互关系、如何用各种方法检查语义错误、符号表的实现、语法糖和基础语法的关系,等等。当你把Java编译器的脉络看清楚以后,再去看其他静态类型语言的编译器的代码,就会发现其中有很多地方是共通的,你就能更快地熟悉起来。这样下来,你对静态语言编译器的前端,都会有个清晰的了解。
|
||||
|
||||
当然,只了解前端部分是不够的,Java还有专注于中后端功能的编译器,也就是JIT编译器。我们这讲也已经说过了,前端编译器的优化功能是有限的。那么,如果想让Java代码高效运行,就要依靠JIT编译器的优化功能和生成机器码的功能了。在后面的四讲中,我会接着给你揭秘Java的JIT编译器。
|
||||
|
||||
## 一课一思
|
||||
|
||||
针对Java编译器这4讲的内容,我们来做一个综合的思考题。假设你现在要写一个简单的DSL引擎,比如让它能够处理一些自定义的公式,最后要生成字节码,你会如何让它最快地实现?是否可以复用Java编译器的功能?
|
||||
|
||||
欢迎你留言分享自己的观点。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Java语言规范第六章:[Java虚拟机指令集](https://docs.oracle.com/javase/specs/jvms/se13/html/jvms-6.html)。
|
||||
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/13 | Java JIT编译器(一):动手修改Graal编译器.md
Normal file
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/13 | Java JIT编译器(一):动手修改Graal编译器.md
Normal file
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="13 | Java JIT编译器(一):动手修改Graal编译器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/3a/3a29fb2e334ec19ea4b0408c04801a3a.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在前面的4讲当中,我们已经解析了OpenJDK中的Java编译器,它是把Java源代码编译成字节码,然后交给JVM运行。
|
||||
|
||||
用过Java的人都知道,在JVM中除了可以解释执行字节码以外,还可以通过即时编译(JIT)技术生成机器码来执行程序,这使得Java的性能很高,甚至跟C++差不多。反之,如果不能达到很高的性能,一定会大大影响一门语言的流行。
|
||||
|
||||
但是,对很多同学来说,对于编译器中后端的了解,还是比较模糊的。比如说,你已经了解了中间代码、优化算法、指令选择等理论概念,**那这些知识在实际的编译器中是如何落地的呢?**
|
||||
|
||||
所以从今天开始,我会花4讲的时间,来带你了解Java的JIT编译器的组成部分和工作流程、它的IR的设计、一些重要的优化算法,以及生成目标代码的过程等知识点。在这个过程中,你还可以印证关于编译器中后端的一些知识点。
|
||||
|
||||
今天这一讲呢,我首先会带你理解JIT编译的基本原理;然后,我会带你进入Graal编译器的代码内部,一起去修改它、运行它、调试它,让你获得第一手的实践经验,消除你对JIT编译器的神秘感。
|
||||
|
||||
## 认识Java的JIT编译器
|
||||
|
||||
我们先来探究一下JIT编译器的原理。
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我讲过程序运行的原理:把一个指令指针指向一个内存地址,CPU就可以读取其中的内容,并作为指令来执行。
|
||||
|
||||
所以,Java后端的编译器只要生成机器码就行了。如果是在运行前一次性生成,就叫做提前编译(AOT);如果是在运行时按需生成机器码,就叫做即时编译(JIT)。Java以及基于JVM的语言,都受益于JVM的JIT编译器。
|
||||
|
||||
在JDK的源代码中,你能找到src/hotspot目录,这是JVM的运行时,它们都是用C++编写的,其中就包括JIT编译器。标准JDK中的虚拟机呢,就叫做HotSpot。
|
||||
|
||||
实际上,HotSpot带了两个JIT编译器,一个叫做**C1**,又叫做**客户端编译器**,它的编译速度快,但优化程度低。另一个叫做**C2**,又叫做**服务端编译器**,它的编译速度比较慢,但优化程度更高。这两个编译器在实际的编译过程中,是被结合起来使用的。而**字节码解释器**,我们可以叫做是**C0**,它的运行速度是最慢的。
|
||||
|
||||
在运行过程中,HotSpot首先会用C0解释执行;接着,HotSpot会用C1快速编译,生成机器码,从而让运行效率提升。而对于运行频率高的热点(HotSpot)代码,则用C2深化编译,得到运行效率更高的代码,这叫做**分层编译**(Tiered Compilation)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/ba/984004f06874e5a28571082c72c080ba.jpg" alt="">
|
||||
|
||||
由于C2会做一些激进优化,比如说,它会根据程序运行的统计信息,认为某些程序分支根本不会被执行,从而根本不为这个分支生成代码。不过,有时做出这种激进优化的假设其实并不成立,那这个时候就要做一个**逆优化(Deoptimization)**,退回到使用C1的代码,或退回到用解释器执行。
|
||||
|
||||
触发即时编译,需要检测**热点代码**。一般是以方法为单位,虚拟机会看看该方法的运行频次是否很高,如果运行特别频繁,那么就会被认定为是热点代码,从而就会被触发即时编译。甚至如果一个方法里,有一个循环块是热点代码(比如循环1.5万次以上),这个时候也会触发编译器去做即时编译,在这个方法还没运行完毕的时候,就被替换成了机器码的版本。由于这个时候,该方法的栈帧还在栈上,所以我们把这个技术叫做**栈上替换**(On-stack Replacement,OSR)。栈上替换的技术难点,在于让本地变量等数据无缝地迁移,让运行过程可以正确地衔接。
|
||||
|
||||
## Graal:用Java编写的JIT编译器
|
||||
|
||||
如果想深入地研究Java所采用的JIT编译技术,我们必须去看它的源码。可是,对于大多数Java程序员来说,如果去阅读C++编写的编译器代码,肯定会有些不适应。
|
||||
|
||||
一个好消息是,Oracle公司推出了一个完全用Java语言编写的JIT编译器:Graal,并且也有开放源代码的社区版,你可以[下载](https://github.com/graalvm/graalvm-ce-builds/releases)安装并使用。
|
||||
|
||||
用Java开发一款编译器的优点是很明显的。
|
||||
|
||||
1. 首先,Java是内存安全的,而C++程序的很多Bug都与内存管理有关,比如可能不当地使用了指针之类的。
|
||||
1. 第二,与Java配套的各种工具(比如IDE)更友好、更丰富。
|
||||
1. 第三,Java的性能并不低,所以能够满足对编译速度的需求。
|
||||
1. 最后,用Java编译甚至还能节省内存的占用,因为Java采用的是动态内存管理技术,一些对象没用了,其内存就会被回收。而用C++编写的话,可能会由于程序员的疏忽,导致一些内存没有被及时释放。
|
||||
|
||||
从Java9开始,你就可以用Graal来替换JDK中的JIT编译器。这里有一个**JVMCI**(JVM Compiler Interface)接口标准,符合这个接口标准的JIT编译器,都可以被用于JVM。
|
||||
|
||||
Oracle公司还专门推出了一款JVM,叫做**GraalVM**。它除了用Graal作为即时编译器以外,还提供了一个很创新的功能:在一个虚拟机上支持多种语言,并且支持它们之间的互操作。你知道,传统的JVM上已经能够支持多种语言,比如Scala、Clojure等。而新的GraalVM会更进一步,它通过一个Truffle框架,可以支持JavaScript、Ruby、R、Python等需要解释执行的语言。
|
||||
|
||||
再进一步,它还通过一个Sulong框架支持LLVM IR,从而支持那些能够生成LLVM IR的语言,如C、C++、Rust等。想想看,在Java的虚拟机上运行C语言,还是有点开脑洞的!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/cc/7b5e9041787e6b098ef62e6cf42671cc.jpg" alt="">
|
||||
|
||||
最后,GraalVM还支持AOT编译,这就让Java可以编译成本地代码,让程序能更快地启动并投入高速运行。我听说最近的一些互联网公司,已经在用Graal做AOT编译,来生成本地镜像,提高应用的启动时间,从而能够更好地符合云原生技术的要求。
|
||||
|
||||
## 修改并运行Graal
|
||||
|
||||
好,那接下来,我就带你一起动手修改一下Graal编译器,在这个过程中,你就能对Graal的程序结构熟悉起来,消除对它的陌生感,有助于后面深入探索其内部的实现机制。
|
||||
|
||||
在本课程中,我采用了Graal的20.0.1版本的源代码。你可以参考Graal中的[文档](https://github.com/oracle/graal/tree/master/compiler)来做编译工作。
|
||||
|
||||
首先,下载源代码(指定了代码的分支):
|
||||
|
||||
```
|
||||
git clone -b vm-20.0.1 https://github.com/oracle/graal.git
|
||||
|
||||
```
|
||||
|
||||
接着,下载GraalVM的构建工具mx,它是用Python2.7编写的,你需要有正确的Python环境:
|
||||
|
||||
```
|
||||
git clone https://github.com/graalvm/mx.git
|
||||
export PATH=$PWD/mx:$PATH
|
||||
|
||||
```
|
||||
|
||||
你需要在自己的机器上设置好JDK8或11的环境。我这里是在macOS上,采用JDK8。
|
||||
|
||||
```
|
||||
export PATH="/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home/bin:$PATH"
|
||||
export JAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home
|
||||
|
||||
```
|
||||
|
||||
好了,现在你就可以编译Graal了。你可以在Graal源代码的compiler子目录中,运行mx build:
|
||||
|
||||
```
|
||||
mx build
|
||||
|
||||
```
|
||||
|
||||
编译完毕以后,你可以写一个小小的测试程序,来测试Graal编译器的功能。
|
||||
|
||||
```
|
||||
javac Foo.java //编译Foo.java
|
||||
mx vm Foo //运行Foo.java,相当于执行java Foo
|
||||
|
||||
```
|
||||
|
||||
“mx vm”命令在第一次运行的时候,会打包出一个新的GraalVM,它所需要的HotSpot VM,是从JDK中拷贝过来的,然后它会把Graal编译器等其他模块也添加进去。
|
||||
|
||||
Foo.java的源代码如下。在这个示例程序中,main方法会无限次地调用add方法,所以add方法就成为了热点代码,这样会逼迫JIT编译器把add方法做即时编译。
|
||||
|
||||
```
|
||||
public class Foo{
|
||||
public static void main(String args[]){
|
||||
int i = 0;
|
||||
while(true){
|
||||
if(i%1000==0){
|
||||
System.out.println(i);
|
||||
try{
|
||||
Thread.sleep(100); //暂停100ms
|
||||
}catch(Exception e){}
|
||||
}
|
||||
|
||||
i++;
|
||||
add(i,i+1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static int add(int x, int y){
|
||||
return x + y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于我们现在已经有了Graal的源代码,所以我们可以在源代码中打印一点信息,来显示JIT是什么时候被触发的。
|
||||
|
||||
**org.graalvm.compiler.hotspot.HotspotGraalCompiler.compileMethod()方法**,是即时编译功能的入口,你可以在里面添加一行输出功能,然后用“mx build”命令重新构建。
|
||||
|
||||
```
|
||||
public CompilationRequestResult compileMethod(CompilationRequest request) {
|
||||
//打印被编译的方法名和字节码
|
||||
System.out.println("Begin to compile method: " + request.getMethod().getName() + "\nbytecode: " + java.util.Arrays.toString(request.getMethod().getCode()));
|
||||
|
||||
return compileMethod(request, true, graalRuntime.getOptions());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你在compiler目录里,打出“mx ideinit”命令,就可以为Eclipse、IntelliJ Idea等编译器生成配置信息了。你可以参照[文档](https://github.com/oracle/graal/blob/master/compiler/docs/IDEs.md)来做好IDE的配置。
|
||||
|
||||
注意:我用Eclipse和IntelliJ Idea都试了一下。Idea的使用体验更好一些。但用mx ideinit命令为Idea生成的配置文件,只是针对JDK8的,如果要改为JDK11,还需要手工修改不少配置信息。<br>
|
||||
在使用Idea的时候,你要注意安装python插件,文档中建议的其他插件可装可不装。<br>
|
||||
在使用Eclipse时,我曾经发现有一些报错信息,是因为IDE不能理解一些注解。你如果也遇到了类似情况,稍微修改一下头注释就能正常使用了。
|
||||
|
||||
```
|
||||
mx ideinit
|
||||
|
||||
```
|
||||
|
||||
然后,你可以运行下面的命令来执行示例程序:
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
你会看到,命令中包含了很多不同的参数,它们分别代表了不同的含义。
|
||||
|
||||
- -XX:+UnlockExperimentalVMOptions:启用试验特性。
|
||||
- -XX:+EnableJVMCI:启用JVMCI功能。
|
||||
- -XX:+UseJVMCICompiler :使用JVMCI编译器,也就是Graal。
|
||||
- -XX:-TieredCompilation :禁用分层编译。
|
||||
- -XX:CompileOnly=Foo.add:只编译add方法就行了。
|
||||
|
||||
当程序运行以后,根据打印的信息,你就能判断出JIT编译器是否真的被调用了。实际上,它是在add方法执行了15000次以后才被调用的。这个时候,JVM会认为add方法是一个热点。因为JIT是在另一个线程启动执行的,所以输出信息要晚一点。
|
||||
|
||||
好了,通过这个实验,你就能直观地了解到,JVM是如何判断热点并启动JIT机制的了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/41/c757696ac327f3e3d0ecf10d69cf8241.jpg" alt="">
|
||||
|
||||
另外,在这个实验中,你还可以通过“-XX:CompileThreshold”参数,来修改热点检测的门槛。比如说,你可以在“-XX:CompileThreshold=50”,也就是让JVM在被add方法执行了50次之后,就开始做即时编译。你还可以使用“-Xcomp”参数,让方法在第一次被调用的时候就开始做编译。不过这样编译的效果会差一些,因为让方法多运行一段时间再编译,JVM会收集一些运行时的信息,这些信息会有助于更好地做代码优化。**这也是AOT编译的效果有时会比JIT差的原因,因为AOT缺少了运行时的一些信息。**
|
||||
|
||||
好了,接下来,我们再来看看JIT编译后的机器码是什么样子的。
|
||||
|
||||
JIT所做的工作,本质上就是把字节码的Byte数组翻译成机器码的Byte数组,在翻译过程中,编译器要参考一些元数据信息(符号表等),再加上运行时收集的一些信息(用于帮助做优化)。
|
||||
|
||||
前面的这个示例程序,它在运行时就已经打印出了字节码:[26, 27, 96, -84]。如果我们转换成16进制,就是[1a, 1b, 60, ac]。它对应的字节码是:[iload_0, iload_1, iadd, ireturn]。
|
||||
|
||||
我们暂时忽略掉这中间的编译过程,先来看看JIT编译后生成的机器码。
|
||||
|
||||
Graal编译完毕以后,是在org.graalvm.compiler.hotspot.CompilationTask的**performCompilation方法**中,把编译完毕的机器码安装到缓存区,用于后续执行。在这里,你可以加一点代码,打印编译后的结果。
|
||||
|
||||
```
|
||||
...
|
||||
installMethod(debug, result); //result是编译结果
|
||||
System.out.println("Machine code: " + java.util.Arrays.toString(result.getTargetCode()));
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
打印输出的机器码数组如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/9f/a6d3f9yy43f400be22d564a20eeeba9f.jpg" alt="">
|
||||
|
||||
我们光看这些机器码数组,当然是看不出来有什么含义的,但JDK可以把机器码反编译成汇编码,然后打印输出,就会更方便被我们解读。这就需要一个**反汇编工具hsdis**。
|
||||
|
||||
运行“mx hsdis”命令,你可以下载一个动态库(在macOS上是hsdis-amd64.dylib,在Linux上以so结尾,在Windows上以dll结尾)。这个动态库会被拷贝到JDK的lib目录下,这样我们就可以通过命令行参数,让JVM输出编译生成的汇编码。
|
||||
|
||||
```
|
||||
sudo -E mx hsdis #用sudo是为了有权限把动态库拷贝到系统的JDK的lib目录
|
||||
|
||||
```
|
||||
|
||||
注:由于我使用mx命令来运行示例程序,所以使用的JDK实际上是GraalVM从系统JDK中拷贝过来的版本,因此我需要手工把hsdis.dylib拷贝到graal-vm-20.0.1/compiler/mxbuild/darwin-amd64/graaljdks/jdk11-cmp/lib目录下。
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:+PrintCompilation \
|
||||
-XX:+UnlockDiagnosticVMOptions \
|
||||
-XX:+PrintAssembly \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
输出的汇编码信息如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/f8/70d6ba99d1330bdfa59be99dbab4d3f8.jpg" alt="">
|
||||
|
||||
我来解释一下这段汇编代码的含义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/56/77fc4ce3ff1ecaec8ca927e537796056.jpg" alt="">
|
||||
|
||||
好了,现在你已经能够直观地了解JIT启动的时机:检测出热点代码;以及它最后生成的结果:机器码。
|
||||
|
||||
但我们还想了解一下中间处理过程的细节,因为这样才能理解编译器的工作机制。所以这个时候,如果能够跟踪Graal的执行过程就好了,就像调试一个我们自己编写的程序那样。那么我们能做到吗?
|
||||
|
||||
当然是可以的。
|
||||
|
||||
## 跟踪Graal的运行
|
||||
|
||||
Graal是用Java编写的,因此你也可以像调试普通程序一样调试它。你可以参考源代码中的这篇与调试有关的[文档](https://github.com/oracle/graal/blob/master/compiler/docs/Debugging.md)。
|
||||
|
||||
由于Graal是在JVM中运行的,所以你要用到JVM的远程调试模式。我们仍然要运行Foo示例程序,不过要加个“-d”参数,表示让JVM运行在调试状态下。
|
||||
|
||||
```
|
||||
mx -d vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
这个时候,在JVM启动起来之后,会在8000端口等待调试工具跟它连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/48/1bd338412260c145ba1b944a786c9648.jpg" alt="">
|
||||
|
||||
你可以使用Eclipse或Idea做调试工具。我以Eclipse为例,在前面运行“mx ideinit”的时候,我就已经设置了一个远程调试的配置信息。
|
||||
|
||||
你可以打开“run>debug configurations…”菜单,在弹出的对话框中,选择Remote Java Application,可以看到几个预制好的配置。
|
||||
|
||||
然后,点击“compiler-attach-localhost-8000”,你可以看到相关属性。其中,连接信息正是本机的8000端口。
|
||||
|
||||
把Project改成“org.graalvm.compiler.hotspot”,然后点击Debug按钮。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/b0/05024fc7b348d197934162b41c5d5fb0.jpg" alt="">
|
||||
|
||||
补充:如果你使用的是Idea,你也会找到一个预制好的远程调试配置项:GraalDebug。直接点击就可以开始调试。
|
||||
|
||||
为了方便调试,我在org.graalvm.compiler.hotspot.compileMethod()方法中设置了断点,所以到了断点的时候,程序就会停下来,而不是一直运行到结束。
|
||||
|
||||
当你点击Debug按钮以后,Foo程序会继续运行。在触发了JIT功能以后,JVM会启动一个新线程来运行Graal,而Foo则继续在主线程里运行。因为Foo一直不会结束,所以你可以从容地进行调试,不用担心由于主线程的退出,而导致运行Graal的线程也退出。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/cb/795f4a22afc0b478e4e84e09fc6926cb.jpg" alt="">
|
||||
|
||||
现在,你可以跟踪Graal的编译过程,看看能发现些什么。在这个过程中,你需要一点耐心,慢慢理解整个代码结构。
|
||||
|
||||
Graal执行过程的主要结构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/9b/77355ff69b5092e21ed486397288a79b.jpg" alt="">
|
||||
|
||||
首先,你会发现,在编译开始的时候,Graal编译器要把字节码转化成一个图的数据结构。而后续的编译过程,都是对这个图的处理。这说明了这个图很重要,而这个图就是Graal要用到的IR,在Graal编译器中,它也被叫做HIR。
|
||||
|
||||
接着,你会看到,整个编译过程被分成了前端和后端两个部分。前端部分使用的IR是HIR。而且在前端部分,HIR又分成了高(HighTier)、中(MidTier)、低(LowTier)三层。在每个层次里,都要执行很多遍(Phase)对图的处理。这些处理,指的就是各种的优化和处理算法。而从高到低过渡的过程,就是不断Lower的过程,也就是把IR中,较高抽象度的节点替换成了更靠近底层实现的节点。
|
||||
|
||||
在后端部分,则要生成一种新的IR,也就是我们在[第6讲](https://time.geekbang.org/column/article/247700)中提到过的LIR,并且Graal也要对它进行多遍处理。最后一步,就是生成目标代码。
|
||||
|
||||
下图中,我举了一个例子,列出了编译器在前端的三个层次以及在后端所做的优化和处理工作。
|
||||
|
||||
**你要注意的是,**在编译不同的方法时,所需要的优化工作也是不同的,具体执行的处理也就不一样。并且这些处理执行过程也不是线性执行的,而可能是一个处理程序调用了另一个处理程序,嵌套执行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/f1/6f5d357fc7df9319060ede8f5a39d4f1.jpg" alt="">
|
||||
|
||||
不过通过跟踪Graal的运行过程,你可以留下一个直观的印象:**Graal编译器的核心工作,就是对图(IR)的一遍遍处理。**
|
||||
|
||||
在下一讲中,我就会进一步讲述Graal的IR,并会带你一起探讨优化算法的实现过程,你可以了解到一个真实编译器的IR是怎样设计的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你大致了解了Java的JIT编译器。你需要重点关注以下几个核心要点:
|
||||
|
||||
- JIT可能会用到多个编译器,有的编译速度快但不够优化(比如C1,客户端编译器),有的够优化但编译速度慢(比如C2,服务端编译器),所以在编译过程中会结合起来使用。
|
||||
- 你还需要理解逆优化的概念,以及导致逆优化的原因。
|
||||
- 另外,我还带你了解了Graal这个用Java编写的Java JIT编译器。最重要的是,通过查看它的代码、修改代码、运行和调试的过程,你能够建立起对Graal编译器的亲切感,不会觉得这些技术都是很高冷的,不可接近的。当你开始动手修改的时候,你就踏上了彻底掌握它的旅途。
|
||||
- 你要熟练掌握调试方法,并且熟练运用GraalVM的很多参数,这会有利于你去做很多实验,来深入掌握Graal。
|
||||
|
||||
本讲的思维导图我放在这里了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/09/4420e3679bb8yy26d6ef663f7fe05609.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否把示例程序的add函数改成一个需要计算量的函数,然后,你可以比较一下,看看JIT前后性能相差了多少倍?通过这样的一个例子,你可以获得一些感性认识。
|
||||
|
||||
有相关的问题或者是思考呢,你都可以给我留言。如果你觉得有收获,你也可以把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. GraalVM项目的官方网站:[graalvm.org](https://www.graalvm.org/)。
|
||||
1. Graal的[Github地址](https://github.com/oracle/graal)。
|
||||
1. Graal项目的[出版物](https://github.com/oracle/graal/blob/master/docs/Publications.md)。有很多围绕这个项目来做研究的论文,值得一读。
|
||||
@@ -0,0 +1,268 @@
|
||||
<audio id="audio" title="14 | Java JIT编译器(二):Sea of Nodes为何如此强大?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/67/bfa3853c0030c9cd6aa50ayyc02c1167.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。这一讲,我们继续来研究Graal编译器,重点来了解一下它的IR的设计。
|
||||
|
||||
在上一讲中,我们发现Graal在执行过程中,创建了一个图的数据结构,这个数据结构就是Graal的IR。之后的很多处理和优化算法,都是基于这个IR的。可以说,这个IR是Graal编译器的核心特性之一。
|
||||
|
||||
**那么,为什么这个IR采用的是图结构?它有什么特点和优点?编译器的优化算法又是如何基于这个IR来运行的呢?**
|
||||
|
||||
今天,我就带你一起来攻破以上这些问题。在揭晓问题答案的过程中,你对真实编译器中IR的设计和优化处理过程,也就能获得直观的认识了。
|
||||
|
||||
## 基于图的IR
|
||||
|
||||
IR对于编译器非常重要,因为它填补了高级语言和机器语言在语义上的巨大差别。比如说,你在高级语言中是使用一个数组,而翻译成最高效的x86机器码,是用间接寻址的方式,去访问一块连续的内存。所以IR的设计必须有利于实现这种转换,并且还要有利于运行优化算法,使得生成的代码更加高效。
|
||||
|
||||
在上一讲中,通过跟踪Graal编译器的执行过程,我们会发现它在一开始,就把字节码翻译成了一种新的IR,这个IR是用图的结构来表示的。那这个图长什么样子呢?非常幸运的是,我们可以用工具来直观地看到它的结构。
|
||||
|
||||
你可以从Oracle的[网站](https://www.oracle.com/downloads/graalvm-downloads.html)上,下载一个**idealgraphvisualizer**的工具。下载之后,解压缩,并运行它:
|
||||
|
||||
```
|
||||
export PATH="/<上级目录>/idealgraphvisualizer/bin:$PATH"
|
||||
idealgraphvisualizer &
|
||||
|
||||
```
|
||||
|
||||
这时,程序会启动一个图形界面,并在4445端口上等待GraalVM发送数据过来。
|
||||
|
||||
接着,还是运行Foo示例程序,不过这次你要增加一个参数“`-Dgraal.Dump`”,这会让GraalVM输出编译过程的一些中间结果。并且在这个示例程序当中,我还增加了一个“`-Xcomp`”参数,它能让JIT编译器在第一次使用某个方法的时候,就去做编译工作。
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo \
|
||||
-Dgraal.Dump \
|
||||
-Xcomp \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
GraalVM会在终端输出“`Connected to the IGV on 127.0.0.1:4445`”,这表明它连接上了idealgraphvisualizer。接着,在即时编译之后,idealgraphvisualizer就接收到了编译过程中生成的图,你可以点击显示它。
|
||||
|
||||
这里我展示了其中两个阶段的图,一个是刚解析完字节码之后(After parsing),一个是在处理完中间层之后(After mid tier)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/2b/28a6cd4180b3a28ce59098a2f5a4c82b.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/77/9448a684d1b3e04b695bc0761a6b7c77.jpg" alt="">
|
||||
|
||||
Graal IR其实受到了“程序依赖图”的影响。我们在[第6讲](https://time.geekbang.org/column/article/247700)中提到过程序依赖图(PDG),它是用图来表示程序中的数据依赖和控制依赖。并且你也知道了,这种IR还有一个别名,叫做**节点之海(Sea of Nodes)**。因为当程序稍微复杂一点以后,图里的节点就会变得非常多,我们用肉眼很难看得清。
|
||||
|
||||
基于Sea of Nodes的IR呢,算是后起之秀。在HotSpot的编译器中,就采用了这种IR,而且现在Java的Graal编译器和JavaScript的V8编译器中的IR的设计,都是基于了Sea of Nodes结构,所以我们必须重视它。
|
||||
|
||||
这也不禁让我们感到好奇了:**Sea of Nodes到底强在哪里?**
|
||||
|
||||
我们都知道,数据结构的设计对于算法来说至关重要。IR的数据结构,会影响到算法的编写方式。好的IR的设计,会让优化算法的编写和维护都更加容易。
|
||||
|
||||
**而Sea of Nodes最大的优点,就是能够用一个数据结构同时反映控制流和数据流,并且尽量减少它们之间的互相依赖**。
|
||||
|
||||
怎么理解这个优点呢?在传统的编译器里,控制流和数据流是分开的。控制流是用控制流图(Control-flow Graph,CFG)来表示的,比如GNU的编译器、LLVM,都是基于控制流图的。而IR本身,则侧重于表达数据流。
|
||||
|
||||
以LLVM为例,它采用了SSA格式的IR,这种IR可以很好地体现值的定义和使用关系,从而很好地刻画了数据流。
|
||||
|
||||
而问题在于,采用这种比较传统的方式,控制流和数据流会耦合得比较紧,因为IR指令必须归属于某个基本块。
|
||||
|
||||
举个例子来说明一下吧。在下面的示例程序中,“`int b = a*2;`”这个语句,会被放到循环体的基本块中。
|
||||
|
||||
```
|
||||
int foo(int a){
|
||||
int sum = 0;
|
||||
for(int i = 0; i< 10; i++){
|
||||
int b = a*2; //这一句可以提到外面
|
||||
sum += b;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可是,从数据流的角度看,变量b只依赖于a。所以这个语句没必要放在循环体内,而是可以提到外面。在传统的编译器中,这一步是要分析出循环无关的变量,然后再把这条语句提出去。而如果采用Sea of Nodes的数据结构,变量b一开始根本没有归属到特定的基本块,所以也就没有必要专门去做代码的移动了。
|
||||
|
||||
另外,我们之前讲[本地优化和全局优化](https://time.geekbang.org/column/article/248770)的时候,也提到过,它们的区别就是,在整个函数范围内,优化的范围是在基本块内还是会跨基本块。而Sea of Nodes没有过于受到基本块的束缚,因此也就更容易做全局优化了。
|
||||
|
||||
好,那在概要地理解了Graal IR的数据结构之后,接下来,我们就具体了解一下Graal IR,包括认识一下数据流与控制流的特点,了解两种不同的节点:浮动节点和固定节点,以及认识一种特殊的节点:FrameState。
|
||||
|
||||
### 数据流和控制流
|
||||
|
||||
我们已经知道,Graal IR整合了两种图结构:数据流图和控制流图。
|
||||
|
||||
**首先,我们来看看它的数据流。**
|
||||
|
||||
在下图中,蓝色的边代表的是数据流,也就是数据之间的依赖关系。参数1(“`P(0)`”节点)和参数2(“`P(1)`”节点)的值流入到+号节点,再流入到Return节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/26/5d45ebd21fed3b59db52c4e5416b2b26.jpg" alt="">
|
||||
|
||||
在Graal IR的设计中,Add节点有两个输入,分别是x和y,这两个输入是AddNode的两个属性。**注意**,这个图中的箭头方向代表的是**数据依赖关系**,也就是Add节点保持着对它的两个输入节点的引用,这其实跟AST是一致的。**而数据流向,则是反过来的**,从x和y流向Add节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/8f/3fb26f079c4eee298dca7954c55a4f8f.jpg" alt="">
|
||||
|
||||
查看AddNode的设计,你会发现其父类中有两个成员变量,x和y。它们用@input做了注解,这就意味着,这两个成员变量代表的是数据流图中的两条边。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/b0/96408d7ab3721593981172b249daf8b0.jpg" alt="">
|
||||
|
||||
另外,Graal IR的数据流图是符合SSA格式的。也就是说,每个节点代表了SSA中的一个值,它只被定义一次,也就相当于SSA中的每个变量只被赋值一次。
|
||||
|
||||
**我们再来看看控制流。**
|
||||
|
||||
下图中,红色的边代表的是控制流,控制流图代表的是程序执行方向的改变。进入或退出一个函数、条件分支语句、循环语句等,都会导致程序的执行从一个地方跳到另一个地方。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/d2/f6d826358b5f1cc2d4048a310273a6d2.jpg" alt="">
|
||||
|
||||
数据流加上控制流,就能完整表达程序的含义,它等价于字节码,也等价于更早期的AST。你可以从Start节点,沿着控制流遍历这个图。当到达Return节点之前,Return所依赖的数据(x+y)也需要计算出来。
|
||||
|
||||
add()方法的控制流很简单,只有Start和Return两个节点。我们做一个稍微复杂一点的例子,在Foo.add2()示例程序中,调用两个函数getX()和getY(),分别获取x和y成员变量。
|
||||
|
||||
```
|
||||
public int add2(){
|
||||
return getX() + getY();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的Graal图如下。它增加了两个节点,分别是调用方法getX和getY,这就导致了控制流发生变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/38/ce0fb1e90cb4938197d3fe502ef37c38.jpg" alt="">
|
||||
|
||||
注意:对于这个例子,在使用GraalVM时,要使用-XX:-Inline选项,避免编译器做内联优化,否则Foo.getX()和Foo.getY()会被内联。我们在下一讲中就会探讨内联优化。
|
||||
|
||||
除了调用其他函数,if语句、循环语句等,也会导致控制流的变化。我们看看这个例子:
|
||||
|
||||
```
|
||||
public int doif(int x, int y){
|
||||
int z;
|
||||
if (x < 2)
|
||||
z=x+y;
|
||||
else
|
||||
z=x*y;
|
||||
return z;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它对应的Graal图如下,if语句会让控制流产生分支,分别对应if块和else块,最后在Merge节点合并起来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/5f/ffa4a5ab2fb85437085fac2c67333f5f.jpg" alt="">
|
||||
|
||||
IfNode作为一种控制流节点,它保存着对下级节点的引用,并用@Successor注解来标注。这意味着trueSuccessor和falseSuccessor两个成员变量,代表着控制流中的两条边。当然,你也会注意到,If节点有一个数据流的输入,这就是If的判断条件。IR会基于这个判断条件,来决定控制流的走向。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/d2/1dce8ca28447b2b61f31e4b40d1f37d2.jpg" alt="">
|
||||
|
||||
跟控制流类似,数据流也产生了两个分支,分别是`x+y`和`x*y`。最后用一个Phi节点合并到一起。
|
||||
|
||||
Phi节点是SSA的一个特性。在doif示例程序中,z可能有两个取值。如果控制流走的是if块,那么`z=x+y`;而如果走的是else块,则`z=x*y`。Phi节点就起到这个作用,它根据控制流来选择值。
|
||||
|
||||
总结一下:控制流图表达的是控制的流转,而数据流图代表的是数据之间的依赖关系。二者双剑合璧,代表了源程序完整的语义。
|
||||
|
||||
接下来,我再给你介绍一下浮动节点和固定节点的概念。
|
||||
|
||||
### 浮动节点和固定节点
|
||||
|
||||
注意,在Graal IR,数据流与控制流是相对独立的。你看看前面的doif示例程序,会发现`x+y`和`x*y`的计算,与if语句的控制流没有直接关系。所以,你其实可以把这两个语句挪到if语句外面去执行,也不影响程序运行的结果(要引入两个临时变量z1和z2,分别代表z的两个取值)。
|
||||
|
||||
对于这些在执行时间上具有灵活性的节点,我们说它们是浮动的(Floating)。你在AddNode的继承层次中,可以看到一个父类:FloatingNode,这说明这个节点是浮动的。它可以在最后生成机器码(或LIR)的环节,再去确定到底归属哪个基本块。
|
||||
|
||||
除了浮动节点以外,还有一些节点是固定在控制流中的,前后顺序不能乱,这些节点叫做固定节点。除了那些流程控制类的节点(如IfNode)以外,还有一些节点是固定节点,比如内存访问的节点。当你访问一个对象的属性时,就需要访问内存。
|
||||
|
||||
内存是个共享资源,同一个内存地址(比如对象的属性),可以被多次读写。也就是说,内存位置不是SSA中的值,所以也不受单赋值的约束。
|
||||
|
||||
对同一个内存地址的读写操作,顺序是不能乱的。比如下面代码中,第二行和第三行的顺序是不能变的,它们被固定在了控制流中。
|
||||
|
||||
```
|
||||
x := 10
|
||||
store x to 地址a
|
||||
y := load 地址a
|
||||
z := y + 10
|
||||
|
||||
```
|
||||
|
||||
不过,在运行某些优化算法的时候,某些固定节点会被转化成浮动节点,从而提供了更大的代码优化空间。我们在下一讲的“内联和逃逸分析”中,会见到这样的例子。
|
||||
|
||||
### FrameState节点
|
||||
|
||||
在看Graal IR的时候,你经常会遇到一个绿色的节点插在图中。为避免你产生困惑,接下来我就专门给你解释一下这个节点,我们一起来认识一下它。
|
||||
|
||||
在Foo.add()新生成的IR中,如果你不勾选“Remove State”选项,就会显示出一个绿色的节点。这个节点就是FrameState节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/2f/74159a7a56ed706335caf8e5fb3c5c2f.jpg" alt="">
|
||||
|
||||
FrameState比较特殊。它保存了栈帧的状态,而且这里我指的是Java字节码解释器的栈帧的状态,包括了本地变量和操作数栈里的值。
|
||||
|
||||
**为什么要保存栈帧的状态呢?**
|
||||
|
||||
**第一个用途,是用于逆优化。**上一讲我们说过,编译器有时候会基于推测做一些激进的优化,比如忽略掉某些分支。但如果推测依据的前提错了,那么就要做逆优化,重新回到解释器去执行。而FrameState的作用就是在代码中一些叫做安全点的地方,记录下栈帧的状态,便于逆优化以后,在解释器里去接着执行程序。
|
||||
|
||||
**第二个用途,是用于debug。**编译器会用FrameState,来记录程序执行的一些中间状态值,以方便程序的调试。
|
||||
|
||||
对于Foo.add()方法的IR,通过后面的一些优化处理,你会发现Foo.add()并不需要逆优化,那么FrameState节点就会被去掉。否则,FrameState就会转化成一个逆优化节点,生成与逆优化有关的代码。
|
||||
|
||||
如果你并不关心逆优化,那你在平常看IR的过程中,可以勾选“Remove State”选项,不用关注FrameState节点就行了。
|
||||
|
||||
好了,我们已经大致了解了Graal IR。进一步,编译器要基于IR做各种处理和优化。
|
||||
|
||||
## 对Graal IR的处理和优化
|
||||
|
||||
通过上一讲,我们已经知道在编译过程中要对图进行很多遍的处理。还是以Foo.add()示例程序为例,在运行GraalVM的时候,我们加上“`-Dgraal.Dump=:5`”选项,程序就会详细地dump出所做的处理步骤,你可以在idealgraphvisualizer中看到这些处理环节,点击每个环节可以看到相对应的IR图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/92/7462377c2df0c782dc6524d9ac583b92.jpg" alt="">
|
||||
|
||||
在这些处理阶段的名称中,你会看到我们在[第7讲](https://time.geekbang.org/column/article/248770)中提到的一些代码优化算法的名称(如死代码删除)。有了前面课程的铺垫,你现在看它们应该就消除了一些陌生感。
|
||||
|
||||
另外,你会发现,在这些处理阶段中,有一个Canonicalizer的阶段出现了好几次,并且你可能对这个词也比较陌生,所以下面我们不妨来看看,这个阶段都做了些什么。
|
||||
|
||||
### 规范化(Canonicalizer)
|
||||
|
||||
Canonicalize的意思是规范化。如果某段程序有多种写法,那么编译器会处理成一种统一的、标准的写法。
|
||||
|
||||
比如,对于下面这个简单的函数,它是把a乘以2。在CanonicalizerPhase运行之后,乘法运算被替换成了移位运算,也就是`a<<1`。它的效果与乘以2是相同的,但运行效率更高。
|
||||
|
||||
```
|
||||
public int doDouble(int a){
|
||||
return 2*a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/67/9723be4d7a727f8a4eb229d151e13367.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/f9/f81af2861253a6ce25337df40f1399f9.jpg" alt="">
|
||||
|
||||
你还可以试一下对某个变量取两次负号的操作。在规范化阶段以后,两个负号就会被去掉,直接返回a。
|
||||
|
||||
```
|
||||
public int negneg(int a){
|
||||
return -(-a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
规范化需要的操作,都是对本节点进行修改和替换,一般都不太复杂。某节点如果实现了Canonicalizable接口,在CanonicalizerPhase就会对它做规范化。
|
||||
|
||||
在规范化阶段实现的优化算法包括:常数折叠(Constant Folding)、强度折减(Strength reduction)、全局值编号(Global Value Numbering,GVN),等等。它们的原理,我在[第7讲](https://time.geekbang.org/column/article/248770)都介绍过,这里就不赘述了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我给你介绍了Graal的IR:它整合了控制流图与数据流图,符合SSA格式,有利于优化算法的编写和维护。
|
||||
|
||||
我还带你了解了对IR的一个优化处理过程:规范化。规范化所需要的操作一般并不复杂,它都是对本节点进行修改和替换。在下一讲中,我会带你分析另外两个重要的算法,内联和逃逸分析。
|
||||
|
||||
另外,Graal的IR格式是声明式的(Declarative),它通过描述一个节点及其之间的关系来反映源代码的语义。而我们之前见到的类似[三地址代码](https://time.geekbang.org/column/article/247700)那样的格式,是命令式的(Imperative),它的风格是通过命令直接告诉计算机,来做一个个的动作。
|
||||
|
||||
声明式和命令式是编程的两种风格,在Graal编译器里,我们可以看到声明式的IR会更加简洁,对概念的表达也更加清晰。我们在后面介绍MySQL编译器的实现机制当中,在讲DSL的时候,还会再回到这两个概念,到时你还会有更加深刻的认识。
|
||||
|
||||
本讲的思维导图我也放在了这里,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/88/4fd67086601a6dc4deacc794101d9188.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
了解了Graal IR的特点以后,通过对比我们在第7讲中学过的优化算法,你觉得哪些优化算法在Graal IR上实现起来会更方便?为什么?欢迎在留言区分享你的看法。
|
||||
|
||||
如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
基于图的IR,有三篇论文必须提到:
|
||||
|
||||
1. 程序依赖图:[J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization](https://www.cs.utexas.edu/users/less/reading/spring00/ferrante.pdf). July 1987。有关程序依赖图的概念在1987年就提出来了。
|
||||
1. Click的论文:[A Simple Graph-Based Intermediate Representation](https://www.oracle.com/technetwork/java/javase/tech/c2-ir95-150110.pdf)。这篇文章比较易读,属于必读文献。Click还发表了一些论文,讲述了基于图的IR上的优化算法。
|
||||
1. 介绍Graal IR的论文:[Graal IR: An Extensible Declarative Intermediate Representation](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.726.5496&rep=rep1&type=pdf)。这篇论文也很易读,建议你一定要读一下。
|
||||
@@ -0,0 +1,260 @@
|
||||
<audio id="audio" title="15 | Java JIT编译器(三):探究内联和逃逸分析的算法原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/f6/dcb2a02ae500a3651f3067d603e3cff6.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
基于Graal IR进行的优化处理有很多。但有些优化,针对Java语言的特点,会显得更为重要。
|
||||
|
||||
今天这一讲,我就带你来认识两个对Java来说很重要的优化算法。如果没有这两个优化算法,你的程序执行效率会大大下降。而如果你了解了这两个算法的机理,则有可能写出更方便编译器做优化的程序,从而让你在实际工作中受益。这两个算法,分别是**内联和逃逸分析**。
|
||||
|
||||
另外,我还会给你介绍一种JIT编译所特有的优化模式:**基于推理的优化**。这种优化模式会让某些程序比AOT编译的性能更高。这个知识点,可能会改变你对JIT和AOT的认知,因为通常来说,你可能会认为AOT生成的机器码速度更快,所以通过这一讲的学习,你也会对“全生命周期优化”的概念有所体会。
|
||||
|
||||
好,首先,我们来看看内联优化。
|
||||
|
||||
## 内联(Inlining)
|
||||
|
||||
内联优化是Java JIT编译器非常重要的一种优化策略。简单地说,内联就是把被调用的方法的方法体,在调用的地方展开。这样做最大的好处,就是省去了函数调用的开销。对于频繁调用的函数,内联优化能大大提高程序的性能。
|
||||
|
||||
**执行内联优化是有一定条件的。**第一,被内联的方法要是热点方法;第二,被内联的方法不能太大,否则编译后的目标代码量就会膨胀得比较厉害。
|
||||
|
||||
在Java程序里,你经常会发现很多短方法,特别是访问类成员变量的getter和setter方法。你可以看看自己写的程序,是否也充斥着很多对这种短方法的调用?这些调用如果不做优化的话,性能损失是很厉害的。你可以做一个性能对比测试,通过“`-XX:-Inlining`”参数来阻止JVM做内联优化,看看性能降低得会有多大。
|
||||
|
||||
**但是这些方法有一个好处:它们往往都特别短,内联之后,实际上并不会显著增加目标代码长度。**
|
||||
|
||||
比如,针对add2示例方法,我们采用内联选项优化后,方法调用被替换成了LoadField(加载成员变量)。
|
||||
|
||||
```
|
||||
public int add2(){
|
||||
return getX() + getY();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/d2/bf9488262141720de83ff05a05942fd2.jpg" alt="">
|
||||
|
||||
在做了Lower处理以后,LoadField会被展开成更底层的操作:根据x和y的地址相对于对象地址的偏移量,获取x和y的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/33/3bdef5cd633e39bc53d188015e82de33.jpg" alt="">
|
||||
|
||||
而要想正确地计算字段的偏移量,我们还需要了解Java对象的**内存布局**。
|
||||
|
||||
在64位平台下,每个Java对象头部都有8字节的标记字,里面有对象ID的哈希值、与内存收集有关的标记位、与锁有关的标记位;标记字后面是一个指向类定义的指针,在64位平台下也是8位,不过如果堆不是很大,我们可以采用压缩指针,只占4个字节;在这后面才是x和y字段。因此,x和y的偏移量分别是12和16。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/23/13766183508ae9b5668ec5bb816aac23.jpg" alt="">
|
||||
|
||||
在Low Tier编译完毕以后,图2还会进一步被Lower,形成AMD64架构下的地址。这样的话,编译器再进一步翻译成汇编代码就很容易了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/3f/70022d915f78d49d2a9b871b4891fe3f.jpg" alt="">
|
||||
|
||||
内联优化除了会优化getter、setter这样的短方法,它实际上还起到了另一个重要的作用,即**跨过程的优化**。一般的优化算法,只会局限在一个方法内部。而启动内联优化后,多个方法会合并成一个方法,所以就带来了更多的优化的可能性。
|
||||
|
||||
我们来看看下面这个inlining示例方法。它调用了一个atLeastTen方法,这个方法对于<10的参数,会返回10;对于≥10的参数,会返回该参数本身。所以你用肉眼就可以看出来,inlining方法的返回值应该是10。
|
||||
|
||||
```
|
||||
public int inliningTest(int a){
|
||||
return atLeastTen(3); //应该返回10
|
||||
}
|
||||
|
||||
//至少返回10
|
||||
public int atLeastTen(int a){
|
||||
if (a < 10)
|
||||
return 10;
|
||||
else
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果不启用编译器的内联选项,那么inliningTest方法对应的IR图,就是常规的方法调用而已:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/12/060cbc2c3a8a68086f4cd9077df46f12.jpg" alt="">
|
||||
|
||||
而一旦启用了内联选项,就可以触发一系列的优化。在把字节码解析生成IR的时候,编译器就启动了内联分析过程,从而会发现this参数和常量3对于inliningTest方法根本是无用的,在图里表现成了一些孤岛。在Mid Tier处理完毕之后,inliningTest方法就直接返回常量10了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/58/d63a8ac893a03c9c3714580f61742258.jpg" alt="">
|
||||
|
||||
另外,方法的类型也会影响inlining。如果方法是final的,或者是private的,那么它就不会被子类重载,所以可以大胆地内联。
|
||||
|
||||
但如果存在着多重继承的类体系,方法就有可能被重载,这就会导致**多态**。在运行时,JVM会根据对象的类型来确定到底采用哪个子类的具体实现。这种运行时确定具体方法的过程,叫做**虚分派**(Virtual Dispatch)。
|
||||
|
||||
在存在多态的情况下,JIT编译器做内联就会遇到困难了。因为它不知道把哪个版本的实现内联进来。不过编译器仍然没有放弃。这时候所采用的技术,就叫做“**多态内联**(Polymorphic inlining)”。
|
||||
|
||||
它的具体做法是,在运行时,编译器会统计在调用多态方法的时候,到底用了哪几个实现。然后针对这几个实现,同时实现几个分支的内联,并在一开头根据对象类型判断应该走哪个分支。**这个方法的缺陷是生成的代码量会比较大,但它毕竟可以获得内联的好处。**最后,如果实际运行中遇到的对象,与提前生成的几个分支都不匹配,那么编译器还可以继续用缺省的虚分派模式来做函数调用,保证程序不出错。
|
||||
|
||||
这个案例也表明了,JIT编译器是如何充分利用运行时收集的信息来做优化的。对于AOT模式的编译来说,由于无法收集到这些信息,因此反倒无法做这种优化。
|
||||
|
||||
如果你想对多态内联做更深入的研究,还可以参考这一篇经典论文《[Inlining of Virtual Methods](http://extras.springer.com/2000/978-3-540-67660-7/papers/1628/16280258.pdf)》。
|
||||
|
||||
总结起来,内联优化不仅能降低由于函数调用带来的开销,还能制造出新的优化机会,因此带来的优化效果非常显著。接下来,我们看看另一个能带来显著优化效果的算法:逃逸分析。
|
||||
|
||||
## 逃逸分析(Escape Analysis, EA)
|
||||
|
||||
逃逸分析是JVM的另一个重要的优化算法,它同样可以起到巨大的性能提升作用。
|
||||
|
||||
**逃逸分析能够让编译器判断出,一个对象是否能够在创建它的方法或线程之外访问。**如果只能在创建它的方法内部访问,我们就说这个对象不是方法逃逸的;如果仅仅逃逸出了方法,但对这个对象的访问肯定都是在同一个线程中,那么我们就说这个对象不是线程逃逸的。
|
||||
|
||||
**判断是否逃逸有什么用呢?**用处很大。只要我们判断出了该对象没有逃逸出方法或线程,就可以采用更加优化的方法来管理该对象。
|
||||
|
||||
以下面的示例代码为例。我们有一个escapeTest()方法,这个方法可以根据输入的年龄,返回年龄段:小于20岁的返回1,否则返回2。
|
||||
|
||||
在示例程序里,我们创建了一个Person对象,并调用它的ageSegment方法,来返回年龄段。
|
||||
|
||||
```
|
||||
public int escapeTest(int age){
|
||||
Person p = new Person(age);
|
||||
return p.ageSegment();
|
||||
}
|
||||
|
||||
public class Person{
|
||||
private int age;
|
||||
private float weight;
|
||||
|
||||
public Person(int age){
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
//返回年龄段
|
||||
final public int ageSegment(){
|
||||
if (age < 20)
|
||||
return 1;
|
||||
else
|
||||
return 2;
|
||||
}
|
||||
|
||||
public void setWeight(float weight){
|
||||
this.weight = weight;
|
||||
}
|
||||
|
||||
public float getWeidht(){
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以分析一下,针对这段程序,我们可以做哪些优化工作?
|
||||
|
||||
首先是**栈上分配内存**。
|
||||
|
||||
在Java语言里,对象的内存通常都是在堆中申请的。对象不再被访问以后,会由垃圾收集器回收。但对于这个例子来说,Person对象的生命周期跟escapeTest()方法的生命周期是一样的。在退出方法后,就不再会有别的程序来访问该对象。
|
||||
|
||||
换句话说,这个对象跟一个int类型的本地变量没啥区别。那么也就意味着,我们其实可以在栈里给这个对象申请内存就行了。
|
||||
|
||||
你已经知道,在栈里申请内存会有很多好处:可以自动回收,不需要浪费GC的计算量去回收内存;可以避免由于大量生成小对象而造成的内存碎片;数据的局部性也更好,因为在堆上申请内存,它们的物理地址有可能是不相邻的,从而降低高速缓存的命中率;再有,在并发计算的场景下,在栈上分配内存的效率更高,因为栈是线程独享的,而在堆中申请内存可能需要多线程之间同步。所以,我们做这个优化是非常有价值的。
|
||||
|
||||
再进一步,还可以做**标量替换**(Scalar Replacement)。
|
||||
|
||||
这是什么意思呢?你会发现,示例程序仅仅用到了Person对象的age成员变量,而weight根本没有涉及。所以,我们在栈上申请内存的时候,根本没有必要为weight保留内存。同时,在一个Java对象的标准内存布局中,还要有一块固定的对象头的内存开销。在64位平台,对象头可能占据了16字节。这下倒好,示例程序本来只需要4个字节的一个整型,最后却要申请24个字节,是原需求的6倍,这也太浪费了。
|
||||
|
||||
通过标量替换的技术,我们可以根本不生成对象实例,而是把要用到的对象的成员变量,作为普通的本地变量(也就是标量)来管理。
|
||||
|
||||
这么做还有一个好处,就是编译器可以尽量把标量放到寄存器里去,连栈都不用,这样就避免了内存访问所带来的性能消耗。
|
||||
|
||||
Graal编译器也确实是这么做的。在Mid Tier层处理完毕以后,你查看IR图,会发现它变成了下面的这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/4d/fa291d3ccb2bc3a865cdfb4a46ed704d.jpg" alt="">
|
||||
|
||||
你会看到,编译器连Person的ageSegement方法也内联进来了。最后优化后的函数相当于:
|
||||
|
||||
```
|
||||
public int escapeTest(int age){
|
||||
return age<20 ? 1 : 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
图7中的Conditional是一个条件计算的三元表达式。你看到这个优化结果的时候,有没有感到震惊?是的。善用编译器的优化算法,就是会达到如此程度的优化。优化前后的代码的功能是一样的,但优化后的代码变得如此简洁、直奔最终计算目标,忽略中间我们自己编程所带来的冗余动作。
|
||||
|
||||
上面讲的都是没有逃逸出方法的情况。这种情况被叫做**NoEscape**。还有一种情况,是虽然逃逸出了方法,但没有逃逸出当前线程,也就是说不可能被其他线程所访问,这种逃逸叫做**ArgEscape**,也就是它仅仅是通过方法的参数逃逸的。最后一种情况就是**GlobalEscape**,也就是能够被其他线程所访问,因此没有办法优化。
|
||||
|
||||
对于ArgEscape的情况,虽然编译器不能做内存的栈上分配,但也可以做一定的优化,这就是**锁消除或者同步消除**。
|
||||
|
||||
我们知道,在并发场景下,锁对性能的影响非常之大。而很多线程安全的对象,比如一些集合对象,它们的内部都采用了锁来做线程的同步。如果我们可以确定这些对象没有逃逸出线程,那么就可以把这些同步逻辑优化掉,从而提高代码的性能。
|
||||
|
||||
好了,现在你已经理解了逃逸分析的用途。那么逃逸分析的算法是怎么实现的呢?这方面你可以去参考这篇经典论文《[Escape Analysis for Java](https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf)》。论文里的算法利用了一种数据结构,叫做**连接图**(Connection Graph)。简单地说,就是分析出了程序中对象之间的引用关系。
|
||||
|
||||
整个分析算法是建立在这样一种直觉认知上的:**基于一个连接图,也就是对象之间的引用关系图,如果A引用了B,而A能够被线程外的程序所访问(线程逃逸),那么也就意味着B也是线程逃逸的。**也就是说,**逃逸性是有传染能力的**。通过这样的分析,那些完全没被传染的对象,就是NoEscape的;只被ArgEscape对象传染的,那就也是ArgEscape的。原理说起来就是这么简单。
|
||||
|
||||
另外,我们前面所做的分析都是静态分析,也就是基于对代码所做的分析。对于一个对象来说,只要存在任何一个控制流,使得它逃逸了,那么编译器就会无情地把它标注为是逃逸对象,也就不能做优化了。但是,还会出现一种情况,就是有可能这个分支的执行频率特别少,大部分情况下该对象都是不逃逸的。
|
||||
|
||||
所以,Java的JIT编译器实际上又向前迈进了一步,实现了**部分逃逸分析**(Partial Escape Analysis)。它会根据运行时的统计信息,对不同的控制流分支做不同的处理。对于没有逃逸的分支,仍然去做优化。在这里,你能体会到,编译器为了一点点的性能提升,简直无所不用其极呀。
|
||||
|
||||
如果你还想对部分逃逸分析做进一步的研究,那你可以参考这篇论文:《[Partial Escape Analysis and Scalar Replacement for Java](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)》。
|
||||
|
||||
总结起来,逃逸分析能够让对象在栈上申请内存,做标量替换,从而大大减少对象处理的开销。这个算法对于对象生命周期比较短的场景,优化效果是非常明显的。
|
||||
|
||||
在讲内联和逃逸算法的时候,我们都发现,编译器会根据运行时的统计信息,通过推断来做一些优化,比如多态内联、部分逃逸分析。而这种优化模式,就叫做基于推理的优化。
|
||||
|
||||
## 基于推理的优化(Speculative Optimization)
|
||||
|
||||
我刚刚说过,一般情况下,编译器的优化工作是基于对代码所做的分析,也就是静态分析。而JIT编译还有一个优势,就是会根据运行时收集的统计信息来做优化。
|
||||
|
||||
我还是以Foo.atLeastTen()方法举例。在正常情况下,它生成的HIR是下面的样子,根据条件表达式的值(a<10),分别返回10和a。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/95/d8a140e575dac91yye441ea207e6ff95.jpg" alt="">
|
||||
|
||||
而如果我们在主程序里调用atLeastTen方法是采用下面示例代码中的逻辑,在启动JIT编译器时,已经循环了上万次。而在这上万次当中,只有9次i的值是小于10的,那么编译器就会根据运行时的统计信息判断,i的值大概率是大于10的。所以,它会仅针对大于10的分支做编译。
|
||||
|
||||
而如果遇到小于10的情况,则会导致逆优化。你会看到,IR中有一个绿色的FrameState节点,这个节点保存了栈帧的状态,在逆优化时会被用到。
|
||||
|
||||
```
|
||||
int i = 0;
|
||||
while(true){
|
||||
i++;
|
||||
foo.atLeastTen(i);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/7a/b0ba99f24e3df80748e98df1da93187a.jpg" alt="">
|
||||
|
||||
我们把主程序修改一下,再做一次实验。这次,我们传给Foo.atLeastTen方法的参数是i%10,也就是参数a的取值范围永远是在0到9之间。这一次,JIT编译器会反过来,仅针对a小于10的分支做编译,而对a大于10的情况做逆优化处理。
|
||||
|
||||
```
|
||||
int i = 0;
|
||||
while(true){
|
||||
i++;
|
||||
foo.atLeastTen(i%10);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/31/726aa67de052ec01f3907b4f5329ed31.jpg" alt="">
|
||||
|
||||
通过这个简单的例子,你对JIT编译器基于推理的优化情况就有了一个直观的了解。对于atLeastTen这个简单的方法,这样的优化似乎并不会带来太多的好处。但对于比较复杂的程序,那既可以节省编译时间,也能减少编译后的代码大小,收益是很高的。比如对于程序中仅用于输出Debug信息的分支,就根本不需要生成代码。
|
||||
|
||||
另外,这种基于推理的优化,还会带来其他额外的优化机会。比如对于逃逸分析而言,去掉了一些导致逃逸的分支以后,在剩下的分支中,对象并没有逃逸,所以也就可以做优化了!
|
||||
|
||||
总结起来,基于运行时的统计信息进行推理的优化,有时会比基于静态分析的AOT产生出性能更高的目标代码。所以,现代编译技术的实践,会强调“全生命周期”优化的概念。甚至即使是AOT产生的目标代码,仍然可以在运行期通过JIT做进一步优化。LLVM项目的发起人之一,Chris Lattner就曾写了一篇[论文](https://llvm.org/pubs/2003-09-30-LifelongOptimizationTR.pdf)来提倡这个理念,这也是LLVM的设计目标之一。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天我带你了解了Java JIT编译器中两个重要的优化算法,这两个优化算法都会大大提升程序的执行效率。另外,你还了解了在JIT编译中,基于运行时的信息做推理优化的技术。
|
||||
|
||||
在课程中,我不可能带你去分析所有的优化算法,但你可以根据课程的内容去举一反三,研究一下里面的其他算法。如果你对这些算法掌握得比较清晰,那你就可以胜任编译器的开发工作了。因为编译器开发的真正的工作量,都在中后端。
|
||||
|
||||
另外,熟悉这些重要的优化算法的原理,还有助于你写出性能更高的程序。比如说,你要让高频使用的代码易于内联;在使用对象的时候,让它的作用范围清晰一些,不要做无用的关联,尽量不要逃逸出方法和线程之外,等等。
|
||||
|
||||
本讲的思维导图我也放在下面了,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/cd/1189f3bb094dcaba28ba44be41bff7cd.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天的思考题,还是想请你设计一个场景,测试内联 vs 无内联,或者做逃逸优化 vs 无逃逸优化的性能差异。借此,你也可以熟悉一下如何控制JVM的优化选项。欢迎在留言区分享你在测试中的发现。
|
||||
|
||||
>
|
||||
<p>关闭内联优化: -XX:-Inlining。JDK8缺省是打开的。<br>
|
||||
显示内联优化详情:-XX:+PrintInlining。<br>
|
||||
关闭逃逸分析:-XX:-DoEscapeAnalysis。JDK8缺省是打开的。<br>
|
||||
显示逃逸分析详情:-XX:+PrintEscapeAnalysis。<br>
|
||||
关闭标量替换:-XX:-EliminateAllocations。JDK8缺省是打开的。<br>
|
||||
显示标量替换详情:-XX:+PrintEliminateAllocations。</p>
|
||||
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 多态内联:[Inlining of Virtual Methods](http://extras.springer.com/2000/978-3-540-67660-7/papers/1628/16280258.pdf)。
|
||||
1. 逃逸分析:[Escape Analysis for Java](https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf)。
|
||||
1. 部分逃逸分析:[Partial Escape Analysis and Scalar Replacement for Java](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)。
|
||||
@@ -0,0 +1,251 @@
|
||||
<audio id="audio" title="16 | Java JIT编译器(四):Graal的后端是如何工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/92/f987fe6382e6f6f8775c2506b2667492.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
前面两讲中,我介绍了Sea of Nodes类型的HIR,以及基于HIR的各种分析处理,这可以看做是编译器的中端。
|
||||
|
||||
可编译器最终还是要生成机器码的。那么,这个过程是怎么实现的呢?与硬件架构相关的LIR是什么样子的呢?指令选择是怎么做的呢?
|
||||
|
||||
这一讲,我就带你了解Graal编译器的后端功能,回答以上这些问题,破除你对后端处理过程的神秘感。
|
||||
|
||||
首先,我们来直观地了解一下后端处理的流程。
|
||||
|
||||
## 后端的处理流程
|
||||
|
||||
在[第14讲](https://time.geekbang.org/column/article/256914)中,我们在运行Java示例程序的时候(比如`atLeastTen()`方法),使用了“`-Dgraal.Dump=:5`”的选项,这个选项会dump出整个编译过程最详细的信息。
|
||||
|
||||
对于HIR的处理过程,程序会通过网络端口,dump到IdealGraphVisualizer里面。而后端的处理过程,缺省则会dump到工作目录下的一个“`graal_dumps`”子目录下。你可以用文本编辑器打开查看里面的信息。
|
||||
|
||||
```
|
||||
//至少返回10
|
||||
public int atLeastTen(int a){
|
||||
if (a < 10)
|
||||
return 10;
|
||||
else
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,你还可以再偷懒一下,使用一个图形工具[c1visualizer](http://lafo.ssw.uni-linz.ac.at/c1visualizer/)来查看。
|
||||
|
||||
补充:c1visualizer原本是用于查看Hopspot的C1编译器(也就是客户端编译器)的LIR的工具,这也就是说,Graal的LIR和C1的是一样的。另外,该工具不能用太高版本的JDK运行,我用的是JDK1.8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/0c/a5a9a3f2135b0cba3e5945158d72df0c.jpg" alt="">
|
||||
|
||||
在窗口的左侧,你能看到后端的处理流程。
|
||||
|
||||
- 首先是把HIR做最后一次排序(HIR Final Schedule),这个处理会把HIR节点分配到基本块,并且排序;
|
||||
- 第二是生成LIR,在这个过程中要做指令选择;
|
||||
- 第三,寄存器分配工作,Graal采用的算法是线性扫描(Linear Scan);
|
||||
- 第四,是基于LIR的一些优化工作,比如ControlFlowOptimizer等;
|
||||
- 最后一个步骤,是生成目标代码。
|
||||
|
||||
接下来,我们来认识一下这个LIR:它是怎样生成的,用什么数据结构保存的,以及都有什么特点。
|
||||
|
||||
## 认识LIR
|
||||
|
||||
在对HIR的处理过程中,前期(High Tier、Mid Tier)基本上都是与硬件无关。到了后期(Low Tier),你会看到IR中的一些节点逐步开始带有硬件的特征,比如上一讲中,计算AMD64地址的节点。而LIR就更加反映目标硬件的特征了,基本上可以跟机器码一对一地翻译。所以,从HIR生成LIR的过程,就要做指令选择。
|
||||
|
||||
我把与LIR相关的包和类整理成了类图,里面划分成了三个包,分别包含了与HIR、LIR和CFG有关的类。你可以重点看看它们之间的相互关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/a0/55fb0e71e492d921c2ea0df13b433aa0.jpg" alt="">
|
||||
|
||||
在HIR的最后的处理阶段,程序会通过一个Schedule过程,把HIR节点排序,并放到控制流图中,为生成LIR和目标代码做准备。我之前说过,HIR的一大好处,就是那些浮动节点,可以最大程度地免受控制流的约束。但在最后生成的目标代码中,我们还是要把每行指令归属到某个具体的基本块的。而且,基本块中的HIR节点是按照顺序排列的,在ScheduleResult中保存着这个顺序(blockToNodesMap中顺序保存了每个Block中的节点)。
|
||||
|
||||
**你要注意**,这里所说的Schedule,跟编译器后端的指令排序不是一回事儿。这里是把图变成线性的程序;而编译器后端的指令排序(也叫做Schedule),则是为了实现指令级并行的优化。
|
||||
|
||||
当然,把HIR节点划分到不同的基本块,优化程度是不同的。比如,与循环无关的代码,放在循环内部和外部都是可以的,但显然放在循环外部更好一些。把HIR节点排序的Schedule算法,复杂度比较高,所以使用了很多**启发式**的规则。刚才提到的把循环无关代码放在循环外面,就是一种启发式的规则。
|
||||
|
||||
图2中的ControlFlowGraph类和Block类构成了控制流图,控制流图和最后阶段的HIR是互相引用的。这样,你就可以知道HIR中的每个节点属于哪个基本块,也可以知道每个基本块中包含的HIR节点。
|
||||
|
||||
做完Schedule以后,接着就会生成LIR。**与声明式的HIR不同,LIR是命令式的,由一行行指令构成。**
|
||||
|
||||
图1显示的是Foo.atLeatTen方法对应的LIR。你会看到一个控制流图(CFG),里面有三个基本块。B0是B1和B2的前序基本块,B0中的最后一个语句是分支语句(基本块中,只有最后一个语句才可以是导致指令跳转的语句)。
|
||||
|
||||
LIR中的指令是放到基本块中的,LIR对象的LIRInstructions属性中,保存了每个基本块中的指令列表。
|
||||
|
||||
OK,那接下来,我们来看看LIR的指令都有哪些,它们都有什么特点。
|
||||
|
||||
LIRInstruction的子类,主要放在三个包中,你可以看看下面的类图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/87/2cda9c39ab9ff5a7b2f1b100c24b2687.jpg" alt="">
|
||||
|
||||
首先,在**org.graalvm.compiler.lir包**中,声明了一些与架构无关的指令,比如跳转指令、标签指令等。因为无论什么架构的CPU,一定都会有跳转指令,也一定有作为跳转目标的标签。
|
||||
|
||||
然后,在**org.graalvm.compiler.lir.amd64包**中,声明了几十个AMD64架构的指令,为了降低你的阅读负担,这里我只列出了有代表性的几个。这些指令是LIR代码中的主体。
|
||||
|
||||
最后,在**org.graalvm.compiler.hotspot.amd64包**中,也声明了几个指令。这几个指令是利用HotSpot虚拟机的功能实现的。比如,要获取某个类的定义的地址,只能由虚拟机提供。
|
||||
|
||||
好了,通过这样的一个分析,你应该对LIR有更加具体的认识了:**LIR中的指令,大多数是与架构相关的。**这样才适合运行后端的一些算法,比如指令选择、寄存器分配等。你也可以据此推测,其他编译器的LIR,差不多也是这个特点。
|
||||
|
||||
接下来,我们就来了解一下Graal编译器是如何生成LIR,并且在这个过程中,它是如何实现指令选择的。
|
||||
|
||||
## 生成LIR及指令选择
|
||||
|
||||
我们已经知道了,Graal在生成LIR的过程中,要进行指令选择。
|
||||
|
||||
我们先看一下Graal对一个简单的示例程序Foo.add1,是如何生成LIR的。
|
||||
|
||||
```
|
||||
public static int add1(int x, int y){
|
||||
return x + y + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个示例程序,在转LIR之前,它的HIR是下面这样。其中有两个加法节点,操作数包括了参数(ParameterNode)和常数(ConstantNode)两种类型。最后是一个Return节点。这个例子足够简单。实际上,它简单到只是一棵树,而不是图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/b2/68dc38a051c4481f1ab73c195be574b2.jpg" alt="">
|
||||
|
||||
你可以想一下,对于这么简单的一棵树,编译器要如何生成指令呢?
|
||||
|
||||
最简单的方法,是做一个语法制导的简单翻译。我们可以深度遍历这棵树,针对不同的节点,分别使用不同的规则来生成指令。比如:
|
||||
|
||||
- 在遇到参数节点的时候,我们要搞清楚它的存放位置。因为参数要么是在寄存器中,要么是在栈中,可以直接用于各种计算。
|
||||
- 遇到常数节点的时候,我们记下这个常数,用于在下一条指令中作为立即数使用。
|
||||
- 在遇到加法节点的时候,生成一个add指令,左右两棵子树的计算结果分别是其操作数。在处理到6号节点的时候,可以不用add指令,而是生成一个lea指令,这样可以直接把结果写入rax寄存器,作为返回值。这算是一个优化,因为可以减少一次从寄存器到寄存器的拷贝工作。
|
||||
- 遇到Return节点的时候,看看其子树的计算结果是否放在rax寄存器中。如果不是,那么就要生成一个mov指令,把返回值放入rax寄存器,然后再生成一条返回指令(ret)。通常,在返回之前,编译器还要做一些栈帧的处理工作,把栈指针还原。
|
||||
|
||||
对于这个简单的例子来说,按照这个翻译规则来生成代码,是完全没有问题的。你可以看下,Graal生成LIR,然后再基于LIR生成的目标代码的示例程序,它只有三行,足够精简和优化:
|
||||
|
||||
```
|
||||
add esi,edx #将参数1加到参数0上,结果保存在esi寄存器
|
||||
lea eax,[rsi+0xa] #将rsi加10,结果放入eax寄存器
|
||||
ret #返回
|
||||
|
||||
```
|
||||
|
||||
补充:<br>
|
||||
1.我去掉了一些额外的汇编代码,比如用于跟JVM握手,让JVM有机会做垃圾收集的代码。<br>
|
||||
2. lea指令原本是用于计算地址的。上面的指令的意思是把rsi寄存器的值作为地址,然后再偏移10个字节,把新的地址放到eax寄存器。<br>
|
||||
x86计算机支持间接寻址方式:**偏移量(基址,索引值,字节数)**<br>
|
||||
其地址是:**基址 + 索引值*字节数 + 偏移量**<br>
|
||||
所以,你可以利用这个特点,计算出`a+b*c+d`的值。但c(也就是字节数)只能取1、2、4、8。就算让c取1,那也能完成`a+b+c`的计算。并且,它还可以在另一个操作数里指定把结果写到哪个寄存器,而不像add指令,只能从一个操作数加到另一个操作数上。这些优点,使得x86汇编中经常使用lea指令做加法计算。
|
||||
|
||||
Graal编译器实际上大致也是这么做的。
|
||||
|
||||
首先,它通过Schedule的步骤,把HIR的节点排序,并放入基本块。对于这个简单的程序,只有一个基本块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/4c/592dcea33424a5547bdfafd287cd604c.jpg" alt="">
|
||||
|
||||
接着,编译器会对基本块中的节点做遍历(参考:NodeLIRBuilder.java中的[代码](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/gen/NodeLIRBuilder.java#L363))。针对每个节点转换(Lower)成LIR。
|
||||
|
||||
- 把参数节点转换成了MoveFromRegOp指令,在示例程序里,其实这是冗余的,因为可以直接把存放参数的两个寄存器,用于加法计算;
|
||||
- 把第一个加法节点转换成了CommutativeTwoOp指令;
|
||||
- 把第二个加法节点转换成了LeaOp指令,并且把常数节点变成了LeaOp指令的操作数;
|
||||
- Return节点生成了两条指令,一条是把加法运算的值放到rax寄存器,作为返回值,这条我们知道是冗余的,所以就要看看后面的优化算法如何消除这个冗余;第二条是返回指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/39/7f84ef267efbb8f3493f386fa5a83939.jpg" alt="">
|
||||
|
||||
一开始生成的LIR,使用的寄存器都是虚拟的寄存器名称,用v1、v2、v3这些来表示。等把这些虚拟的寄存器对应到具体的物理寄存器以后,就可以消除掉刚才提到的冗余代码了。
|
||||
|
||||
我们在c1visualizer中检查一下优化过程,可以发现这是在LinearScanAssignLocationsPhase做的优化。加法指令中对参数1和参数2的引用,变成了对物理寄存器的引用,从而优化掉了两条指令。lea指令中的返回值,也直接赋给了rax寄存器。这样呢,也就省掉了把计算结果mov到rax的指令。这样优化后的LIR,基本上已经等价于目标代码了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/06/963f345b185734fd59270d67cec7e806.jpg" alt="">
|
||||
|
||||
好了,通过这样一个分析,你应该理解了从HIR生成LIR的过程。但是还有个问题,这中间似乎也没有做什么指令选择呀?唯一的一处,就是把加法操作优化成了lea指令。而这个也比较简单,基于单独的Add节点就能做出这个优化选择。**那么,更复杂的模式匹配是怎么做的呢?**
|
||||
|
||||
不要着急,我们接下来就看看Graal是如何实现复杂一点的指令选择的。这一次,我们用了另一个示例程序:Foo.addMemory方法。它把一个类成员变量m和参数a相加。
|
||||
|
||||
```
|
||||
public class Foo{
|
||||
static int m = 3;
|
||||
public static int addMemory(int a){
|
||||
return m + a;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这跟add1方法有所不同,因为它要使用一个成员变量,所以一定要访问内存。而add1方法的所有操作,都是在寄存器里完成的,是“空中作业”,根本不在内存里落地。
|
||||
|
||||
我们来看一下这个示例程序对应的HIR。其中一个黄色节点“Read#Foo.m”,是读取内存的节点,也就是读取成员变量m的值。而这又需要通过AMD64Address节点来计算m的地址。由于m是个静态成员,所以它的地址要通过类的地址加上一定的偏移量来计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/1f/64d1f9933ace9171331100a9b570861f.jpg" alt="">
|
||||
|
||||
这里有一个小的知识点,我在第14讲中也提过:对内存操作的节点(如图中的ReadNode),是要加入控制流中的。因为内存里的值,会由于别的操作而改变。如果你把它变成浮动节点,就有可能破坏对内存读写的顺序,从而出现错误。
|
||||
|
||||
回到主题,我们来看看怎么为addMemory生成LIR。
|
||||
|
||||
如果还是像处理add1方法一样,那么你就会这么做:
|
||||
|
||||
- **计算m变量的地址,并放入一个寄存器;**
|
||||
- **基于这个地址,取出m的值,放入另一个寄存器;**
|
||||
- **把m的值和参数a做加法。**
|
||||
|
||||
不过这样做,至少要生成3条指令。
|
||||
|
||||
在[第8讲](https://time.geekbang.org/column/article/249261)中我曾经讲过,像AMD64这样使用复杂指令集(CICS)的架构,具有强大的地址表达能力,并且可以在做算术运算的时候,直接使用内存。所以上面的三条指令,其实能够缩减成一条指令。
|
||||
|
||||
这就需要编译器把刚才这种基于内存访问做加法的模式识别出来,以便生成优化的LIR,进而生成优化的目标代码。这也是指令选择算法要完成的任务。可是,**如何识别这种模式呢?**
|
||||
|
||||
跟踪Graal的执行,你会发现HIR在生成LIR之前,有一个对基本块中的节点做[模式匹配](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/LIRGenerationPhase.java#L72)的操作,进而又调用匹配复杂表达式([matchComplexExpressions](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/gen/NodeLIRBuilder.java#L430))。在这里,编译器会把节点跟一个个匹配规则([MatchStatement](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/match/MatchStatement.java))做匹配。**注意**,匹配的时候是逆序做的,相当于从树根开始遍历。
|
||||
|
||||
在匹配加法节点的时候,Graal匹配上了一个MatchStatement,这个规则的名字叫“addMemory”,是专门针对涉及内存操作的加法运算提供的一个匹配规则。这个MatchStatement包含了一个匹配模式(MatchPattern),该模式的要求是:
|
||||
|
||||
- 节点类型是AddNode;
|
||||
- 第一个输入(也就是子节点)是一个值节点(value);
|
||||
- 第二个输入是一个ReadNode,而且必须只有一个使用者(singleUser=true)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/3d/78c06c07d6524fd01ac08f7978da863d.jpg" alt="">
|
||||
|
||||
这个MatchStatement是在[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)中用注解生成的。利用这样的一个匹配规则,就能够匹配示例程序中的Add节点。
|
||||
|
||||
匹配上以后,Graal会把AddNode和ReadNode做上特殊标记,这样在生成LIR的时候,就会按照新的生成规则。生成的LIR如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/98/f77fa43149d505951ac29378f25b7998.jpg" alt="">
|
||||
|
||||
你可以发现,优化后,编译器把取参数a的指令省略掉了,直接使用了传入参数a的寄存器rsi:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/5f/f66ea988f8b69b46a466e944acb9015f.jpg" alt="">
|
||||
|
||||
最后生成的目标代码如下:
|
||||
|
||||
```
|
||||
movabs rax,0x797b00690 #把Foo类的地址放入rax寄存器
|
||||
add esi,DWORD PTR [rax+0x68] #偏移0x68后,是m的地址。做加法
|
||||
mov eax,esi #设置返回值
|
||||
ret #返回
|
||||
|
||||
```
|
||||
|
||||
到目前为止,你已经了解了Graal是如何匹配一个模式,并选择优化的指令的了。
|
||||
|
||||
你可以看看[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)类,它的里面定义了不少这种匹配规则。通过阅读和理解这些规则,你就会对为什么要做指令选择有更加具体的理解了。
|
||||
|
||||
Graal的指令选择算法算是比较简单的。在HotSpot的C2编译器中,指令选择采用的是BURS(Bottom-Up Rewrite System,自底向上的重写系统)。这个算法会更加复杂一点,消耗的时间更长,但优化效果更好一些。
|
||||
|
||||
这里我补充一个分享,我曾经请教过ARM公司的研发人员,他们目前认为Graal对针对AArch64的指令选择是比较初级的,你可以参考这个[幻灯片](https://static.linaro.org/connect/san19/presentations/san19-514.pdf)。所以,他们也正在帮助Graal做改进。
|
||||
|
||||
## 后端的其他功能
|
||||
|
||||
出于突出特色功能的目的,这一讲我着重讲了LIR的特点和指令选择算法。不过在考察编译器的后端的时候,我们通常还要注意一些其他功能,比如寄存器分配算法、指令排序,等等。我这里就把Graal在这些功能上的实现特点,给你简单地介绍一下,你如果有兴趣的话,可以根据我的提示去做深入了解:
|
||||
|
||||
- **寄存器分配**:Graal采用了线性扫描(Linear Scan)算法。这个算法的特点是速度比较快,但优化效果不如图染色算法。在HotSpot的C2中采用的是后者。
|
||||
- **指令排序**:Graal没有为了实现指令级并行而去做指令排序。这里一个主要原因,是现在的很多CPU都已经支持乱序(out-of-order)执行,再做重排序的收益不大。
|
||||
- **窥孔优化**:Graal在生成LIR的时候,会做一点窥孔优化(AMD64NodeLIRBuilder类的[peephole](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeLIRBuilder.java#L62)方法)。不过它的优化功能有限,只实现了针对除法和求余数计算的一点优化。
|
||||
- **从LIR生成目标代码**:由于LIR已经跟目标代码很接近了,所以这个翻译过程已经比较简单,没有太难的算法了,需要的只是了解和熟悉汇编代码和调用约定。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我带你对Graal的后端做了一个直观的认识,让你的后端知识有了第一个真实世界中编译器的参考系。
|
||||
|
||||
第一,把LIR从比较抽象的概念中落地。你现在可以确切地知道哪些指令是必须跟架构相关的,而哪些指令可以跟架构无关。
|
||||
|
||||
第二,把指令选择算法落地。虽然Graal的指令选择算法并不复杂,但这毕竟提供了一个可以借鉴的思路,是你认知的一个阶梯。如果你仔细阅读代码,你还可以具象地了解到,符合哪些模式的表达式,是可以从指令选择中受益的。这又是一个理论印证实践的点。
|
||||
|
||||
我把这讲的思维导图也放在了下面,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/8f/d9003105d0d392ed71a30fe5f64ed48f.jpg" alt="">
|
||||
|
||||
同时,这一讲之后,我们对Java编译器的探讨也就告一段落了。但是,我希望你对它的研究不要停止。
|
||||
|
||||
我们讨论的两个编译器(javac和Graal)中的很多知识点,你只要稍微深入挖掘一下,就可以得出不错的成果了。比如,我看到有国外的硕士学生研究了一下HotSpot,就可以发表不错的[论文](http://ssw.jku.at/Research/Papers/Schwaighofer09Master/schwaighofer09master.pdf)。如果你是在校大学生,我相信你也可以通过顺着这门课程提供的信息做一些研究,从而得到不错的成果。如果是已经工作的同学,我们可以在极客时间的社群(比如留言区和部落)里保持对Java编译技术的讨论,也一定会对于你的工作有所助益。
|
||||
|
||||
## 一课一思
|
||||
|
||||
请你阅读[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)中的匹配规则,自己设计另一个例子,能够测试出指令选择的效果。如果降低一下工作量的话,你可以把它里面的某些规则解读一下,在留言区发表你的见解。
|
||||
|
||||
好,就到这里。感谢你的阅读,欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
|
||||
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/17 | Python编译器(一):如何用工具生成编译器?.md
Normal file
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/17 | Python编译器(一):如何用工具生成编译器?.md
Normal file
@@ -0,0 +1,298 @@
|
||||
<audio id="audio" title="17 | Python编译器(一):如何用工具生成编译器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/62/5fd73ba3ff552e7fb58762c4a4448262.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
最近几年,Python在中国变得越来越流行,我想可能有几个推动力:第一个是因为人工智能热的兴起,用Python可以很方便地使用流行的AI框架,比如TensorFlow;第二个重要的因素是编程教育,特别是很多面对青少年的编程课程,都是采用的Python语言。
|
||||
|
||||
不过,Python之所以变得如此受欢迎,虽然有外在的机遇,但也得益于它内在的一些优点。比如说:
|
||||
|
||||
- Python的语法比较简单,容易掌握,它强调一件事情只能用一种方法去做。对于老一代的程序员来说,Python就像久远的BASIC语言,很适合作为初学者的第一门计算机语言去学习,去打开计算机编程这个充满魅力的世界。
|
||||
- Python具备丰富的现代语言特性,实现方式又比较简洁。比如,它既支持面向对象特性,也支持函数式编程特性,等等。这对于学习编程很有好处,能够带给初学者比较准确的编程概念。
|
||||
- 我个人比较欣赏Python的一个原因,是它能够充分利用开源世界的一些二进制的库,比如说,如果你想研究计算机视觉和多媒体,可以用它调用OpenCV和FFmpeg。Python跟AI框架的整合也是同样的道理,这也是Python经常用于系统运维领域的原因,因为它很容易调用操作系统的一些库。
|
||||
- 最后,Python还有便于扩展的优势。如果你觉得Python有哪方面能力的不足,你也可以用C语言来写一些扩展。而且,你不仅仅可以扩展出几个函数,你还能扩展出新的类型,并在Python里使用这些新类型。比如,Python的数学计算库是NumPy,它的核心代码是用C语言编写的,性能很高。
|
||||
|
||||
看到这里,你自然会好奇,这么一门简洁有力的语言,是如何实现的呢?吉多·范罗苏姆(Python初始设计者)在编写Python的编译器的时候,脑子里是怎么想的呢?
|
||||
|
||||
从这一讲开始,我们就进入到Python语言的编译器内部,去看看它作为一门动态、解释执行语言的代表,是如何做词法分析、语法分析和语义分析的,又是如何解释执行的,以及它的运行时有什么设计特点,让它可以具备这些优势。你在这个过程中,也会对编译技术的应用场景了解得更加全面。这也正是我要花3讲的时间,带领你来解析Python编译器的主要原因。
|
||||
|
||||
今天这一讲,我们重点来研究Python的词法分析和语法分析功能,一起来看看它在这两个处理阶段都有什么特点。你会学到一种新的语法分析实现思路,还能够学到CST跟AST的区别。
|
||||
|
||||
好了,让我们开始吧。
|
||||
|
||||
## 编译源代码,并跟踪调试
|
||||
|
||||
首先,你可以从[python.org网站](https://www.python.org/)下载[3.8.1版本的源代码](https://www.python.org/ftp/python/3.8.1/Python-3.8.1.tgz)。解压后你可以先自己浏览一下,看看能不能找到它的词法分析器、语法分析器、符号表处理程序、解释器等功能的代码。
|
||||
|
||||
Python源代码划分了多个子目录,每个子目录的内容整理如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/28/b4dffdcfc258350fa6ce81a2dcae7128.jpg" alt="">
|
||||
|
||||
**首先,你会发现Python编译器是用C语言编写的。**这跟Java、Go的编译器不同,Java和Go语言的编译器是支持自举的编译器,也就是这两门语言的编译器是用这两门语言自身实现的。
|
||||
|
||||
实际上,用C语言实现的Python编译器叫做**CPython**,是Python的几个编译器之一。它的标准库也是由C语言和Python混合编写的。**我们课程中所讨论的就是CPython,它是Python语言的参考实现,也是macOS和Linux缺省安装的版本。**
|
||||
|
||||
不过,Python也有一个编译器是用Python本身编写的,这个编译器是PyPy。它的图标是一条咬着自己尾巴的衔尾蛇,表明这个编译器是自举的。除此之外,还有基于JVM的Jython,这个版本的优势是能够借助成熟的JVM生态,比如可以不用自己写垃圾收集器,还能够调用丰富的Java类库。如果你觉得理解C语言的代码比较困难,你也可以去看看这两个版本的实现。
|
||||
|
||||
在Python的“[开发者指南](https://devguide.python.org/)”网站上,有不少关于Python内部实现机制的技术资料。**请注意**,这里的开发者,指的是有兴趣参与Python语言开发的程序员,而不是Python语言的使用者。这就是像Python这种开源项目的优点,它欢迎热爱Python的程序员来修改和增强Python语言,甚至你还可以增加一些自己喜欢的语言特性。
|
||||
|
||||
根据开发者指南的指引,你可以编译一下Python的源代码。注意,你要用**调试模式**来编译,因为接下来我们要跟踪Python编译器的运行过程。这就要使用**调试工具GDB**。
|
||||
|
||||
GDB是GNU的调试工具,做C语言开发的人一般都会使用这个工具。它支持通过命令行调试程序,包括设置断点、单步跟踪、观察变量的值等,这跟你在IDE里调试程序的操作很相似。
|
||||
|
||||
开发者指南中有如何用调试模式编译Python,并如何跟GDB配合使用的信息。实际上,GDB现在可以用Python来编写扩展,从而给我们带来更多的便利。比如,我们在调试Python编译器的时候,遇到Python对象的指针(PyObject*),就可以用更友好的方式来显示Python对象的信息。
|
||||
|
||||
好了,接下来我们就通过跟踪Python编译器执行过程,看看它在编译过程中都涉及了哪些主要的程序模块。
|
||||
|
||||
在tokenizer.c的tok_get()函数中打一个断点,通过GDB观察Python的运行,你会发现下面的调用顺序(用bt命令打印输出后整理的结果):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/c4/0eedee1683034d95e4380e2b4769dac4.jpg" alt="">
|
||||
|
||||
这个过程是运行Python并执行到词法分析环节,你可以看到完整的程序执行路径:
|
||||
|
||||
1. 首先是python.c,这个文件很短,只是提供了一个main()函数。你运行python命令的时候,就会先进入这里。
|
||||
1. 接着进入Modules/main.c文件,这个文件里提供了运行环境的初始化等功能,它能执行一个python文件,也能启动REPL提供一个交互式界面。
|
||||
1. 之后是Python/pythonrun.c文件,这是Python的解释器,它调用词法分析器、语法分析器和字节码生成功能,最后解释执行。
|
||||
1. 再之后来到Parser目录的parsetok.c文件,这个文件会调度词法分析器和语法分析器,完成语法分析过程,最后生成AST。
|
||||
1. 最后是toknizer.c,它是词法分析器的具体实现。
|
||||
|
||||
拓展:REPL是Read-Evaluate-Print-Loop的缩写,也就是通过一个交互界面接受输入并回显结果。
|
||||
|
||||
通过上述的跟踪过程,我们就进入了Python的词法分析功能。下面我们就来看一下它是怎么实现的,再一次对词法分析的原理做一下印证。
|
||||
|
||||
## Python的词法分析功能
|
||||
|
||||
首先,你可以看一下tokenizer.c的tok_get()函数。你一阅读源代码,就会发现,这是我们很熟悉的一个结构,它也是通过有限自动机把字符串变成Token。
|
||||
|
||||
你还可以用另一种更直接的方法来查看Python词法分析的结果。
|
||||
|
||||
```
|
||||
./python.exe -m tokenize -e foo.py
|
||||
|
||||
```
|
||||
|
||||
补充:其中的python.exe指的是Python的可执行文件,如果是在Linux系统,可执行文件是python。
|
||||
|
||||
运行上面的命令会输出所解析出的Token:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/66/c3934c23760c13884c98d979fc250c66.jpg" alt="">
|
||||
|
||||
其中的第二列是Token的类型,第三列是Token对应的字符串。各种Token类型的定义,你可以在Grammar/Tokens文件中找到。
|
||||
|
||||
我们曾在研究[Java编译器](https://time.geekbang.org/column/article/251937)的时候,探讨过如何解决关键字和标识符的词法规则冲突的问题。**那么Python是怎么实现的呢?**
|
||||
|
||||
原来,Python在词法分析阶段根本没有区分这两者,只是都是作为“NAME”类型的Token来对待。
|
||||
|
||||
补充:Python里面有两个词法分析器,一个是用C语言实现的(tokenizer.c),一个是用Python实现的(tokenizer.py)。C语言版本的词法分析器由编译器使用,性能更高。
|
||||
|
||||
所以,Python的词法分析功能也比较常规。其实你会发现,每个编译器的词法分析功能都大同小异,你完全可以借鉴一个比较成熟的实现。Python跟Java的编译器稍微不同的一点,就是没有区分关键字和标识符。
|
||||
|
||||
接下来,我们来关注下这节课的重点内容:语法分析功能。
|
||||
|
||||
## Python的语法分析功能
|
||||
|
||||
在GDB中继续跟踪执行过程,你会在parser.c中找到语法分析的相关逻辑:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/f7/0d96372c3f18fe45cb6f0bbc12fc77f7.jpg" alt="">
|
||||
|
||||
**那么,Python的语法分析有什么特点呢?它采用的是什么算法呢?是自顶向下的算法,还是自底向上的算法?**
|
||||
|
||||
首先,我们到Grammar目录,去看一下Grammar文件。这是一个用EBNF语法编写的Python语法规则文件,下面是从中节选的几句,你看是不是很容易读懂呢?
|
||||
|
||||
```
|
||||
//声明函数
|
||||
funcdef: 'def' NAME parameters ['->' test] ':' [TYPE_COMMENT] func_body_suite
|
||||
//语句
|
||||
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
|
||||
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
|
||||
import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
|
||||
|
||||
```
|
||||
|
||||
通过阅读规则文件,你可以精确地了解Python的语法规则。
|
||||
|
||||
**这个规则文件是给谁用的呢**?实际上Python的编译器本身并不使用它,它是给一个**pgen**的工具程序([Parser/pgen](https://github.com/python/cpython/blob/3.9/Parser/pgen/pgen.py))使用的。这个程序能够基于语法规则生成**解析表**(Parse Table),供语法分析程序使用。有很多工具能帮助你生成语法解析器,包括yacc(GNU版本是bison)、ANTLR等。
|
||||
|
||||
有了pgen这个工具,你就可以通过修改规则文件来修改Python语言的语法,比如,你可以把函数声明中的关键字“def”换成“function”,这样你就可以用新的语法来声明函数。
|
||||
|
||||
pgen能给你生成新的语法解析器。parser.c的注释中讲解了它的工作原理。它是把EBNF转化成一个NFA,然后再把这个NFA转换成DFA。基于这个DFA,在读取Token的时候,编译器就知道如何做状态迁移,并生成解析树。
|
||||
|
||||
这个过程你听上去是不是有点熟悉?实际上,我们在[第2讲](https://time.geekbang.org/column/article/243685)讨论正则表达式工具的时候,就曾经把正则表达式转化成了NFA和DFA。基于这个技术,我们既可以做词法解析,也可以做语法解析。
|
||||
|
||||
实际上,Python用的是LL(1)算法。我们来回忆一下LL(1)算法的[特点](https://time.geekbang.org/column/article/244906):**针对每条语法规则,最多预读一个Token,编译器就可以知道该选择哪个产生式。**这其实就是一个DFA,从一条语法规则,根据读入的Token,迁移到下一条语法规则。
|
||||
|
||||
我们通过一个例子来看一下Python的语法分析特点,这里采用的是我们熟悉的一个语法规则:
|
||||
|
||||
```
|
||||
add: mul ('+' mul)*
|
||||
mul: pri ('*' pri)*
|
||||
pri: IntLiteral | '(' add ')'
|
||||
|
||||
```
|
||||
|
||||
我把这些语法规则对应的DFA画了出来。你会看到,它跟采用递归下降算法的思路是一样的,只不过换了种表达方式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/06/def9c3178ca00a1ebc5471b4a74acb06.jpg" alt="" title="add: mul ('+' mul)*对应的DFA">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/0d/f6b24be02983c724b945e8a1674yya0d.jpg" alt="" title="mul: pri ('*' pri)*对应的DFA">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/1d/d0a8af5d54571f07b6df4eb965031e1d.jpg" alt="" title="pri: IntLiteral | '(' add ')'对应的DFA">
|
||||
|
||||
不过,跟手写的递归下降算法为解析每个语法规则写一个函数不同,parser.c用了一个通用的函数去解析所有的语法规则,它所依据的就是为每个规则所生成的DFA。
|
||||
|
||||
主要的实现逻辑是在parser.c的PyParser_AddToken()函数里,你可以跟踪它的实现过程。为了便于你理解,我模仿Python编译器,用上面的文法规则解析了一下“`2+3*4+5`”,并把整个解析过程画成图。
|
||||
|
||||
在解析的过程,我用了一个栈作为一个工作区,来保存当前解析过程中使用的DFA。
|
||||
|
||||
**第1步,匹配add规则。**把add对应的DFA压到栈里,此时该DFA处于状态0。这时候预读了一个Token,是字面量2。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/54/2a9318064fa07108f5484235fb824454.jpg" alt="">
|
||||
|
||||
**第2步,根据add的DFA,走mul-1这条边,去匹配mul规则。**这时把mul对应的DFA入栈。在示意图中,栈是从上往下延伸的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/59/6d3222404b30yy08d29943a321e6ac59.jpg" alt="">
|
||||
|
||||
**第3步,根据mul的DFA,走pri-1这条边,去匹配pri规则。**这时把pri对应的DFA入栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/77/78fca50b5a414fbf74f6229aa4c0a877.jpg" alt="">
|
||||
|
||||
**第4步,根据pri的DFA,因为预读的Token是字面量2,所以移进这个字面量,并迁移到状态3。同时,为字面量2建立解析树的节点。**这个时候,又会预读下一个Token,`'+'`号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/ff/4a2daba678f9f8fe476e94403267d2ff.jpg" alt="">
|
||||
|
||||
**第5步,从栈里弹出pri的DFA,并建立pri节点。**因为成功匹配了一个pri,所以mul的DFA迁移到状态1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/41/5a204c08609187584d88894b5388d741.jpg" alt="">
|
||||
|
||||
**第6步,因为目前预读的Token是`'+'`号,所以mul规则匹配完毕,把它的DFA也从栈里弹出**。而add对应的DFA也迁移到了状态1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/a6/a6a49e70fa5216f0cc516981978a5fa6.jpg" alt="">
|
||||
|
||||
**第7步,移进`'+'`号,把add的DFA迁移到状态2,预读了下一个Token:字面量3**。这个Token是在mul的First集合中的,所以就走mul-2边,去匹配一个mul。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/3c/8f446e247ecab4e9224f59130de4013c.jpg" alt="">
|
||||
|
||||
按照这个思路继续做解析,直到最后,可以得到完整的解析树:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg" alt="">
|
||||
|
||||
总结起来,Python编译器采用了一个通用的语法分析程序,以一个栈作为辅助的数据结构,来完成各个语法规则的解析工作。当前正在解析的语法规则对应的DFA,位于栈顶。一旦当前的语法规则匹配完毕,那语法分析程序就可以把这个DFA弹出,退回到上一级的语法规则。
|
||||
|
||||
所以说,语法解析器生成工具,会基于不同的语法规则来生成不同的DFA,但语法解析程序是不变的。这样,你随意修改语法规则,都能够成功解析。
|
||||
|
||||
上面我直观地给你解读了一下解析过程。你可以用GDB来跟踪一下PyParser_AddToken()函数,从而了解得更具体。你在这个函数里,还能够看到像下面这样的语句,这是对外输出调试信息。
|
||||
|
||||
```
|
||||
D(printf(" Push '%s'\n", d1->d_name)); //把某DFA入栈
|
||||
|
||||
```
|
||||
|
||||
你还可以用“-d”参数运行python,然后在REPL里输入程序,这样它就能打印出这些调试信息,包括什么时候把DFA入栈、什么时候出栈,等等。我截取了一部分输出信息,你可以看一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/f1/d6e523e506687846f7d13a1eaff211f1.jpg" alt="">
|
||||
|
||||
在Python的语法规则里,arith_expr指的是加减法的表达式,term指的是乘除法的表达式,atom指的是基础表达式。这套词汇也经常被用于语法规则中,你可以熟悉起来。
|
||||
|
||||
好了,现在你已经知道了语法解析的过程。不过你可能注意到了,上面的语法解析过程形成的结果,我没有叫做是AST,而是叫做**解析树**(Parse Tree)。看到这里,你可能会产生疑问:**解析源代码不就会产生AST吗?怎么这里是生成一个叫做解析树的东西?什么是解析树,它跟AST有啥区别?**别着急,下面我就来为你揭晓答案。
|
||||
|
||||
## 解析树和AST的区别
|
||||
|
||||
解析树又可以叫做**CST**(Concrete Syntax Tree,具体语法树),与AST(抽象语法树)是相对的:一个具体,一个抽象。
|
||||
|
||||
它俩的区别在于:**CST精确地反映了语法规则的推导过程,而AST则更准确地表达了程序的结构。如果说CST是“形似”,那么AST就是“神似”。**
|
||||
|
||||
你可以看看在前面的这个例子中,所形成的CST的特点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg" alt="">
|
||||
|
||||
首先,加法是个二元运算符,但在这里add节点下面对应了两个加法运算符,跟原来加法的语义不符。第二,很多节点都只有一个父节点,这个其实可以省略,让树结构更简洁。
|
||||
|
||||
所以,我们期待的AST其实是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/ce/7aa1ea17abafdba3f0cd68f6d14b6ace.jpg" alt="">
|
||||
|
||||
这就是CST和AST的区别。
|
||||
|
||||
理解了这个知识点以后,我们拿Python实际的CST和AST来做一下对比。在Python的命令行中,输入下面的命令:
|
||||
|
||||
```
|
||||
>>> from pprint import pprint
|
||||
>>> import parser
|
||||
>>> cst = parser.expr('2+3+4') //对加法表达式做解析
|
||||
>>> pprint(parser.st2list(cst)) //以美观的方式打印输出CST
|
||||
|
||||
```
|
||||
|
||||
你会得到这样的输出结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/6f/508d14e74211a1a0bbf6e8c0b282b76f.jpg" alt="">
|
||||
|
||||
这是用缩进的方式显示了CST的树状结构,其中的数字是符号和Token的编号。你可以从Token的字典(dict)里把它查出来,从而以更加直观的方式显示CST。
|
||||
|
||||
我们借助一个lex函数来做美化的工作。现在再显示一下CST,就更直观了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/fe/104aa190ab9a4c118d1fb5a73187fafe.jpg" alt="">
|
||||
|
||||
**那么,Python把CST转换成AST,会是什么样子呢?**
|
||||
|
||||
你可以在命令行敲入下面的代码,来显示AST。它虽然是以文本格式显示的,但你能发现它是一个树状结构。这个树状结构就很简洁:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/bd/2232eb547e255f88e5d867ec147867bd.jpg" alt="">
|
||||
|
||||
如果你嫌这样不够直观,还可以用另一个工具“instaviz”,在命令行窗口用pip命令安装instaviz模块,以图形化的方式更直观地来显示AST。instaviz是“Instant Visualization”(立即可视化)的意思,它能够图形化显示AST。
|
||||
|
||||
```
|
||||
$ pip install instaviz
|
||||
|
||||
```
|
||||
|
||||
然后启动Python,并敲入下面的代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/fb/41808237c525885d28534fc9514329fb.jpg" alt="">
|
||||
|
||||
instaviz会启动一个Web服务器,你可以在浏览器里通过http://localhost:8080来访问它,里面有图形化的AST。你可以看到,这个AST比起CST来,确实简洁太多了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/bf/e1700c27cec63f492f5cdb68809d42bf.jpg" alt="">
|
||||
|
||||
点击代表“`2+3*4+5`”表达式的节点,你可以看到这棵子树的各个节点的属性信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/c5/28ab9da7c9c4cd005d13fca4d44e69c5.jpg" alt="">
|
||||
|
||||
总结起来,在编译器里,我们经常需要把源代码转变成CST,然后再转换成AST。生成CST是为了方便编译器的解析过程。而转换成AST后,会让树结构更加精简,并且在语义上更符合语言原本的定义。
|
||||
|
||||
**那么,Python是如何把CST转换成AST的呢?**这个过程分为两步。
|
||||
|
||||
**首先,Python采用了一种叫做ASDL的语言,来定义了AST的结构。**[ASDL](https://www.cs.princeton.edu/research/techreps/TR-554-97)是“抽象语法定义语言(Abstract Syntax Definition Language)”的缩写,它可以用于描述编译器中的IR以及其他树状的数据结构。你可能不熟悉ASDL,但可能了解XML和JSON的Schema,你可以通过Schema来定义XML和JSON的合法的结构。另外还有DTD、EBNF等,它们的作用都是差不多的。
|
||||
|
||||
这个定义文件是Parser/Python.asdl。CPython编译器中包含了两个程序(Parser/asdl.py和Parser/asdl_c.py)来解析ASDL文件,并生成AST的数据结构。最后的结果在Include/Python-ast.h文件中。
|
||||
|
||||
到这里,你可能会有疑问:**这个ASDL文件及解析程序不就是生成了AST的数据结构吗?为什么不手工设计这些数据结构呢?有必要采用一种专门的DSL来做这件事情吗?**
|
||||
|
||||
确实如此。Java语言的AST,只是采用了手工设计的数据结构,也没有专门用一个DSL来生成。
|
||||
|
||||
但Python这样做确实有它的好处。上一讲我们说过,Python的编译器有多种语言的实现,因此基于统一的ASDL文件,我们就可以精准地生成不同语言下的AST的数据结构。
|
||||
|
||||
在有了AST的数据结构以后,**第二步,是把CST转换成AST,这个工作是在Python/ast.c中实现的,入口函数是PyAST_FromNode()。**这个算法是手写的,并没有办法自动生成。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们开启了对Python编译器的探究。我想给你强调下面几个关键要点:
|
||||
|
||||
- **非自举**。CPython的编译器是用C语言编写的,而不是用Python语言本身。编译器和核心库采用C语言会让它性能更高,并且更容易与各种二进制工具集成。
|
||||
- **善用GDB**。使用GDB可以跟踪CPython编译器的执行过程,加深对它的内部机制的理解,加快研究的速度。
|
||||
- **编译器生成工具pgen**。pgen能够根据语法规则生成解析表,让修改语法的过程变得更加容易。
|
||||
- **基于DFA的语法解析过程**。基于pgen生成的解析表,通过DFA驱动完成语法解析过程,整个执行过程跟递归下降算法的原理相同,但只需要一个通用的解析程序即可。
|
||||
- **从CST到AST**。语法分析首先生成CST,接着生成AST。CST准确反映了语法推导的过程,但会比较啰嗦,并且可能不符合语义。AST同样反映了程序的结构,但更简洁,并且支持准确的语义。
|
||||
|
||||
本讲的思维导图我也放在这里了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/34/1e11c1bb92669152c725a35c919b4534.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
这一讲我们提到,Python的词法分析器没有区分标识符和关键字,但这样为什么没有影响到Python的语法分析的功能呢?你可以结合语法规则文件和对语法解析过程的理解,谈谈你的看法。如果你能在源代码里找到确定的答案,那就更好了!
|
||||
|
||||
欢迎你在留言区中分享你的见解,也欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
|
||||
|
||||
## 参考资料
|
||||
|
||||
GDB的安装和配置:参考[这篇文章](https://github.com/RichardGong/CompilersInPractice/edit/master/python/GDB.md)。
|
||||
296
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/18 | Python编译器(二):从AST到字节码.md
Normal file
296
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/18 | Python编译器(二):从AST到字节码.md
Normal file
@@ -0,0 +1,296 @@
|
||||
<audio id="audio" title="18 | Python编译器(二):从AST到字节码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/75/61281ce750eebc407f758251a7750875.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
今天这一讲,我们继续来研究Python的编译器,一起来看看它是如何做语义分析的,以及是如何生成字节码的。学完这一讲以后,你就能回答出下面几个问题了:
|
||||
|
||||
- **像Python这样的动态语言,在语义分析阶段都要做什么事情呢,跟Java这样的静态类型语言有什么不同?**
|
||||
- **Python的字节码有什么特点?生成字节码的过程跟Java有什么不同?**
|
||||
|
||||
好了,让我们开始吧。首先,我们来了解一下从AST到生成字节码的整个过程。
|
||||
|
||||
## 编译过程
|
||||
|
||||
Python编译器把词法分析和语法分析叫做“**解析(Parse)**”,并且放在Parser目录下。而从AST到生成字节码的过程,才叫做“**编译(Compile)**”。当然,这里编译的含义是比较狭义的。你要注意,不仅是Python编译器,其他编译器也是这样来使用这两个词汇,包括我们已经研究过的Java编译器,你要熟悉这两个词汇的用法,以便阅读英文文献。
|
||||
|
||||
Python的编译工作的主干代码是在Python/compile.c中,它主要完成5项工作。
|
||||
|
||||
**第一步,检查**[**future语句**](https://docs.python.org/3/reference/simple_stmts.html#future-statements)。future语句是Python的一个特性,让你可以提前使用未来版本的特性,提前适应语法和语义上的改变。这显然会影响到编译器如何工作。比如,对于“8/7”,用不同版本的语义去处理,得到的结果是不一样的。有的会得到整数“1”,有的会得到浮点数“1.14285…”,编译器内部实际上是调用了不同的除法函数。
|
||||
|
||||
**第二步,建立符号表。**
|
||||
|
||||
**第三步,为基本块产生指令。**
|
||||
|
||||
**第四步,汇编过程:把所有基本块的代码组装在一起。**
|
||||
|
||||
**第五步,对字节码做窥孔优化。**
|
||||
|
||||
其中的第一步,它是Python语言的一个特性,但不是我们编译技术关注的重点,所以这里略过。我们从建立符号表开始。
|
||||
|
||||
## 语义分析:建立符号表和引用消解
|
||||
|
||||
通常来说,在语义分析阶段首先是建立符号表,然后在此基础上做引用消解和类型检查。
|
||||
|
||||
而Python是动态类型的语言,类型检查应该是不需要了,但引用消解还是要做的。并且你会发现,Python的引用消解有其独特之处。
|
||||
|
||||
首先,我们来看看Python的符号表是一个什么样的数据结构。在**Include/symtable.h**中定义了两个结构,分别是符号表和符号表的条目:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/b9/f2d30eaa6yy3fd1e5dc5589f2ff197b9.jpg" alt="">
|
||||
|
||||
在编译的过程中,针对每个模块(也就是一个Python文件)会生成一个**符号表**(symtable)。
|
||||
|
||||
Python程序被划分成“块(block)”,块分为三种:模块、类和函数。每种块其实就是一个作用域,而在Python里面还叫做命名空间。**每个块对应一个符号表条目(PySTEntryObject),每个符号表条目里存有该块里的所有符号(ste_symbols)。**每个块还可以有多个子块(ste_children),构成树状结构。
|
||||
|
||||
在符号表里,有一个st_blocks字段,这是个字典,它能通过模块、类和函数的AST节点,查找到Python程序的符号表条目,通过这种方式,就把AST和符号表关联在了一起。
|
||||
|
||||
我们来看看,对于下面的示例程序,它对应的符号表是什么样子的。
|
||||
|
||||
```
|
||||
a = 2 #模块级变量
|
||||
class myclass:
|
||||
def __init__(self, x):
|
||||
self.x = x
|
||||
def foo(self, b):
|
||||
c = a + self.x + b #引用了外部变量a
|
||||
return c
|
||||
|
||||
```
|
||||
|
||||
这个示例程序有模块、类和函数三个级别的块。它们分别对应一条符号表条目。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/6a/4e5475a404a12f0b15yy096deae46d6a.jpg" alt="">
|
||||
|
||||
你可以看到,每个块里都有ste_symbols字段,它是一个字典,里面保存了本命名空间涉及的符号,以及每个符号的各种标志位(flags)。关于标志位,我下面会给你解释。
|
||||
|
||||
然后,我们再看看针对这个示例程序,符号表里的主要字段的取值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/a5/53dbb4c2c6620yy777c4b7dd7bb4a1a5.jpg" alt="">
|
||||
|
||||
好了,通过这样一个例子,你大概就知道了Python的符号表是怎样设计的了。下面我们来看看符号表的建立过程。
|
||||
|
||||
建立符号表的主程序是Python/symtable.c中的**PySymtable_BuildObject()函数**。
|
||||
|
||||
Python建立符号表的过程,需要做两遍处理,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/8a/e1f46cf122deb05ec1091d3e7619de8a.jpg" alt="">
|
||||
|
||||
**第一遍**,主要做了两件事情。第一件事情是建立一个个的块(也就是符号表条目),并形成树状结构,就像示例程序那样;第二件事情,就是给块中的符号打上一定的标记(flag)。
|
||||
|
||||
我们用GDB跟踪一下第一遍处理后生成的结果。你可以参考下图,看一下我在Python的REPL中的输入信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/f6/51531fb61d5d69b65e6e626dbc2d87f6.jpg" alt="">
|
||||
|
||||
我在symtable_add_def_helper()函数中设置了断点,便于调试。当编译器处理到foo函数的时候,我在GDB中打印输出了一些信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/4c/951750f5e311fa1714ac8b815df7e64c.jpg" alt="">
|
||||
|
||||
在这些输出信息中,你能看到前面我给你整理的表格中的信息,比如,符号表中各个字段的取值。
|
||||
|
||||
我重点想让你看的,是foo块中各个符号的标志信息:self和b是20,c是2,a是16。这是什么意思呢?
|
||||
|
||||
```
|
||||
ste_symbols = {'self': 20, 'b': 20, 'c': 2, 'a': 16}
|
||||
|
||||
```
|
||||
|
||||
这就需要看一下symtable.h中,对这些标志位的定义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/44/81250354f166061cae4dd0967398ac44.jpg" alt="">
|
||||
|
||||
我给你整理成了一张更容易理解的图,你参考一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/f2/968aa4e62c158d2d0cf3592a77bda3f2.jpg" alt="">
|
||||
|
||||
根据上述信息,你会发现self和b,其实是被标记了3号位和5号位,意思是这两个变量是函数参数,并且在foo中被使用。而a只标记了5号位,意思是a这个变量在foo中被使用,但这个变量又不是参数,所以肯定是来自外部作用域的。我们再看看c,c只在2号位被标记,表示这个变量在foo里被赋值了。
|
||||
|
||||
到目前为止,第一遍处理就完成了。通过第一遍处理,我们会知道哪些变量是本地声明的变量、哪些变量在本块中被使用、哪几个变量是函数参数等几方面的信息。
|
||||
|
||||
但是,现在还有一些信息是不清楚的。比如,在foo中使用了a,那么外部作用域中是否有这个变量呢?这需要结合上下文做一下分析。
|
||||
|
||||
还有,变量c是在foo中赋值的。那它是本地变量,还是外部变量呢?
|
||||
|
||||
在这里,你能体会出**Python语言使用变量的特点:由于变量在赋值前,可以不用显式声明。**所以你不知道这是新声明了一个变量,还是引用了外部变量。
|
||||
|
||||
正由于Python的这个特点,所以它在变量引用上有一些特殊的规定。
|
||||
|
||||
比如,想要在函数中给全局变量赋值,就必须加**global关键字**,否则编译器就会认为这个变量只是一个本地变量。编译器会给这个符号的1号位做标记。
|
||||
|
||||
而如果给其他外部作用域中的变量赋值,那就必须加**nonlocal关键字**,并在4号位上做标记。这时候,该变量就是一个自由变量。在闭包功能中,编译器还要对自由变量的存储做特殊的管理。
|
||||
|
||||
接下来,编译器会**做第二遍的分析**(见symtable_analyze()函数)。在这遍分析里,编译器会根据我们刚才说的Python关于变量引用的语义规则,分析出哪些是全局变量、哪些是自由变量,等等。这些信息也会被放到符号的标志位的第12~15位。
|
||||
|
||||
```
|
||||
ste_symbols = {'self': 2068, 'b': 2068, 'c': 2050, 'a': 6160}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/7b/7a7a05f76f4d72e20d075eb5d732267b.jpg" alt="">
|
||||
|
||||
以变量a为例,它的标志值是6160,也就是二进制的1100000010000。其标记位设置如下,其作用域的标志位是3,也就是说,a是个隐式的全局变量。而self、b和c的作用域标志位都是1,它们的意思是本地变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/2f/d52bd8bfe3412da62b289fbdeab7942f.jpg" alt="">
|
||||
|
||||
在第二遍的分析过程中,Python也做了一些语义检查。你可以搜索一下Python/symtable.c的代码,里面有很多地方会产生错误信息,比如“nonlocal declaration not allowed at module level(在模块级不允许非本地声明)”。
|
||||
|
||||
另外,Python语言提供了访问符号表的API,方便你直接在REPL中,来查看编译过程中生成的符号表。你可以参考我的屏幕截图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/46/20364439b1a4e664b5c868032767a546.jpg" alt="">
|
||||
|
||||
好了,现在符号表已经生成了。基于AST和符号表,Python编译器就可以生成字节码。
|
||||
|
||||
## 生成CFG和指令
|
||||
|
||||
我们可以用Python调用编译器的API,来观察字节码生成的情况:
|
||||
|
||||
```
|
||||
>>> co = compile("a+2", "test.py", "eval") //编译表达式"a+2"
|
||||
>>> dis.dis(co.co_code) //反编译字节码
|
||||
0 LOAD_NAME 0 (0) //装载变量a
|
||||
2 LOAD_CONST 0 (0) //装载常数2
|
||||
4 BINARY_ADD //执行加法
|
||||
6 RETURN_VALUE //返回值
|
||||
|
||||
```
|
||||
|
||||
其中的LOAD_NAME、LOAD_CONST、BINARY_ADD和RETURN_VALUE都是字节码的指令。
|
||||
|
||||
对比一下,Java的字节码的每个指令只有一个字节长,这意味着指令的数量不会超过2的8次方(256)个。
|
||||
|
||||
Python的指令一开始也是一个字节长的,后来变成了一个字(word)的长度,但我们仍然习惯上称为字节码。Python的在线文档里有对所有[字节码的说明](https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions),这里我就不展开了,感兴趣的话你可以自己去看看。
|
||||
|
||||
并且,Python和Java的虚拟机一样,都是基于栈的虚拟机。所以,它们的指令也很相似。比如,加法操作的指令是不需要带操作数的,因为只需要取出栈顶的两个元素相加,把结果再放回栈顶就行了。
|
||||
|
||||
进一步,你可以对比一下这两种语言的字节码,来看看它们的异同点,并且试着分析一下原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/98/9bc680cf4da741d31eea1df105356498.jpg" alt="">
|
||||
|
||||
这样对比起来,你可以发现,它们主要的区别就在于,**Java的字节码对不同的数据类型会提供不同的指令,而Python则不加区分。**因为Python对所有的数值,都会提供统一的计算方式。
|
||||
|
||||
所以你可以看出,一门语言的IR,是跟这门语言的设计密切相关的。
|
||||
|
||||
生成CFG和字节码的代码在**Python/compile.c**中。调用顺序如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/e9/d367326a1117ecef5d2c6ce71b6736e9.jpg" alt="">
|
||||
|
||||
**总的逻辑是:以visit模式遍历整个AST,并建立基本块和指令。**对于每种AST节点,都由相应的函数来处理。
|
||||
|
||||
以compiler_visit_expr1()为例,对于二元操作,编译器首先会递归地遍历左侧子树和右侧子树,然后根据结果添加字节码的指令。
|
||||
|
||||
```
|
||||
compiler_visit_expr1(struct compiler *c, expr_ty e)
|
||||
{
|
||||
switch (e->kind) {
|
||||
...
|
||||
.
|
||||
case BinOp_kind:
|
||||
VISIT(c, expr, e->v.BinOp.left); //遍历左侧子树
|
||||
VISIT(c, expr, e->v.BinOp.right); //遍历右侧子树
|
||||
ADDOP(c, binop(c, e->v.BinOp.op)); //添加二元操作的指令
|
||||
break;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**那么基本块是如何生成的呢?**
|
||||
|
||||
编译器在进入一个作用域的时候(比如函数),至少要生成一个基本块。而像循环语句、if语句,还会产生额外的基本块。
|
||||
|
||||
所以,编译的结果,会在compiler结构中保存一系列的基本块,这些基本块相互连接,构成CFG;基本块中又包含一个指令数组,每个指令又包含操作码、参数等信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/a3/fc9d9bf9526f14b217f2912c35658ea3.jpg" alt="">
|
||||
|
||||
为了直观理解,我设计了一个简单的示例程序。foo函数里面有一个if语句,这样会产生多个基本块。
|
||||
|
||||
```
|
||||
def foo(a):
|
||||
if a > 10 :
|
||||
b = a
|
||||
else:
|
||||
b = 10
|
||||
return b
|
||||
|
||||
```
|
||||
|
||||
通过GDB跟踪编译过程,我们发现,它生成的CFG如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/62/7159fe5e9baa1c1d6401644e21a67f62.jpg" alt="">
|
||||
|
||||
在CFG里,你要注意两组箭头。
|
||||
|
||||
**实线箭头**是基本块之间的跳转关系,用b_next字段来标记。**虚线箭头**能够基于b_list字段把所有的基本块串起来,形成一个链表,每一个新生成的基本块指向前一个基本块。只要有一个指针指向最后一个基本块,就能访问所有的基本块。
|
||||
|
||||
你还可以通过GDB来查看每个基本块中的指令分别是什么,这样你就会理解每个基本块到底干了啥。不过我这里先给你留个小伏笔,在下一个小节讲汇编的时候一起给你介绍。
|
||||
|
||||
到目前为止,我们已经生成了CFG和针对每个基本块的指令数组。但我们还没有生成最后的字节码。这个任务,是由汇编(Assembly)阶段负责的。
|
||||
|
||||
## 汇编(Assembly)
|
||||
|
||||
汇编过程是在Python/compiler.c中的assemble()函数中完成的。听名字,你会感觉这个阶段做的事情似乎应该比较像汇编语言的汇编器的功能。也确实如此。汇编语言的汇编器,能够生成机器码;而Python的汇编阶段,是生成字节码,它们都是生成目标代码。
|
||||
|
||||
具体来说,汇编阶段主要会完成以下任务:
|
||||
|
||||
- 把每个基本块的指令对象转化成字节码。
|
||||
- 把所有基本块的字节码拼成一个整体。
|
||||
- 对于从一个基本块跳转到另一个基本块的jump指令,它们有些采用的是相对定位方式,比如往前跳几个字的距离。这个时候,编译器要计算出正确的偏移值。
|
||||
- 生成PyCodeObject对象,这个对象里保存着最后生成的字节码和其他辅助信息,用于Python的解释器执行。
|
||||
|
||||
我们还是通过示例程序,来直观地看一下汇编阶段的工作成果。你可以参照下图,使用instaviz工具看一下foo函数的编译结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/a8/3bc3086cf0acc70c854ea8bcf732c3a8.jpg" alt="">
|
||||
|
||||
在PyCodeObject对象中,co_code字段是生成的字节码(用16进制显示)。你还能看到常量表和变量表,这些都是在解释器中运行所需要的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/0a/a58759f12bd9fa20514e53c58bd6ee0a.jpg" alt="">
|
||||
|
||||
如果把co_code字段的那一串字节码反编译一下,你会得到下面的信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/77/279aeb6e24fa2351d46f3774efa1cc77.jpg" alt="">
|
||||
|
||||
你会看到,一共11条指令,其中BB1是7条,BB2和BB3各2条。BB1里面是If条件和if块中的内容,BB2对应的是else块的内容,BB3则对应return语句。
|
||||
|
||||
不过,如果你对照基本块的定义,你其实会发现,BB1不是一个标准的基本块。因为一般来说,标准的基本块只允许在最后一个语句做跳转,其他语句都是顺序执行的。而我们看到第4个指令“POP_JUMP_IF_FALSE 14”其实是一个条件跳转指令。
|
||||
|
||||
尽管如此,因为Python把CFG只是作为生成字节码的一个中间结构,并没有基于CFG做数据流分析和优化,所以虽然基本块不标准,但是对Python的编译过程并无影响。
|
||||
|
||||
你还会注意到第7行指令“JUMP_FORWARD”,这个指令是一个基于相对位置的跳转指令,它往前跳4个字,就会跳到BB3。这个跳转距离就是在assemble阶段去计算的,这个距离取决于你如何在代码里排列各个基本块。
|
||||
|
||||
好了,到目前为止,字节码已经生成了。不过,在最后放到解释器里执行之前,编译器还会再做一步窥孔优化工作。
|
||||
|
||||
## 窥孔优化
|
||||
|
||||
说到优化,总体来说,在编译的过程中,Python编译器的优化功能是很有限的。在compiler.c的代码中,你会看到一点优化逻辑。比如,在为if语句生成指令的时候,编译器就会看看if条件是否是个常数,从而不必生成if块或者else块的代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/44/f93947d0ed0a74d08f78eeacabe1c744.jpg" alt="">
|
||||
|
||||
另一个优化机会,就是在字节码的基础上优化,这就是窥孔优化,其实现是在**Python/peephole.c**中。它能完成的优化包括:
|
||||
|
||||
- 把多个LOAD_CONST指令替换为一条加载常数元组的指令。
|
||||
- 如果一个跳转指令,跳到return指令,那么可以把跳转指令直接替换成return指令。
|
||||
- 如果一个条件跳转指令,跳到另一个条件跳转指令,则可以基于逻辑运算的规则做优化。比如,“x:JUMP_IF_FALSE_OR_POP y”和“y:JUMP_IF_FALSE_OR_POP z”可以直接简化为“x:JUMP_IF_FALSE_OR_POP z”。这是什么意思呢?第一句是依据栈顶的值做判断,如果为false就跳转到y。而第二句,继续依据栈顶的值做判断,如果为false就跳转到z。那么,简化后,可以直接从第一句跳转到z。
|
||||
- 去掉return指令后面的代码。
|
||||
- ……
|
||||
|
||||
在做优化的时候,窥孔优化器会去掉原来的指令,替换成新的指令。如果有多余出来的位置,则会先填充NOP指令,也就是不做任何操作。最后,才扫描一遍整个字节码,把NOP指令去掉,并且调整受影响的jump指令的参数。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们继续深入探索Python的编译之旅。你需要记住以下几点:
|
||||
|
||||
- Python通过一个**建立符号表**的过程来做相关的语义分析,包括做引用消解和其他语义检查。由于Python可以不声明变量就直接使用,所以编译器要能识别出正确的“定义-使用”关系。
|
||||
- **生成字节码**的工作实际上包含了生成CFG、为每个基本块生成指令,以及把指令汇编成字节码,并生成PyCodeObject对象的过程。
|
||||
- **窥孔优化器**在字节码的基础上做了一些优化,研究这个程序,会让你对窥孔优化的认识变得具象起来。
|
||||
|
||||
按照惯例,我把这一讲的思维导图也整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/4e/0966a177eeb50a2e0d7bea71e1e1914e.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在语义分析过程中,针对函数中的本地变量,Python编译器没有像Java编译器那样,一边添加符号,一边做引用消解。这是为什么?请在留言区分享你的观点。
|
||||
|
||||
如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Python[字节码的说明](https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions)。
|
||||
399
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/19 | Python编译器(三):运行时机制.md
Normal file
399
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/19 | Python编译器(三):运行时机制.md
Normal file
@@ -0,0 +1,399 @@
|
||||
<audio id="audio" title="19 | Python编译器(三):运行时机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3c/0e/3cb15a945720d148294407d9f0c15a0e.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在前面两讲中,我们已经分析了Python从开始编译到生成字节码的机制。但是,我们对Python只是了解了一半,还有很多问题需要解答。比如:**Python字节码是如何运行的呢?它是如何管理程序所用到的数据的?它的类型体系是如何设计的,有什么特点?**等等。
|
||||
|
||||
所以今天这一讲,我们就来讨论一下Python的运行时机制。其中的**核心,是Python对象机制的设计**。
|
||||
|
||||
我们先来研究一下字节码的运行机制。你会发现,它跟Python的对象机制密切相关。
|
||||
|
||||
## 理解字节码的执行过程
|
||||
|
||||
我们用GDB跟踪执行一个简单的示例程序,它只有一行:“`a=1`”。
|
||||
|
||||
这行代码对应的字节码如下。其中,前两行指令实现了“`a=1`”的功能(后两行是根据Python的规定,在执行完一个模块之后,缺省返回一个None值)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/50/8ae69517e1bfec78fddea57e4da89e50.jpg" alt="">
|
||||
|
||||
你需要在**_PyEval_EvalFrameDefault()函数**这里设置一个断点,在这里实际解释指令并执行。
|
||||
|
||||
**首先是执行第一行指令,LOAD_CONST。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/48/def47ca68f979af8cf684e47c0889848.jpg" alt="">
|
||||
|
||||
你会看到,解释器做了三件事情:
|
||||
|
||||
1. 从常数表里取出0号常数。你知道,编译完毕以后会形成PyCodeObject,而在这个对象里会记录所有的常量、符号名称、本地变量等信息。常量1就是从它的常量表中取出来的。
|
||||
1. 把对象引用值加1。对象引用跟垃圾收集机制相关。
|
||||
1. 把这个常数对象入栈。
|
||||
|
||||
从这第一行指令的执行过程,你能得到什么信息呢?
|
||||
|
||||
第一个信息,常量1在Python内部,它是一个对象。你可以在GDB里显示这个对象的信息:该对象的类型是PyLong_Type型,这是Python的整型在内部的实现。
|
||||
|
||||
另外,该对象的引用数是126个,说明这个常量对象其实是被共享的,LOAD_CONST指令会让它的引用数加1。我们用的常数是1,这个值在Python内部也是会经常被用到,所以引用数会这么高。你可以试着选个不那么常见的常数,看看它的引用数是多少,都是在哪里被引用的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/e1/028e92c61293d4fd2eb7c6acc00b5ce1.jpg" alt="">
|
||||
|
||||
进一步,我们会发现,往栈里放的数据,其实是个对象指针,而不像Java的栈机那样,是放了个整数。
|
||||
|
||||
总结上述信息,我其实可以告诉你一个结论:**在Python里,程序中的任何符号都是对象,包括整数、浮点数这些基础数据,或者是自定义的类,或者是函数,它们都是对象。**在栈机里处理的,是这些对象的引用。
|
||||
|
||||
**我们再继续往下分析一条指令,也就是STORE_NAME指令**,来加深一下对Python运行机制的理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/36/c89af4691c41517545acc20486648c36.jpg" alt="">
|
||||
|
||||
执行STORE_NAME指令时,解释器做了5件事情:
|
||||
|
||||
1. 根据指令的参数,从名称表里取出变量名称。这个名称表也是来自于PyCodeObject。前面我刚说过了,Python程序中的一切都是对象,那么name也是对象。你可以查看它的类型,是PyUnicode_Type,也就是Unicode的字符串。
|
||||
1. 从栈顶弹出上一步存进去的常量对象。
|
||||
1. 获取保存了所有本地变量的字典,这也是来自PyCodeObject。
|
||||
1. 在字典里,设置a的值为该常量。如果你深入跟踪其执行过程,你会发现在存入字典的时候,name对象和v对象的引用都会加1。这也是可以理解的,因为它们一个作为key,一个作为value,都要被字典所引用。
|
||||
1. 减少常量对象的引用计数。意思是栈机本身不再引用该常量。
|
||||
|
||||
好了,通过详细解读这两条指令的执行过程,我相信你对Python的运行机制摸到一点头绪了,但可能还是会提出很多问题来,比如说:
|
||||
|
||||
- **既然栈里的操作数都是对象指针,那么如何做加减乘除等算术运算?**
|
||||
- **如果函数也是对象,那么执行函数的过程又是怎样的?**
|
||||
- ……
|
||||
|
||||
别着急,我在后面会带你探究清楚这些问题。不过在此之前,我们有必要先加深一下对Python对象的了解。
|
||||
|
||||
## Python对象的设计
|
||||
|
||||
Python的对象定义在object.h中。阅读文件头部的注释和对各类数据结构的定义,你就可以理解Python对象的设计思路。
|
||||
|
||||
首先是PyObject和PyVarObject两个基础的数据结构,它们分别表示定长的数据和变长的数据。
|
||||
|
||||
```
|
||||
typedef struct _object { //定长对象
|
||||
Py_ssize_t ob_refcnt; //对象引用计数
|
||||
struct _typeobject *ob_type; //对象类型
|
||||
} PyObject;
|
||||
|
||||
typedef struct { //变长对象
|
||||
PyObject ob_base;
|
||||
Py_ssize_t ob_size; //变长部分的项目数量,在申请内存时有确定的值,不再变
|
||||
} PyVarObject;
|
||||
|
||||
```
|
||||
|
||||
PyObject是最基础的结构,所有的对象在Python内部都表示为一个PyObject指针。它里面只包含两个成员:对象引用计数(ob_refcnt)和对象类型(ob_type),你在用GDB跟踪执行时也见过它们。可能你会问,**为什么只有这两个成员呢?对象的数据(比如一个整数)保存在哪里?**
|
||||
|
||||
实际上,任何对象都会在一开头包含PyObject,其他数据都跟在PyObject的后面。比如说,Python3的整数的设计是一个变长对象,会用一到多个32位的段,来表示任意位数的整数:
|
||||
|
||||
```
|
||||
#define PyObject_VAR_HEAD PyVarObject ob_base;
|
||||
struct _longobject {
|
||||
PyObject_VAR_HEAD //PyVarObject
|
||||
digit ob_digit[1]; //数字段的第一个元素
|
||||
};
|
||||
typedef struct _longobject PyLongObject; //整型
|
||||
|
||||
```
|
||||
|
||||
它在内存中的布局是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/24/3d80b4e2750cdf5701yy5fa1d68fde24.jpg" alt="">
|
||||
|
||||
所以你会看出,**`PyObject*`、`PyVarObject*`和`PyLongObject*`指向的内存地址是相同的**。你可以根据ob_type的类型,把`PyObject*`强制转换成`PyLongObject*`。
|
||||
|
||||
实际上,像C++这样的面向对象语言的内存布局也是如此,父类的成员变量在最前面,子类的成员变量在后面,父类和子类的指针之间可以强制转换。懂得了这个原理,我们用C语言照样可以模拟出面向对象的继承结构出来。
|
||||
|
||||
你可以注意到,我在图1中标出了每个字段所占内存的大小,总共是28个字节(这是64位macOS下的数值,如果是另外的环境,比如32位环境,数值会有所不同)。
|
||||
|
||||
你可以用sys.getsizeof()函数,来测量对象占据的内存空间。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> import sys
|
||||
>>> sys.getsizeof(a)
|
||||
28 #ob_size = 1
|
||||
>>> a = 1234567890
|
||||
>>> sys.getsizeof(a)
|
||||
32 #ob_size = 2,支持更大的整数
|
||||
|
||||
```
|
||||
|
||||
到这里,我们总结一下Python对象设计的三个特点:
|
||||
|
||||
**1.基于堆**
|
||||
|
||||
Python对象全部都是在堆里申请的,没有静态申请和在栈里申请的。这跟C、C++和Java这样的静态类型的语言很不一样。
|
||||
|
||||
C的结构体和C++的对象都既可以在栈里,也可以在堆里;Java也是一样,除了原生数据类型可以在栈里申请,未逃逸的Java对象的内存也可以在栈里管理,我们在讲[Java的JIT编译器](https://time.geekbang.org/column/article/257504)的时候已经讲过了。
|
||||
|
||||
**2.基于引用计数的垃圾收集机制**
|
||||
|
||||
每个Python对象会保存一个引用计数。也就是说,Python的垃圾收集机制是基于引用计数的。
|
||||
|
||||
它的优点是可以实现增量收集,只要引用计数为零就回收,避免出现周期性的暂停;缺点是需要解决循环引用问题,并且要经常修改引用计数(比如在每次赋值和变量超出作用域的时候),开销有点大。
|
||||
|
||||
**3.唯一ID**
|
||||
|
||||
每个Python对象都有一个唯一ID,它们在生存期内是不变的。用id()函数就可以获得对象的ID。根据Python的[文档](https://docs.python.org/3/library/functions.html#id),这个ID实际就是对象的内存地址。所以,实际上,你不需要在对象里用一个单独的字段来记录对象ID。这同时也说明,Python对象的地址在整个生命周期内是不会变的,这也符合基于引用计数的垃圾收集算法。对比一下,如果采用“停止和拷贝”的算法,对象在内存中会被移动,地址会发生变化。所以你能看出,ID的算法与垃圾收集算法是环环相扣的。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> id(a)
|
||||
140330839057200
|
||||
|
||||
```
|
||||
|
||||
接下来,我们看看ob_type这个字段,它指向的是对象的类型。以这个字段为线索,我们就可以牵出Python的整个类型系统的设计。
|
||||
|
||||
## Python的类型系统
|
||||
|
||||
Python是动态类型的语言。它的类型系统的设计相当精巧,Python语言的很多优点,都来自于它的类型系统。我们来看一下。
|
||||
|
||||
首先,Python里每个PyObject对象都有一个类型信息。保存类型信息的数据结构是PyTypeObject(定义在Include/cpython/object.h中)。PyTypeObject本身也是一个PyObject,只不过这个对象是用于记录类型信息的而已。它是一个挺大的结构体,包含了对一个类型的各种描述信息,也包含了一些函数的指针,这些函数就是对该类型可以做的操作。可以说,只要你对这个结构体的每个字段的作用都了解清楚了,那么你对Python的类型体系也就了解透彻了。
|
||||
|
||||
```
|
||||
typedef struct _typeobject {
|
||||
PyObject_VAR_HEAD
|
||||
const char *tp_name; /* 用于打印的名称格式是"<模块>.<名称>" */
|
||||
Py_ssize_t tp_basicsize, tp_itemsize; /* 用于申请内存 */
|
||||
|
||||
/* 后面还有很多字段,比如用于支持数值计算、序列、映射等操作的函数,用于描述属性、子类型、文档等内容的字段,等等。 */
|
||||
...
|
||||
} PyTypeObject
|
||||
|
||||
```
|
||||
|
||||
因为这个结构非常重要,所以我把一些有代表性的字段整理了一下,你可以重点关注它们:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/16/2bd631d8b2c0c7fcc700dce65af8a916.jpg" alt="">
|
||||
|
||||
你会看到,这个结构里的很多部分都是一个函数插槽(Slot),你可以往插槽里保存一些函数指针,用来实现各种标准操作,比如对象生命周期管理、转成字符串、获取哈希值等。
|
||||
|
||||
在上面的表格中,你还会看到像“`__init__`”这样的方法,它的两边都是有两个下划线的,也就是“double underscore”,简称dunder方法,也叫做“**魔术方法**”。在用Python编写自定义的类的时候,你可以实现这些魔术方法,它们就会被缺省的`tp_*`函数所调用,比如,“`__init__`”会被缺省的`tp_init`函数调用,完成类的初始化工作。
|
||||
|
||||
现在我们拿整型对象来举个例子,一起来看看它的PyTypeObject是什么样子。
|
||||
|
||||
对于整型对象来说,它的ob_type会指向一个PyLong_Type对象。这个对象是在longobject.c中初始化的,它是PyTypeObject的一个实例。从中你会看到一些信息:类型名称是“int”,转字符串的函数是`long_to_decimal_string`,此外还有比较函数、方法描述、属性描述、构建和析构函数等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/ab/aabb1ce39b937b38c7e34bc23ca65cab.jpg" alt="">
|
||||
|
||||
我们运行type()函数,可以获得一个对象的类型名称,这个名称就来自PyTypeObject的`tp_name`。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> type(a)
|
||||
<type 'int'>
|
||||
|
||||
```
|
||||
|
||||
我们用dir()函数,可以从PyTypeObject中查询出一个对象所支持的所有属性和方法。比如,下面是查询一个整型对象获得的结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/df/5ea14dda16663f78705fd37897e403df.jpg" alt="">
|
||||
|
||||
好,我们刚才讲了整型,它对应的PyTypeObject的实例是PyLong_Type。Python里其实还有其他一些内置的类型,它们分别都对应了一个PyTypeObject的实例。你可以参考一下这个表格。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/91/d3c8c591b89832b5ff71a08856247491.jpg" alt="">
|
||||
|
||||
上面列出的这些都是Python的内置类型。有些内置类型跟语法是关联着的,比如说,“`a = 1`”就会自动创建一个整型对象,“`a = [2, 'john', 3]`”就会自动创建一个List对象。**这些内置对象都是用C语言实现的。**
|
||||
|
||||
**Python比较有优势的一点,是你可以用C语言,像实现内置类型一样实现自己的类型,并拥有很高的性能。**
|
||||
|
||||
当然,如果性能不是你最重要的考虑因素,那么你也可以用Python来创建新的类型,也就是以class关键字开头的自定义类。class编译以后,也会形成一个PyTypeObject对象,来代表这个类。你为这个类编写的各种属性(比如类名称)和方法,会被存到类型对象中。
|
||||
|
||||
好了,现在你已经初步了解了Python的类型系统的特征。接下来,我就带你更深入地了解一下类型对象中一些重要的函数插槽的作用,以及它们所构成的一些协议。
|
||||
|
||||
## Python对象的一些协议
|
||||
|
||||
前面在研究整型对象的时候,你会发现PyLong_Type的tp_as_number字段被赋值了,这是一个结构体(PyNumberMethods),里面是很多与数值计算有关的函数指针,包括加减乘除等。这些函数指针是实现Python的数值计算方面的协议。任何类型,只要提供了这些函数,就可以像整型对象一样进行计算。这实际上是Python定义的一个针对数值计算的协议。
|
||||
|
||||
既然如此,我们再次用GDB来跟踪一下Python的执行过程,看看整数的加法是怎么实现的。我们的示例程序增加了一行代码,变成:
|
||||
|
||||
```
|
||||
a = 1
|
||||
b = a + 2
|
||||
|
||||
```
|
||||
|
||||
它对应的字节码如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/4e/d0409d6d751cb403fb722fae14ba724e.jpg" alt="">
|
||||
|
||||
我们重点来关注BINARY_ADD指令的执行情况,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/de/b387350911c24493f73722667847efde.jpg" alt="">
|
||||
|
||||
可以看到,如果+号两边是字符串,那么编译器就会执行字符串连接操作。否则,就作为数字相加。
|
||||
|
||||
我们继续跟踪进入PyNumber_Add函数。在这个函数中,Python求出了加法函数指针在PyNumberMethods结构体中的偏移量,接着就进入了binary_op1()函数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/a3/a61339a2e5ae1b3b48b3f55dc780b8a3.jpg" alt="">
|
||||
|
||||
在binary_op1函数中,Python首先从第一个参数的类型对象中,取出了加法函数的指针。你在GDB中打印出输出信息,就会发现它是binaryfunc类型的,函数名称是long_add。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/6b/d2566a006c6a3367c89c372d2526f86b.jpg" alt="">
|
||||
|
||||
binaryfunc类型的定义是:
|
||||
|
||||
```
|
||||
typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
|
||||
|
||||
```
|
||||
|
||||
也就是说,它是指向的函数要求有两个Python对象(的指针)作为参数,返回值也是一个Python对象(的指针)。
|
||||
|
||||
你再继续跟踪下去,会发现程序就进入到了long_add函数。这个函数是在longobject.c里定义的,是Python整型类型做加法计算的内置函数。
|
||||
|
||||
这里有一个隐秘的问题,**为什么是使用了第一个参数(也就是加法左边的对象)所关联的加法函数,而不是第二个参数的加法函数?**
|
||||
|
||||
在我们的示例程序中,由于加法两边的对象的类型是相同的,都是整型,所以它们所关联的加法函数是同一个。但是,如果两边的类型不一样怎么办呢?这个其实是一个很有意思的函数分派问题,你可以先思考一下答案,我会在后面讲Julia的编译器时再回到这个问题上。
|
||||
|
||||
好了,现在我们就能理解了,像加减乘除这样运算,它们在Python里都是怎么实现的了。Python是到对象的类型中,去查找针对这些运算的函数来执行。
|
||||
|
||||
除了内置的函数,我们也可以自己写这样的函数,并被Python所调用。来看看下面的示例程序,我们定义了一个“`__add__`”魔术方法。这个方法会被Python作为SimpleComplex的加法函数所使用,实现了加法操作符的重载,从而支持复数的加法操作。
|
||||
|
||||
```
|
||||
class SimpleComplex(object):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
def __str__(self):
|
||||
return "x: %d, y: %d" % (self.x, self.y)
|
||||
def __add__(self, other):
|
||||
return SimpleComplex(self.x + other.x, self.y + other.y)
|
||||
|
||||
a = SimpleComplex(1, 2)
|
||||
b = SimpleComplex(3, 4)
|
||||
c = a + b
|
||||
print(c)
|
||||
|
||||
```
|
||||
|
||||
**那么对于这么一个自定义类,在执行BINARY_ADD指令时会有什么不同呢?**通过GDB做跟踪,你会发现几点不同:
|
||||
|
||||
首先,在SimpleComplex的type对象中,add函数插槽里放了一个slot_nb_add()函数指针,这个函数会到对象里查找“`__add__`”函数。因为Python的一切都是对象,因此它找到的是一个函数对象。
|
||||
|
||||
所以,接下来,Python需要运行这个函数对象,而不是用C语言写的内置函数。**那么怎么运行这个函数对象呢?**
|
||||
|
||||
这就需要用到Python的另一个协议,**Callable协议**。这个协议规定,只要为对象的类型中的tp_call属性定义了一个合法的函数,那么该对象就是可被调用的。
|
||||
|
||||
对于自定义的函数,Python会设置一个缺省的tp_call函数。这个函数所做的事情,实际上就是找到该函数所编译出来的PyCodeObject,并让解释器执行其中的字节码!
|
||||
|
||||
好了,通过上面的示例程序,我们加深了对类型对象的了解,也了解了Python关于数值计算和可调用性(Callable)方面的协议。
|
||||
|
||||
Python还有其他几个协议,比如枚举协议和映射协议等,用来支持对象的枚举、把对象加入字典等操作。你可以利用这些协议,充分融入到Python语言的框架中,比如说,你可以重载加减乘除等运算。
|
||||
|
||||
接下来,我们再运用Callable协议的知识,来探究一下Python对象的创建机制。
|
||||
|
||||
## Python对象的创建
|
||||
|
||||
用Python语言,我们可以编写class,来支持自定义的类型。我们来看一段示例代码:
|
||||
|
||||
```
|
||||
class myclass:
|
||||
def __init__(self, x):
|
||||
self.x = x
|
||||
def foo(self, b):
|
||||
c = self.x + b
|
||||
return c
|
||||
a = myclass(2);
|
||||
|
||||
```
|
||||
|
||||
其中,myclass(2)是生成了一个myclass对象。
|
||||
|
||||
可是,你发现没有,Python创建一个对象实例的方式,其实跟调用一个函数没啥区别(不像Java语言,还需要new关键字)。如果你不知道myclass是一个自定义的类,你会以为只是在调用一个函数而已。
|
||||
|
||||
不过,我们前面已经提到了Python的Callable协议。所以,利用这个协议,任何对象只要在其类型中定义了tp_call,那么就都可以被调用。
|
||||
|
||||
我再举个例子,加深你对Callable协议的理解。在下面的示例程序中,我定义了一个类型Bar,并创建了一个对象b。
|
||||
|
||||
```
|
||||
class Bar:
|
||||
def __call__(self):
|
||||
print("in __call__: ", self)
|
||||
b = Bar()
|
||||
b() #这里会打印对象信息,并显示对象地址
|
||||
|
||||
```
|
||||
|
||||
现在,我在b对象后面加一对括号,就可以调用b了!实际执行的就是Bar的“`__call__`”函数(缺省的tp_call函数会查找“`__call__`”属性,并调用)。
|
||||
|
||||
所以,我们调用myclass(),那一定是因为myclass的类型对象中定义了tp_call。
|
||||
|
||||
你还可以把“myclass(2)”这个语句编译成字节码看看,它生成的是CALL_FUNCTION指令,与函数调用没有任何区别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/a0/1aac8d9cc637f06aa480d86e722dc8a0.jpg" alt="">
|
||||
|
||||
可是,我们知道,示例程序中a的类型对象是myclass,但myclass的类型对象是什么呢?
|
||||
|
||||
换句话说,一个普通的对象的类型,是一个类型对象。**那么一个类型对象的类型又是什么呢?**
|
||||
|
||||
答案是**元类(metaclass)**,元类是类型的类型。举例来说,整型的metaclass是PyType_Type。其实,大部分类型的metaclass是PyType_Type。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/3e/6135aa92c8f7787f664307ab3cf2a83e.jpg" alt="">
|
||||
|
||||
所以说,调用类型来实例化一个对象,就是调用PyType_Type的tp_call函数。**那么PyType_Type的tp_call函数都做了些什么事情呢?**
|
||||
|
||||
这个函数是type_call(),它也是在typeobject.c中定义的。Python以type_call()为入口,会完成创建一个对象的过程:
|
||||
|
||||
- **创建**
|
||||
|
||||
tp_call会调用类型对象的tp_new插槽的函数。对于PyLong_Type来说,它是long_new。
|
||||
|
||||
如果我们是创建一个Point对象,如果你为它定义了一个“`__new__`”函数,那么就将调用这个函数来创建对象,否则,就会查找基类中的tp_new。
|
||||
|
||||
- **初始化**
|
||||
|
||||
tp_call会调用类型对象的tp_init。对于Point这样的自定义类型来说,如果定义了“`__init__`”函数,就会执行来做初始化。否则,就会调用基类的tp_init。对于PyBaseType_Object来说,这个函数是object_init。
|
||||
|
||||
除了自定义的类型,内置类型的对象也可以用类型名称加括号的方式来创建。我还是以整型为例,创建一个整型对象,也可以用“int(10)”这种格式,其中int是类型名称。而且,它的metaclass也是PyType_Type。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/3d/eac9e78929331459d160018703c7c53d.jpg" alt="">
|
||||
|
||||
当然,你也可以给你的类型指定另一个metaclass,从而支持不同的对象创建和初始化功能。虽然大部分情况下你不需要这么做,但这种可定制的能力,就为你编写某些特殊的功能(比如元编程)提供了可能性。
|
||||
|
||||
好了,现在你已经知道,类型的类型是元类(metaclass),它能为类型的调用提供支持。你可能进一步会问,**那么元类的类型又是什么呢?是否还有元元类?直接调用元类又会发生什么呢?**
|
||||
|
||||
缺省情况下,PyType_Type的类型仍然是PyType_Type,也就是指向它自身。对元类做调用,也一样会启动上面的tp_call()过程。
|
||||
|
||||
到目前为止,我们谈论Python中的对象,还没有谈论那些面向对象的传统话题:继承啦、多态啦,等等。这些特性在Python中的实现,仍然只是在类型对象里设置一些字段即可。你可以在tp_base里设定基类(父类)来支持继承,甚至在tp_bases中设置多个基类来支持多重继承。所有对象缺省的基类是object,tp_base指向的是一个PyBaseObject_Type对象。
|
||||
|
||||
```
|
||||
>>> int.__base__ #查看int类型的基类
|
||||
<class 'object'>
|
||||
|
||||
```
|
||||
|
||||
到目前为止,我们已经对于对象的类型、元类,以及对象之间的继承关系有了比较全面的了解,为了方便你重点复习和回顾,我把它们画成了一张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/5f/3a24633cf6a4637224bd84faac641f5f.jpg" alt="">
|
||||
|
||||
你要注意,图中我用两种颜色的箭头区分了两种关系。**一种是橙色箭头,代表的是类型关系**,比如PyLong_Type是PyLongObject的类型,而PyType_Type是PyLong_Type的类型;**另一种是黑色箭头,代表的是继承关系**,比如int的基类是object,所以PyLong_Type的tp_base指向PyBaseObject_Type。
|
||||
|
||||
到这里,你可能会觉得有点挑战认知。因为通常我们谈面向对象的体系结构,只会涉及图中的继承关系线,不太会考虑其中的类型关系线。Python的类型关系,体现了“数据即程序”的概念。Java语言里,某个类型对应于一个class的字节码,而在Python里,一个类型只是一个Python对象而已。
|
||||
|
||||
并且,在Java里也不会有元类,因为对象的创建和初始化过程都是语言里规定死的。而在Python里,你却拥有一定的掌控能力。
|
||||
|
||||
这些特点,都体现了Python类型体系的强大之处。
|
||||
|
||||
## 课程小结
|
||||
|
||||
好了,我们来总结一下Python的运行时的特征。你会发现,Python的运行时设计的核心,就是**PyObject对象**,Python对象所有的特性都是从PyObject的设计中延伸出来的,给人一种精巧的美感。
|
||||
|
||||
- Python程序中的符号都是Python对象,栈机中存的也都是Python对象指针。
|
||||
- 所有对象的头部信息是相同的,而后面的信息可扩展。这就让Python可以用PyObject指针来访问各种对象,这种设计技巧你需要掌握。
|
||||
- 每个对象都有类型,类型描述信息在一个类型对象里。系统内有内置的类型对象,你也可以通过C语言或Python语言创建新的类型对象,从而支持新的类型。
|
||||
- 类型对象里有一些字段保存了一些函数指针,用于完成数值计算、比较等功能。这是Python指定的接口协议,符合这些协议的程序可以被无缝集成到Python语言的框架中,比如支持加减乘除运算。
|
||||
- 函数的运行、对象的创建,都源于Python的Callable协议,也就是在类型对象中制定tp_call函数。面向对象的特性,也是通过在类型对象里建立与基类的链接而实现的。
|
||||
|
||||
我照例把本讲的重点知识,整理成了一张思维导图,供你参考和回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/5d/0a4916368758e1a1608408bc89a93e5d.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天给你的思考题是很有意思的。
|
||||
|
||||
我前面讲到,当Python做加法运算的时候,如果对象类型相同,那么只有一个加法函数可选。但如果两边的对象类型是不同的,该怎么办呢?你可以看看Python是怎么实现的。这其实是编译技术的一个关注点,我们在后面课程中还会提及这个问题。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Python的[内置类型](https://docs.python.org/3.8/library/stdtypes.html)。
|
||||
229
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/20 | JavaScript编译器(一):V8的解析和编译过程.md
Normal file
229
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/20 | JavaScript编译器(一):V8的解析和编译过程.md
Normal file
@@ -0,0 +1,229 @@
|
||||
<audio id="audio" title="20 | JavaScript编译器(一):V8的解析和编译过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/a2/bafd3183a764b3d6db8c083b8f0b65a2.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。从这一讲开始,我们就进入另一个非常重要的编译器:V8编译器。
|
||||
|
||||
V8是谷歌公司在2008年推出的一款JavaScript编译器,它也可能是世界上使用最广泛的编译器。即使你不是编程人员,你每天也会运行很多次V8,因为JavaScript是Web的语言,我们在电脑和手机上浏览的每个页面,几乎都会运行一点JavaScript脚本。
|
||||
|
||||
扩展:V8这个词,原意是8缸的发动机,换算成排量,大约是4.0排量,属于相当强劲的发动机了。它的编译器,叫做Ignition,是点火装置的意思。而它最新的JIT编译器,叫做TurboFan,是涡轮风扇发动机的意思。
|
||||
|
||||
在浏览器诞生的早期,就开始支持JavaScript了。但在V8推出以后,它重新定义了Web应用可以胜任的工作。到今天,在浏览器里,我们可以运行很多高度复杂的应用,比如办公套件等,这些都得益于以V8为代表的JavaScript引擎的进步。2008年V8发布时,就已经比当时的竞争对手快10倍了;到目前,它的速度又已经提升了10倍以上。从中你可以看到,编译技术有多大的潜力可挖掘!
|
||||
|
||||
对JavaScript编译器来说,它最大的挑战就在于,当我们打开一个页面的时候,源代码的下载、解析(Parse)、编译(Compile)和执行,都要在很短的时间内完成,否则就会影响到用户的体验。
|
||||
|
||||
**那么,V8是如何做到既编译得快,又要运行得快的呢?**所以接下来,我将会花两讲的时间,来带你一起剖析一下V8里面的编译技术。在这个过程中,你能了解到V8是如何完成前端解析、后端优化等功能的,它都有哪些突出的特点;另外,了解了V8的编译原理,对你以后编写更容易优化的程序,也会非常有好处。
|
||||
|
||||
今天这一讲,我们先来透彻了解一下V8的编译过程,以及每个编译阶段的工作原理,看看它跟我们已经了解的其他编译器相比,有什么不同。
|
||||
|
||||
## 初步了解V8
|
||||
|
||||
首先,按照惯例,我们肯定要下载V8的源代码。按照[官方文档](https://v8.dev/docs/build)中的步骤,你可以下载源代码,并在本地编译。注意,你最好把它编译成Debug模式,这样便于用调试工具去跟踪它的执行,所以你要使用下面的命令来进行编译。
|
||||
|
||||
```
|
||||
tools/dev/gm.py x64.debug
|
||||
|
||||
```
|
||||
|
||||
编译完毕以后,进入v8/out/x64.debug目录,你可以运行./d8,这就是编译好的V8的命令行工具。如果你用过Node.js,那么d8的使用方法,其实跟它几乎是完全一样的,因为Node.js就封装了一个V8引擎。你还可以用GDB或LLDB工具来调试d8,这样你就可以知道,它是怎么编译和运行JavaScript程序了。
|
||||
|
||||
而v8/src目录下的,就是V8的源代码了。V8是用C++编写的。你可以重点关注这几个目录中的代码,它们是与编译有关的功能,而别的代码主要是运行时功能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/ee/ce80ee2ace64988a8d332e0e545ef0ee.jpg" alt="">
|
||||
|
||||
V8的编译器的构成跟Java的编译器很像,它们都有从源代码编译到字节码的编译器,也都有解释器(叫Ignition),也都有JIT编译器(叫TurboFan)。你可以看下V8的编译过程的图例。在这个图中,你能注意到两个陌生的节点:**流处理节点(Stream)和预解析器(PreParser)**,这是V8编译过程中比较有特色的两个处理阶段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/fb/095613e2515fc6d6cc36705e6d952afb.jpg" alt="">
|
||||
|
||||
注意:这是比较新的V8版本的架构。在更早的版本里,有时会用到两个JIT编译器,类似于HotSpot的C1和C2,分别强调编译速度和优化效果。在更早的版本里,还没有字节码解释器。现在的架构,引入了字节码解释器,其速度够快,所以就取消了其中一级的JIT编译器。
|
||||
|
||||
下面我们就进入到V8编译过程中的各个阶段,去了解一些编译器的细节。
|
||||
|
||||
## 超级快的解析过程(词法分析和语法分析)
|
||||
|
||||
首先,我们来了解一下V8解析源代码的过程。我在开头就已经说过,V8解析源代码的速度必须要非常快才行。源代码边下载边解析完毕,在这个过程中,用户几乎感觉不到停顿。**那它是如何实现的呢?**
|
||||
|
||||
有两篇文章就非常好地解释了V8解析速度快的原因。
|
||||
|
||||
一个是“[optimizing the scanner](https://v8.dev/blog/scanner)”这篇文章,它解释了V8在词法分析上做的优化。V8的作者们真是锱铢必较地在每一个可能优化的步骤上去做优化,他们所采用的技术很具备参考价值。
|
||||
|
||||
那我就按照我对这篇文章的理解,来给你解释一下V8解析速度快的原因吧:
|
||||
|
||||
第一个原因,是**V8的整个解析过程是流(Stream)化的**,也就是一边从网络下载源代码,一边解析。在下载后,各种不同的编码还被统一转化为UTF-16编码单位,这样词法解析器就不需要处理多种编码了。
|
||||
|
||||
第二个原因,是**识别标识符时所做的优化**,这也让V8的解析速度更快了一点。你应该知道,标识符的第一个字符(ID_START)只允许用字母、下划线和$来表示,而之后的字符(ID_CONTINUE)还可以包括数字。所以,当词法解析器遇到一个字符的时候,我们首先要判断它是否是合法的ID_START。
|
||||
|
||||
**那么,这样一个逻辑,通常你会怎么写?**我一般想也不想,肯定是这样的写法:
|
||||
|
||||
```
|
||||
if(ch >= 'A' && ch <= 'Z' || ch >='a' && ch<='z' || ch == '$' || ch == '_'){
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但你要注意这里的一个问题,**if语句中的判断条件需要做多少个运算?**
|
||||
|
||||
最坏的情况下,要做6次比较运算和3次逻辑“或”运算。不过,V8的作者们认为这太奢侈了。所以他们通过查表的方法,来识别每个ASCII字符是否是合法的标识符开头字符。
|
||||
|
||||
这相当于准备了一张大表,每个字符在里面对应一个位置,标明了该字符是否是合法的标识符开头字符。这是典型的牺牲空间来换效率的方法。虽然你在阅读代码的时候,会发现它调用了几层函数来实现这个功能,但这些函数其实是内联的,并且在编译优化以后,产生的指令要少很多,所以这个方法的性能更高。
|
||||
|
||||
第三个原因,是**如何从标识符中挑出关键字**。
|
||||
|
||||
与Java的编译器一样,JavaScript的Scanner,也是把标识符和关键字一起识别出来,然后再从中挑出关键字。所以,你可以认为这是一个最佳实践。那你应该也会想到,识别一个字符串是否是关键字的过程,使用的方法仍然是查表。查表用的技术是“**完美哈希(perfect hashing)**”,也就是每个关键字对应的哈希值都是不同的,不会发生碰撞。并且,计算哈希值只用了三个元素:前两个字符(ID_START、ID_CONTINUE),以及字符串的长度,不需要把每个字符都考虑进来,进一步降低了计算量。
|
||||
|
||||
文章里还有其他细节,比如通过缩窄对Unicode字符的处理范围来进行优化,等等。从中你能体会到V8的作者们在提升性能方面,无所不用其极的设计思路。
|
||||
|
||||
除了词法分析,在语法分析方面,V8也做了很多的优化来保证高性能。其中,最重要的是“**懒解析**”技术([lazy parsing](https://v8.dev/blog/preparser))。
|
||||
|
||||
一个页面中包含的代码,并不会马上被程序用到。如果在一开头就把它们全部解析成AST并编译成字节码,就会产生很多开销:占用了太多CPU时间;过早地占用内存;编译后的代码缓存到硬盘上,导致磁盘IO的时间很长,等等。
|
||||
|
||||
所以,所有浏览器中的JavaScript编译器,都采用了懒解析技术。在V8里,首先由预解析器,也就是Preparser粗略地解析一遍程序,在正式运行某个函数的时候,编译器才会按需解析这个函数。你要注意,Preparser只检查语法的正确性,而基于上下文的检查则不是这个阶段的任务。你如果感兴趣的话,可以深入阅读一下这篇[介绍Preparser的文章](https://v8.dev/blog/preparser),我在这里就不重复了。
|
||||
|
||||
你可以在终端测试一下懒解析和完整解析的区别。针对foo.js示例程序,你输入“./d8 – ast-print foo.js”命令。
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a + b;
|
||||
}
|
||||
|
||||
//add(1,2) //一开始,先不调用add函数
|
||||
|
||||
```
|
||||
|
||||
得到的输出结果是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/b0/58491fc107d5227fbdaa2ab996ed9bb0.jpg" alt="">
|
||||
|
||||
里面有一个没有名字的函数(也就是程序的顶层函数),并且它记录了一个add函数的声明,仅此而已。你可以看到,Preparser的解析结果确实够粗略。
|
||||
|
||||
而如果你把foo.js中最后一行的注释去掉,调用一下add函数,再次让d8运行一下foo.js,就会输出完整解析后的AST,你可以看看二者相差有多大:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/75/6ec0dc2aedc384d34924848a1eeab575.jpg" alt="">
|
||||
|
||||
最后,你可以去看看正式的Parser(在parser.h、parser-base.h、parser.cc代码中)。学完了这么多编译器的实现机制以后,以你现在的经验,打开一看,你就能知道,这又是用手写的递归下降算法实现的。
|
||||
|
||||
在看算法的过程中,我一般第一个就会去看它是如何处理二元表达式的。因为二元表达式看上去很简单,但它需要解决一系列难题,包括左递归、优先级和结合性。
|
||||
|
||||
V8的Parser中,对于二元表达式的处理,采取的也是一种很常见的算法:**操作符优先级解析器**(Operator-precedence parser)。这跟Java的Parser也很像,它本质上是自底向上的一个LR(1)算法。所以我们可以得出结论,在手写语法解析器的时候,遇到二元表达式,采用操作符优先级的方法,算是最佳实践了!
|
||||
|
||||
好了,现在我们了解了V8的解析过程,那V8是如何把AST编译成字节码和机器码并运行的呢?我们接着来看看它的编译过程。
|
||||
|
||||
## 编译成字节码
|
||||
|
||||
我们在执行刚才的foo.js文件时,加上“–print-bytecode”参数,就能打印出生成的字节码了。其中,add函数的字节码如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/4e/4afe3a33df386d66b4dc7838ef7a304e.jpg" alt="">
|
||||
|
||||
怎么理解这几行字节码呢?我来给你解释一下:
|
||||
|
||||
- Ldar a1:把参数1从寄存器加载到累加器(Ld=load,a=accumulator, r=register)。
|
||||
- Add a0, [0]:把参数0加到累加器上。
|
||||
- Return:返回(返回值在累加器上)。
|
||||
|
||||
不过,要想充分理解这几行简单的字节码,你还需要真正理解Ignition的设计。因为这些字节码是由Ignition来解释执行的。
|
||||
|
||||
Ignition是一个基于寄存器的解释器。它把函数的参数、变量等保存在寄存器里。不过,这里的寄存器并不是物理寄存器,而是指栈帧中的一个位置。下面是一个示例的栈帧:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/3f/ea627032db67ea4yy0898897707f273f.jpg" alt="">
|
||||
|
||||
这个栈帧里包含了执行函数所需要的所有信息:
|
||||
|
||||
- 参数和本地变量。
|
||||
- 临时变量:它是在计算表达式的时候会用到的。比如,计算2+3+4的时候,就需要引入一个临时变量。
|
||||
- 上下文:用来在函数闭包之间维护状态。
|
||||
- pc:调用者的代码地址。
|
||||
|
||||
栈帧里的a0、a1、r0、r1这些都是寄存器的名称,可以在指令里引用。而在字节码里,会用一个操作数的值代替。
|
||||
|
||||
整个栈帧的长度是在编译成字节码的时候就计算好了的。这就让Ignition的栈帧能适应不同架构对栈帧对齐的要求。比如AMD64架构的CPU,它就要求栈帧是16位对齐的。
|
||||
|
||||
Ignition也用到了一些物理寄存器,来提高运算的性能:
|
||||
|
||||
- **累加器:**在做算术运算的时候,一定会用到累加器作为指令的其中一个操作数,所以它就不用在指令里体现了;指令里只要指定另一个操作数(寄存器)就行了。
|
||||
- **字节码数组寄存器:**指向当前正在解释执行的字节码数组开头的指针。
|
||||
- **字节码偏移量寄存器:**当前正在执行的指令,在字节码数组中的偏移量(与pc寄存器的作用一样)。
|
||||
- …
|
||||
|
||||
Ignition是我们见到的第一个寄存器机,它跟我们之前见到的Java和Python的栈机有明显的不同。所以,你可以先思考一下,Ignition会有什么特点呢?
|
||||
|
||||
我来给你总结一下吧。
|
||||
|
||||
1. 它在指令里会引用寄存器作为操作数,寄存器在进入函数时就被分配了存储位置,在函数运行时,栈帧的结构是不变的。而对比起来,栈机的指令从操作数栈里获取操作数,操作数栈随着函数的执行会动态伸缩。
|
||||
1. Ignition还引入了累加器这个物理寄存器作为缺省的操作数。这样既降低了指令的长度,又能够加快执行速度。
|
||||
|
||||
当然,Ignition没有像生成机器码那样,用一个寄存器分配算法,让本地变量、参数等也都尽量采用物理寄存器。这样做的原因,一方面是因为,寄存器分配算法会增加编译的时间;另一方面,这样不利于代码在解释器和TurboFan生成的机器代码之间来回切换(因为它要在调用约定之间做转换)。采用固定格式的栈帧,Ignition就能够在从机器代码切换回来的时候,很容易地设置正确的解释器栈帧状态。
|
||||
|
||||
我把更多的字节码指令列在了下面,你可以仔细看一看Ignition都有哪些指令,从而加深对Ignition解释运行机制的理解。同时,你也可以跟我们已经学过的Java和Python的字节码做个对比。这样呀,你对字节码、解释器的了解就更丰富了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/01/e1ee2400d3447088156959068f4bef01.jpg" alt="">
|
||||
|
||||
## 编译成机器码
|
||||
|
||||
好,前面我提到了,V8也有自己的JIT编译器,叫做TurboFan。在学过Java的JIT编译器以后,你可以预期到,TurboFan也会有一些跟Java JIT编译器类似的特性,比如它们都是把字节码编译生成机器码,都是针对热点代码才会启动即时编译的。那接下来,我们就来验证一下自己的想法,并一起来看看TurboFan的运行效果究竟如何。
|
||||
|
||||
我们来看一个示例程序add.js:
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a+b;
|
||||
}
|
||||
|
||||
for (i = 0; i<100000; i++){
|
||||
add(i, i+1);
|
||||
if (i%1000==0)
|
||||
console.log(i);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以用下面的命令,要求V8打印出优化过程、优化后的汇编代码、注释等信息。其中,“–turbo-filter=add”参数会告诉V8,只优化add函数,否则的话,V8会把add函数内联到外层函数中去。
|
||||
|
||||
```
|
||||
./d8 --trace-opt-verbose \
|
||||
--trace-turbo \
|
||||
--turbo-filter=add \
|
||||
--print-code \
|
||||
--print-opt-code \
|
||||
--code-comments \
|
||||
add.js
|
||||
|
||||
```
|
||||
|
||||
注释:你用./d8 --help,就能列出V8可以使用的各种选项及其说明,我把上面几个选项的含义解释一下。<br>
|
||||
–trace-opt-verbose:跟踪优化过程,并输出详细信息<br>
|
||||
–trace-turbo:跟踪TurboFan的运行过程<br>
|
||||
–print-code:打印生成的代码<br>
|
||||
–print-opt-code:打印优化的代码<br>
|
||||
–code-comment:在汇编代码里输出注释
|
||||
|
||||
程序一开头是解释执行的。在循环了24000次以后,V8认为这是热点代码,于是启动了Turbofan做即时编译。
|
||||
|
||||
最后生成的汇编代码有好几十条指令。不过你可以看到,大部分指令是用于初始化栈帧,以及处理逆优化的情况。真正用于计算的指令,是下面几行指令:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/63/42623960a371d97b95540343f5bf4363.jpg" alt="">
|
||||
|
||||
对这些汇编代码的解读,以及这些指令的产生和优化过程,我会在下一讲继续给你讲解。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这讲,我们从总体上考察了V8的编译过程,我希望你记住几个要点:
|
||||
|
||||
- 首先,是**编译速度**。由于JavaScript是在浏览器下载完页面后马上编译并执行,它对编译速度有更高的要求。因此,V8使用了一边下载一边编译的技术:懒解析技术。并且,在解析阶段,V8也比其他编译器更加关注处理速度,你可以从中学到通过查表减少计算量的技术。
|
||||
- 其次,我们认识了一种新的**解释器Ignition**,它是基于寄存器的解释器,或者叫寄存器机。Ignition比起栈机来,更有性能优势。
|
||||
- 最后,我们初步使用了一下V8的**即时编译器TurboFan**。在下一讲中,我们会更细致地探讨TurboFan的特性。
|
||||
|
||||
按照惯例,这一讲的思维导图我也给你整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/ae/07771c2c05ab77f1d01268cee44a36ae.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否把Ignition的字节码和Java、Python的字节码对比一下。看看它们有哪些共同之处,有哪些不同之处?
|
||||
|
||||
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 这两篇文章分析了V8的解析器为什么速度非常快:[Blazingly fast parsing, part 1: optimizing the scanner](https://v8.dev/blog/scanner),[Blazingly fast parsing, part 2: lazy parsing](https://v8.dev/blog/preparser)
|
||||
1. 这篇文章描述了Ignition的设计:[Ignition Design Doc](https://docs.google.com/document/d/11T2CRex9hXxoJwbYqVQ32yIPMh0uouUZLdyrtmMoL44/mobilebasic),我在GitHub上也放了一个[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition%20Design%20Doc.pdf)
|
||||
1. 这篇文章有助于你了解Ignition的字节码:[Understanding V8’s bytecode](https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775)
|
||||
1. V8项目的[官网](https://v8.dev/),这里有一些重要的博客文章和文档
|
||||
@@ -0,0 +1,279 @@
|
||||
<audio id="audio" title="21 | JavaScript编译器(二):V8的解释器和优化编译器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/d0/c847bc9edd939a495ce01cc15e1ac3d0.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。通过前一讲的学习,我们已经了解了V8的整个编译过程,并重点探讨了一个问题,就是**V8的编译速度为什么那么快**。
|
||||
|
||||
V8把解析过程做了重点的优化,解析完毕以后就可以马上通过Ignition解释执行了。这就让JavaScript可以快速运行起来。
|
||||
|
||||
今天这一讲呢,我们重点来讨论一下,**V8的运行速度为什么也这么快**,一起来看看V8所采用的优化技术。
|
||||
|
||||
上一讲我也提及过,V8在2008年刚推出的时候,它提供了一个快速编译成机器码的编译器,虽然没做太多优化,但性能已经是当时其他JavaScript引擎的10倍了。而现在,V8的速度又是2008年刚发布时候的10倍。那么,是什么技术造成了这么大的性能优化的呢?
|
||||
|
||||
这其中,**一方面原因,是TurboFan这个优化编译器,采用了很多的优化技术。**那么,它采用了什么优化算法?采用了什么IR?其优化思路跟Java的JIT编译器有什么相同点和不同点?
|
||||
|
||||
**另一方面,最新的Ignition解释器,虽然只是做解释执行的功能,但竟然也比一个基础的编译器生成的代码慢不了多少。**这又是什么原因呢?
|
||||
|
||||
所以今天,我们就一起把这些问题都搞清楚,这样你就能全面了解V8所采用的编译技术的特点了,你对动态类型语言的编译,也就能有更深入的了解,并且这也有助于你编写更高效的JavaScript程序。
|
||||
|
||||
好,首先,我们来了解一下TurboFan的优化编译技术。
|
||||
|
||||
## TurboFan的优化编译技术
|
||||
|
||||
TurboFan是一个优化编译器。不过它跟Java的优化编译器要完成的任务是不太相同的。因为JavaScript是动态类型的语言,所以如果它能够推断出准确的类型再来做优化,就会带来巨大的性能提升。
|
||||
|
||||
同时,TurboFan也会像Java的JIT编译器那样,基于IR来运行各种优化算法,以及在后端做指令选择、寄存器分配等优化。所有的这些因素加起来,才使得TurboFan能达到很高的性能。
|
||||
|
||||
我们先来看看V8最特别的优化,也就是通过对类型的推理所做的优化。
|
||||
|
||||
### 基于推理的优化(Speculative Optimazition)
|
||||
|
||||
对于基于推理的优化,我们其实并不陌生。在研究[Java的JIT编译器](https://time.geekbang.org/column/article/257504)时,你就发现了Graal会针对解释器收集的一些信息,对于代码做一些推断,从而做一些激进的优化,比如说会跳过一些不必要的程序分支。
|
||||
|
||||
而JavaScript是动态类型的语言,所以对于V8来说,最重要的优化,就是能够在运行时正确地做出类型推断。举个例子来说,假设示例函数中的add函数,在解释器里多次执行的时候,接受的参数都是整型,那么TurboFan就处理整型加法运算的代码就行了。这也就是上一讲中我们生成的汇编代码。
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a+b;
|
||||
}
|
||||
|
||||
for (i = 0; i<100000; i++){
|
||||
if (i%1000==0)
|
||||
console.log(i);
|
||||
|
||||
add(i, i+1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/07/b3256505bef28fb15c37dcb36117f507.jpg" alt="">
|
||||
|
||||
但是,**如果不在解释器里执行,直接要求TurboFan做编译,会生成什么样的汇编代码呢?**
|
||||
|
||||
你可以在运行d8的时候,加上“–always-opt”参数,这样V8在第一次遇到add函数的时候,就会编译成机器码。
|
||||
|
||||
```
|
||||
./d8 --trace-opt-verbose \
|
||||
--trace-turbo \
|
||||
--turbo-filter=add \
|
||||
--print-code \
|
||||
--print-opt-code \
|
||||
--code-comments \
|
||||
--always-opt \
|
||||
add.js
|
||||
|
||||
```
|
||||
|
||||
这一次生成的汇编代码,跟上一讲生成的就不一样了。由于编译器不知道add函数的参数是什么类型的,所以实际上,编译器是去调用实现Add指令的内置函数,来生成了汇编代码。
|
||||
|
||||
这个内置函数当然支持所有加法操作的语义,但是它也就无法启动基于推理的优化机制了。这样的代码,跟解释器直接解释执行,性能上没太大的差别,因为它们本质上都是调用一个全功能的内置函数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/82/bb27e6dece4cf36d5f3f9f8d87c15082.jpg" alt="">
|
||||
|
||||
而推理式优化的版本则不同,它直接生成了针对整型数字进行处理的汇编代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/43/503abd5987b387750b31574a37bc0943.jpg" alt="">
|
||||
|
||||
我来给你解释一下这几行指令的意思:
|
||||
|
||||
- 第1行和第3行,是把参数1和参数2分别拷贝到r8和r9寄存器。**注意**,这里是从物理寄存器里取值,而不是像前一个版本一样,在栈里取值。前一个版本遵循的是更加保守和安全的调用约定。
|
||||
- 第2行和第4行,是把r8和r9寄存器的值向右移1位。
|
||||
- 第5行,是把r8和r9相加。
|
||||
|
||||
看到这里,你可能就发现了一个问题:**只是做个简单的加法而已,为什么要做移位操作呢?**实际上,如果你熟悉汇编语言的话,要想实现上面的功能,其实只需要下面这两行代码就可以了:
|
||||
|
||||
```
|
||||
movq rax, rdi #把参数1拷贝到rax寄存器
|
||||
addq rax, rcx #把参数2加到rax寄存器上,作为返回值
|
||||
|
||||
```
|
||||
|
||||
那么,多出来的移位操作是干什么的呢?
|
||||
|
||||
这就涉及到了V8的**内存管理机制**。原来,V8对象都保存在堆中。在栈帧中保存的数值,都是指向堆的指针。垃圾收集器可以通过这些指针,知道哪些内存对象是不再被使用的,从而把它们释放掉。我们前面学过,Java的虚拟机和Python对于对象引用,本质上也是这么处理的。
|
||||
|
||||
但是,这种机制对于基础数据类型,比如整型,就不太合适了。因为你没有必要为一个简单的整型数据在堆中申请内存,这样既浪费内存,又降低了访问效率,V8需要访问两次内存才能读到一个整型变量的值(第一次读到地址,第二次根据该地址到堆里读到值)。你记得,Python就是这么访问基础数据的。
|
||||
|
||||
V8显然不能忍受这种低效的方式。它采用的优化机制,是一种被广泛采用的技术,叫做**标记指针**(Tagged Pointer)或者**标记值**(Tagged Value)。《[Pointer Compression in V8](https://v8.dev/blog/pointer-compression)》这篇文章,就介绍了V8中采用Tagged Pointer技术的细节。
|
||||
|
||||
比如说,对于一个32位的指针值,它的最低位是标记位。当标记位是0的时候,前31位是一个短整数(简写为Smi);而当标记位是1的时候,那么前31位是一个地址。这样,V8就可以用指针来直接保存一个整数值,用于计算,从而降低了内存占用,并提高了运行效率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/00/8dcef79f95f098yy29283a65f4e19000.jpg" alt="">
|
||||
|
||||
好了,现在你已经理解了V8的推理式编译的机制。那么,还有什么手段能提升代码的性能呢?
|
||||
|
||||
当然,还有基于IR的各种优化算法。
|
||||
|
||||
### IR和优化算法
|
||||
|
||||
在讲[Java的JIT编译器](https://time.geekbang.org/column/article/256914)时我就提过,V8做优化编译时采用的IR,也是基于Sea of Nodes的。
|
||||
|
||||
你可以回忆一下Sea of Nodes的特点:合并了数据流图与控制流图;是SSA形式;没有把语句划分成基本块。
|
||||
|
||||
它的重要优点,就是优化算法可以自由调整语句的顺序,只要不破坏数据流图中的依赖关系。在Sea of Nodes中,没有变量(有时也叫做寄存器)的概念,只有一个个数据节点,所以对于死代码删除等优化方法来说,它也具备天然的优势。
|
||||
|
||||
**说了这么多,那么要如何查看TurboFan的IR呢?<strong>一个好消息是,V8也像GraalVm一样,提供了一个图形化的工具,来查看TurboFan的IR。这个工具是**turbolizer</strong>,它位于V8源代码的tools/turbolizer目录下。你可以按照该目录下的README.md文档,构建一下该工具,并运行它。
|
||||
|
||||
```
|
||||
python -m SimpleHTTPServer 8000
|
||||
|
||||
```
|
||||
|
||||
它实际启动了一个简单的Web服务。你可以在浏览器中输入“0.0.0.0:8000”,打开turbolizer的界面。
|
||||
|
||||
在运行d8的时候,如果带上参数“–trace-turbo”,就会在当前目录下输出一个.json文件,打开这个文件就能显示出TurboFan的IR来。比如,上一讲的示例程序add.js,所显示出的add函数的IR:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/a9/9dd569674080e7f58455b0f1432276a9.jpg" alt="">
|
||||
|
||||
界面中最左边一栏是源代码,中间一栏是IR,最右边一栏是生成的汇编代码。
|
||||
|
||||
上图中的IR只显示了控制节点。你可以在工具栏中找到一个按钮,把所有节点都呈现出来。在左侧的Info标签页中,还有一些命令的快捷键,你最好熟悉一下它们,以便于控制IR都显示哪些节点。这对于一个大的IR图来说很重要,否则你会看得眼花缭乱:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/d8/771f9962ed9788656124d63b14c1a2d8.jpg" alt="">
|
||||
|
||||
在这个图中,不同类型的节点用了不同的颜色来区分:
|
||||
|
||||
- 黄色:控制流的节点,比如Start、End和Return;
|
||||
- 淡蓝色:代表一个值的节点;
|
||||
- 红色:JavaScript层级的操作,比如JSEqual、JSToBoolean等;
|
||||
- 深蓝色:代表一种中间层次的操作,介于JavaScript层和机器层级中间;
|
||||
- 绿色:机器级别的语言,代表一些比较低层级的操作。
|
||||
|
||||
在turbolizer的界面上,还有一个下拉菜单,里面有多个优化步骤。你可以挨个点击,看看经过不同的优化步骤以后,IR的变化是什么样子的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/fe/3f37d37bfa29b0fa0531ebbaea5a3afe.jpg" alt="">
|
||||
|
||||
你可以看到,在第一步“v8.TFBytecodeGraphBuilder”阶段显示的IR中,它显示的节点还是有点儿多。我们先隐藏掉与计算功能无关的节点,得到了下面的主干。**你要注意其中的绿色节点**,这里已经进行了类型推测,因此它采用了一个整型计算节点:SpeculativeSafeIntegerAdd。
|
||||
|
||||
这个节点的功能是:当两个参数都是整数的时候,那就符合类型推断,做整数加法操作;如果不符合类型推断,那么就执行逆优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/94/159b51ee21c0yy129d251c5ee3660e94.jpg" alt="">
|
||||
|
||||
你可以再去点击其他的优化步骤,看看图形会有什么变化。
|
||||
|
||||
**在v8.TFGenericLowering阶段**,我们得到了如下所示的IR图,这个图只保留了计算过程的主干。里面增加了两个绿色节点,这两个节点就是把标记指针转换成整数的;还增加了一个深蓝色的节点,这个节点是在函数返回之前,把整数再转换成标记指针。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/3a/a6f4a7ba782c38e604469bda41310c3a.jpg" alt="">
|
||||
|
||||
**在v8.TFLateGraphTrimming阶段**,图中的节点增加了更多的细节,它更接近于具体CPU架构的汇编代码。比如,我们把前面图6中的标记指针,转换成32位整数的操作,就变成了两个节点:
|
||||
|
||||
- TruncateInt64ToInt32:把64位整型截短为32位整型;
|
||||
- Word32Sar:32位整数的移位运算,用于把标记指针转换为整数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5y/a4/5yydb5b9e989b15c39c19585035765a4.jpg" alt="">
|
||||
|
||||
这三个阶段就形象地展示出了TurboFan的IR是如何Lower的,从比较抽象的、机器无关的节点,逐步变成了与具体架构相关的操作。
|
||||
|
||||
所以,基本上IR的节点可以分为四类:顶层代表复杂操作的JavaScript节点、底层代表简单操作的机器节点、处于二者之间做了一定简化的节点,以及可以被各个层次共享的节点。
|
||||
|
||||
刚才我们对V8做优化编译时,所采用的IR的分析,只关注了与加法计算有关的主干节点。你还可以用同样的方法,来看看其他的节点。这些节点主要是针对异常情况的处理。比如,如果发现参数类型不是整型,那么就要去执行逆优化。
|
||||
|
||||
在做完了所有的优化之后,编译器就会进入指令排序、寄存器分配等步骤,最后生成机器码。
|
||||
|
||||
现在,你就了解了TurboFan是如何借助Sea of Nodes来做优化和Lower的了。但我们还没有涉及具体的优化算法。**那么,什么优化算法会帮助V8提升性能呢?**
|
||||
|
||||
前面在研究[Java的JIT编译器](https://time.geekbang.org/column/article/257504)的时候,我们重点关注了内联和逃逸分析的优化算法。那么,对于JavaScript来说,这两种优化也同样非常重要,一样能带来巨大的优化效果。
|
||||
|
||||
我们先来看看**内联优化**。对于之前的示例程序,由于我们使用了“–turbo-filter=add”选项来运行代码,因此TurboFan只会编译add方法,这就避免了顶层函数把add函数给内联进去。而如果你去掉了这个选项,就会发现TurboFan在编译完毕以后,程序后面的运行速度会大大加快,一闪而过。这是因为整个顶层函数都被优化编译了,并且在这个过程中,把add函数给内联进去了。
|
||||
|
||||
然后再说说**逃逸分析**。V8运用逃逸分析算法,也可以像Java的编译器一样,把从堆中申请的内存优化为从栈中申请(甚至使用寄存器),从而提升性能,并避免垃圾收集带来的消耗。
|
||||
|
||||
不过,JavaScript和Java的对象体系设计毕竟是不一样的。在Java的类里,每个成员变量相对于对象指针的偏移量都是固定的;而JavaScript则在内部用了**隐藏类**来表示对象的内存布局。这也引出V8的另一个有特色的优化主题:**内联缓存**。
|
||||
|
||||
那接下来,我就带你详细了解一下V8的隐藏类和内联缓存机制。
|
||||
|
||||
### 隐藏类(Shapes)和内联缓存(Inline Caching)
|
||||
|
||||
隐藏类,学术上一般叫做Hidden Class,但不同的编译器的叫法也不一样,比如Shapes、Maps,等等。
|
||||
|
||||
**隐藏类有什么用呢?**你应该知道,在JavaScript中,你不需要先声明一个类,才能创建一个对象。你可以随时创建对象,比如下面的示例程序中,就创建了几个表示坐标点的对象:
|
||||
|
||||
```
|
||||
point1 = {x:2, y:3};
|
||||
point2 = {x:4, y:5};
|
||||
point3 = {y:7, x:6};
|
||||
point4 = {x:8, y:9, z:10};
|
||||
|
||||
```
|
||||
|
||||
那么,V8在内部是怎么来存储x和y这些对象属性的呢?
|
||||
|
||||
如果按照Java的内存布局方案,一定是在对象头后面依次存放x和y的值;而如果按照Python的方案,那就需要用一个字典来保存不同属性的值。但显然用类似Java的方案更加节省内存,访问速度也更快。
|
||||
|
||||
所以,V8内部就采用了隐藏类的设计。如果两个对象,有着相同的属性,并且顺序也相同,那么它们就对应相同的隐藏类。
|
||||
|
||||
在上面的程序中,point1和point2就对应着同一个隐藏类;而point3和point4要么是属性的顺序不同,要么是属性的数量不同,对应着另外的隐藏类。
|
||||
|
||||
所以在这里,你就会得到一个**编写高性能程序的要点**:对于相同类型的对象,一定要保证它们的属性数量和顺序完全一致,这样才会被V8当作相同的类型,从而那些基于类型推断的优化才会发挥作用。
|
||||
|
||||
此外,V8还用到了一种叫做**内联缓存**的技术,来**加快对象属性的访问时间**。它的原理是这样的:当V8第一次访问某个隐藏类的属性的时候,它要到隐藏类里,去查找某个属性的地址相对于对象指针的偏移量。但V8会把这个偏移量缓存下来,这样下一次遇到同样的shape的时候,直接使用这个缓存的偏移量就行了。
|
||||
|
||||
比如下面的示例代码,如果对象o是上面的point1或point2,属性x的地址偏移量就是相同的,因为它们对应的隐藏类是一样的:
|
||||
|
||||
```
|
||||
function getX(o){
|
||||
return o.x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了内联优化技术,那么V8只有在第一次访问某个隐藏类的属性时,速度会慢一点,之后的访问效率就跟Java的差不多了。因为Java这样的静态类型的代码,在编译期就可以确定每个属性相对于对象地址的偏移量。
|
||||
|
||||
好,现在你已经了解了TurboFan做优化的一些关键思路。接下来,我们再返回来,重新探讨一下Ignition的运行速度问题。
|
||||
|
||||
## 提升Ignition的速度
|
||||
|
||||
最新版本的V8已经不需要多级的编译器了,只需要一个解释器(Ignition)和一个优化编译器(TurboFan)就行。在公开的测试数据中,Ignition的运行速度,已经接近一个基线编译器生成的机器码的速度了,也就是那种没有做太多优化的机器码。
|
||||
|
||||
这听上去似乎不符合常理,毕竟,解释执行怎么能赶得上运行机器码呢?所以,这里一定有一些值得探究的技术原理。
|
||||
|
||||
让我们再来看看Ignition解释执行的原理吧。
|
||||
|
||||
在上一讲中你已经了解到,V8的字节码是很精简的。比如,对于各种加法操作,它只有一个Add指令。
|
||||
|
||||
但是我们知道,Add指令的语义是很复杂的,比如在ECMAScript标准中,就对加法的语义有很多的规定,如数字相加该怎么做、字符串连接该怎么做,等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/4a/0b8cc0fd4ef25512aa4f3b657f5c014a.jpg" alt="">
|
||||
|
||||
这样的话,**V8在解释执行Add指令的时候,就要跳到一个内置的函数去执行,其他指令也是如此。这些内置函数的实现质量,就会大大影响解释器的运行速度。**
|
||||
|
||||
那么如果换做你,你会怎么实现这些内置函数呢?
|
||||
|
||||
**选择1:用汇编语言去实现**。这样,我们可以针对每种情况写出最优化的代码。但问题是,这样做的工作量很大。因为V8现在已经支持了9种架构的CPU,而要为每种架构编写这些内置功能,都需要敲几万行的汇编代码。
|
||||
|
||||
**选择2:用C++去实现**。这是一个不错的选择,因为C++代码最后编译的结果也是很优化的。不过这里也有一个问题:C++有它自己的调用约定,跟V8中JavaScript的调用约定是不同的。
|
||||
|
||||
比如,在调用C++的内置函数之前,解释器要把自己所使用的物理寄存器保护起来,避免被C++程序破坏,在调用完毕以后还要恢复。这使得从解释器到内置函数,以及从内置函数回到解释器,都要做不少的转换工作。你还要写专门的代码,来对标记指针进行转换。而如果要使用V8的对象,那要处理的事情就更多了,比如它要去隐藏类中查找信息,以及能否通过优化实现栈上内存分配,等等。
|
||||
|
||||
**那么,我们还有别的选择吗?**
|
||||
|
||||
有的。你看,V8已经有了一个不错的优化编译器TurboFan。**既然它能产生很高效的代码,那么我们为什么不直接用TurboFan来生成机器码呢?**这个思路其实是可行的。这可以看做是V8编译器的一种自举能力,用自己的编译器,来生成自己内部要使用的内置函数的目标代码。
|
||||
|
||||
毕竟,TurboFan本来就是要处理标记指针、JavaScript对象的内存表示等这些问题。这个方案还省去了做调用约定的转换的工作,因为本来V8执行的过程中,就要不断在解释执行和运行机器码之间切换,V8内部对栈桢和调用约定的设计,就是要确保这种切换的代价最低。
|
||||
|
||||
在具体实现的时候,编写这些内置函数是用JavaScript调用TurboFan提供的一些宏。这些宏可以转化为TurboFan的IR节点,从而跟TurboFan的优化编译功能无缝衔接。
|
||||
|
||||
好了,分析到这里,你就知道为什么Ignition的运行速度会这么快了:**它采用了高度优化过的内置函数的实现,并且没有调用约定转换的负担。**而一个基线编译器生成的机器码,因为没有经过充分的优化,反倒并没有那么大的优势。
|
||||
|
||||
再补充一点,V8对字节码也提供了一些优化算法。比如,通过优化,可以减少对临时变量的使用,使得代码可以更多地让累加器起到临时变量的作用,从而减少内存访问次数,提高运行效率。如果你有兴趣在这个话题上去做深入研究,可以参考我在文末链接中给出的一篇论文。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲,我围绕运行速度这个主题,给你讲解了V8在TurboFan和Ignition上所采用的优化技术。你需要记住以下几个要点:
|
||||
|
||||
- 第一,由于JavaScript是动态类型的语言,所以优化的要点,就是推断出函数参数的类型,并形成有针对性的优化代码。
|
||||
- 第二,同Graal一样,V8也使用了Sea of Nodes的IR,而且对V8来说,内联和逃逸优化算法仍然非常重要。我在[解析Graal编译器](https://time.geekbang.org/column/article/257504)的时候已经给你介绍过了,所以这一讲并没有详细展开,你可以自己去回顾复习一下。
|
||||
- 第三,V8所采用的内联缓存技术,能够在运行期提高对象属性访问的性能。另外你要注意的是,在编写代码的时候,一定要避免对于相同的对象生成不同的隐藏类。
|
||||
- 第四,Ignition采用了TurboFan来编译内置函数,这种技术非常聪明,既省了工作量,又简化了系统的结构。实际上,在Graal编译器里也有类似的技术,它叫做Snippet,也是用自身的中后端功能来编译内置函数。所以,你会再次发现,多个编译器之间所采用的编译技术,是可以互相印证的。
|
||||
|
||||
这节课的思维导图我同样帮你整理出来了,供你参考和复习:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/45/9450db3e9fcba439e249bdcfabd62145.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们已经学了两种动态类型的语言的编译技术:Python和JavaScript。那我现在问一个开脑洞的问题:如果你要给Python加一个JIT编译器,那么你可以从JavaScript这里借鉴哪些技术呢?在哪些方面,编译器会得到巨大的性能提升?
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. V8的指针压缩技术:[Pointer Compression in V8](https://v8.dev/blog/pointer-compression)
|
||||
1. 介绍V8基于推理的优化机制:[An Introduction to Speculative Optimization in V8](https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8)
|
||||
1. 对Ignition字节码做优化的论文:[Register equivalence optimization](https://docs.google.com/document/d/1wW_VkkIwhAAgAxLYM0wvoTEkq8XykibDIikGpWH7l1I/edit?ts=570d7131#heading=h.6jz9dj3bnr8t),我在GitHub上也放了一份[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition_%20Register%20Equivalence%20Optimization.pdf)
|
||||
324
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/22 | Julia编译器(一):如何让动态语言性能很高?.md
Normal file
324
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/22 | Julia编译器(一):如何让动态语言性能很高?.md
Normal file
@@ -0,0 +1,324 @@
|
||||
<audio id="audio" title="22 | Julia编译器(一):如何让动态语言性能很高?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/41/81eb38fdc579584de51134af35850941.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
Julia这门语言,其实是最近几年才逐渐获得人们越来越多的关注的。有些人会拿它跟Python进行比较,认为Julia已经成为了Python的劲敌,甚至还有人觉得它在未来可能会取代Python的地位。虽然这样的说法可能是有点夸张了,不过Julia确实是有它的可取之处的。
|
||||
|
||||
为什么这么说呢?前面我们已经研究了Java、Python和JavaScript这几门主流语言的编译器,这几门语言都是很有代表性的:Java语言是静态类型的、编译型的语言;Python语言是动态类型的、解释型的语言;JavaScript是动态类型的语言,但可以即时编译成本地代码来执行。
|
||||
|
||||
而Julia语言却声称同时兼具了静态编译型和动态解释型语言的优点:**一方面它的性能很高,可以跟Java和C语言媲美;而另一方面,它又是动态类型的,编写程序时不需要指定类型。**一般来说,我们很难能期望一门语言同时具有动态类型和静态类型语言的优点的,那么Julia又是如何实现这一切的呢?
|
||||
|
||||
原来它是充分利用了LLVM来实现即时编译的功能。因为LLVM是Clang、Rust、Swift等很多语言所采用的后端工具,所以我们可以借助Julia语言的编译器,来研究如何恰当地利用LLVM。不过,Julia使用LLVM的方法很有创造性,使得它可以同时具备这两类语言的优点。我将在这一讲中给你揭秘。
|
||||
|
||||
此外,Julia编译器的类型系统的设计也很独特,它体现了函数式编程的一些设计哲学,能够帮助你启迪思维。
|
||||
|
||||
还有一点,Julia来自MIT,这里也曾经是Lisp的摇篮,所以Julia有一种学术风和极客风相结合的品味,也值得你去仔细体会一下。
|
||||
|
||||
所以,接下来的两讲,我会带你来好好探究一下Julia的编译器。你从中能够学习到Julia编译器的处理过程,如何创造性地使用LLVM的即时编译功能、如何使用LLVM的优化功能,以及它的独特的类型系统和方法分派。
|
||||
|
||||
那今天这一讲,我会先带你来了解Julia的编译过程,以及它高性能背后的原因。
|
||||
|
||||
## 初步认识Julia
|
||||
|
||||
**Julia的性能有多高呢?**你可以去它的网站上看看与其他编程语言的[性能对比](https://julialang.org/benchmarks/):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/a4/aa683bb1d95381b88d2041d4102968a4.jpg" alt="">
|
||||
|
||||
可以看出,它的性能是在C、Rust这一个级别的,很多指标甚至比Java还要好,比起那些动态语言(如Python、R和Octave),那更是高了一到两个数量级。
|
||||
|
||||
所以,Julia的编译器声称它具备了静态类型语言的性能,确实是不虚此言的。
|
||||
|
||||
你可以从[Julia的官网](https://julialang.org/)下载Julia的二进制版本和源代码。如果你下载的是源代码,那你可以用make debug编译成debug版本,这样比较方便用GDB或LLDB调试。
|
||||
|
||||
Julia的设计目的主要是用于**科学计算**。过去,这一领域的用户主要是使用R语言和Python,但麻省理工(MIT)的研究者们对它们的性能不够满意,同时又想要保留R和Python的友好性,于是就设计出了这门新的语言。目前这门语言受到了很多用户的欢迎,使用者也在持续地上升中。
|
||||
|
||||
我个人对它感兴趣的点,正是因为它打破了静态编译和动态编译语言的边界,我认为这体现了未来语言的趋势:**编译和优化的过程是全生命周期的,而不局限在某个特定阶段。**
|
||||
|
||||
好了,让我们先通过一个例子来认识Juia,直观了解一下这门语言的特点:
|
||||
|
||||
```
|
||||
julia> function countdown(n)
|
||||
if n <= 0
|
||||
println("end")
|
||||
else
|
||||
print(n, " ")
|
||||
countdown(n-1)
|
||||
end
|
||||
end
|
||||
countdown (generic function with 1 method)
|
||||
|
||||
julia> countdown(10)
|
||||
10 9 8 7 6 5 4 3 2 1 end
|
||||
|
||||
```
|
||||
|
||||
所以从这段示例代码中,可以看出,Julia主要有这样几个特点:
|
||||
|
||||
- 用function关键字来声明一个函数;
|
||||
- 用end关键字作为块(函数声明、if语句、for语句等)的结尾;
|
||||
- 函数的参数可以不用指定类型(变量声明也不需要),因为它是动态类型的;
|
||||
- Julia支持递归函数。
|
||||
|
||||
**那么Julia的编译器是用什么语言实现的呢?又是如何支持它的这些独特的特性的呢?**带着这些好奇,让我们来看一看Julia编译器的源代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/9f/7cdbbdbe9a95d0077331ayy8b8608f9f.jpg" alt="">
|
||||
|
||||
其实Julia的实现会让人有点困扰,因为它使用了4种语言:C、C++、Lisp和Julia自身。相比而言,CPython的实现只用了两种语言:C语言和Python。这种情况,就对社区的其他技术人员理解这个编译器和参与开发,带来了不小的困难。
|
||||
|
||||
Julia的作者用C语言实现了一些运行时的核心功能,包括垃圾收集器。他们是比较偏爱C语言的。C++主要是用来实现跟LLVM衔接的功能,因为LLVM是用C++实现的。
|
||||
|
||||
但是,为什么又冒出了一个Lisp语言呢?而且前端部分的主要功能都是用Lisp实现的。
|
||||
|
||||
原来,Julia中用到Lisp叫做[femtolisp](https://github.com/JeffBezanson/femtolisp)(简称flisp),这是杰夫·贝赞松(Jeff Bezanson)做的一个开源Lisp实现,当时的目标是做一个最小的、编译速度又最快的Lisp版本。后来Jeff Bezanson作为Julia的核心开发人员,又把flisp带进了Julia。
|
||||
|
||||
实际上,Julia语言本身也宣称是继承了Lisp语言的精髓。在其核心的设计思想里,在函数式编程风格和元编程功能方面,也确实是如此。Lisp在研究界一直有很多的追随者,Julia这个项目诞生于MIT,同时又主要服务于各种科研工作者,所以它也就带上了这种科学家的味道。它还有其他特性,也能看出这种科研工作者的倾向,比如说:
|
||||
|
||||
- 对于类型系统,Julia的开发者们进行了很好的形式化,是我在所有语言中看到的最像数学家做的类型系统。
|
||||
- 在它的语法和语义设计上,带有Metalab和Mathematics这些数学软件的痕迹,科研工作者们应该很熟悉这种感觉。
|
||||
- 在很多特性的实现上,都带有很强的前沿探索的特征,锋芒突出,不像我们平常使用的那些商业公司设计的计算机语言一样,追求四平八稳。
|
||||
|
||||
以上就是我对Julia的感觉,一种**结合了数据家风格的自由不羁的极客风**。实际上,Lisp最早的设计者约翰·麦卡锡(John McCarthy)就是一位数学博士,所以数学上的美感是Lisp给人的感受之一。而且,Lisp语言本身也是在MIT发源的,所以Julia可以说是继承了这个传统、这种风格。
|
||||
|
||||
## Julia的编译过程
|
||||
|
||||
刚刚说了,Julia的前端主要是用Lisp来实现的。你在启动Julia的时候,通过“–lisp”参数就可以进入flisp的REPL:
|
||||
|
||||
```
|
||||
./julia --lisp
|
||||
|
||||
```
|
||||
|
||||
在这个REPL界面中调用一个julia-parse函数,就可以把一个Julia语句编译成AST。
|
||||
|
||||
```
|
||||
> (julia-parse "a = 2+3*5")
|
||||
(= a (call + 2
|
||||
(call * 3 5)))
|
||||
> (julia-parse "function countdown(n)
|
||||
if n <= 0
|
||||
println(\"end\")
|
||||
else
|
||||
print(n, \" \")
|
||||
countdown(n-1)
|
||||
end
|
||||
end")
|
||||
(function (call countdown n) (block (line 2 none)
|
||||
(if (call <= n 0)
|
||||
(block (line 3 none)
|
||||
(call println "end"))
|
||||
(block (line 5 none)
|
||||
(call print n " ")
|
||||
(line 6 none)
|
||||
(call countdown (call - n 1))))))
|
||||
|
||||
```
|
||||
|
||||
编译后的AST,采用的也是Lisp那种括号嵌套括号的方式。
|
||||
|
||||
Julia的编译器中,主要用到了几个“`.scm`”结尾的代码,来完成词法和语法分析的功能:julia-parser.scm、julia-syntax.scm和ast.scm。(`.scm`文件是Scheme的缩写,而Scheme是Lisp的一种实现,特点是设计精简、语法简单。著名的计算机教科书[SICP](http://mitpress.mit.edu/sicp/)就是用[Scheme](http://www.gnu.org/software/mit-scheme/)作为教学语言,而SICP和Scheme也都是源自MIT。)它的词法分析和语法分析的过程,主要是在parser.scm文件里实现的,我们刚才调用的“julia-parse”函数就是在这个文件中声明的。
|
||||
|
||||
Julia的语法分析过程仍然是你非常熟悉的递归下降算法。因为Lisp语言处理符号的能力很强,又有很好的元编程功能(宏),所以Lisp在实现词法分析和语法分析的任务的时候,代码会比其他语言更短。但是不熟悉Lisp语言的人,可能会看得一头雾水,因为这种括号嵌套括号的语言对于人类的阅读不那么友好,不像Java、JavaScript这样的语言一样,更像自然语言。
|
||||
|
||||
julia-parser.scm输出的成果是比较经典的AST,Julia的文档里叫做“[表面语法AST](http://mortenpi.eu/documenter-html/devdocs/ast.html#Surface-syntax-AST-1)”(surface syntax AST)。所谓表面语法AST,它是跟另一种IR对应的,叫做[Lowered Form](http://mortenpi.eu/documenter-html/devdocs/ast.html#Lowered-form-1)。
|
||||
|
||||
“Lowered”这个词你应该已经很熟悉了,它的意思是**更靠近计算机的物理实现机制**。比如,LLVM的IR跟AST相比,就更靠近底层实现,也更加不适合人类阅读。
|
||||
|
||||
julia-syntax.scm输出的结果就是Lowered Form,这是一种内部IR。它比AST的节点类型更少,所有的宏都被展开了,控制流也简化成了无条件和有条件跳转的节点(“goto”格式)。这种IR后面被用来做类型推断和代码生成。
|
||||
|
||||
你查看julia-syntax.scm的代码,会发现Julia编译器的处理过程是由多个Pass构成的,包括了去除语法糖、识别和重命名本地变量、分析变量的作用域和闭包、把闭包函数做转换、转换成线性IR、记录Slot和标签(label)等。
|
||||
|
||||
这里,我根据Jeff Bezanson在JuliaCon上讲座的内容,把Julia编译器的工作过程、每个阶段涉及的源代码和主要的函数给你概要地梳理了一下,你可以只看这张图,就能大致把握Julia的编译过程,并且可以把它跟你学过的其他几个编译器做一下对比:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/ca/74a68334b5d1661c94a2477982b374ca.jpg" alt="">
|
||||
|
||||
Julia有很好的[反射(Reflection)和自省(Introspection)](http://mortenpi.eu/documenter-html/devdocs/reflection.html#Reflection-and-introspection-1)的能力,你可以调用一些函数或者宏来观察各个阶段的成果。
|
||||
|
||||
比如,采用@code_lowered宏,来看countdown(10)产生的代码,你会看到if…else…的结构被转换成了“goto”语句,这个IR看上去已经有点像LLVM的IR格式了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/dc/102687d427f716eb792545ddef01eddc.jpg" alt="">
|
||||
|
||||
进一步,你还可以用@code_typed宏,来查看它做完类型推断以后的结果,每条语句都标注了类型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/c3/3363dc9cb96e3d545f9485a900b90fc3.jpg" alt="">
|
||||
|
||||
接下来,你可以用@code_llvm和@code_native宏,来查看生成的LLVM IR和汇编代码。这次,我们用一个更简单的函数foo(),让生成的代码更容易看懂:
|
||||
|
||||
```
|
||||
julia> function foo(x,y) #一个简单的函数,把两个参数相加
|
||||
x+y #最后一句的结果就是返回值,这里可以省略return
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
通过@code_llvm宏生成的LLVM IR,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/35/c1bb01e06ed7262107b24ab99b7b1f35.jpg" alt="">
|
||||
|
||||
通过@code_native宏输出的汇编代码是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/3c/dfc49ac304b06700a484886e4bf1cf3c.jpg" alt="">
|
||||
|
||||
最后生成的汇编代码,可以通过汇编器迅速生成机器码并运行。
|
||||
|
||||
通过上面的梳理,你应该已经了解了Julia的编译过程脉络:**通过Lisp的程序,把程序变成AST,然后再变成更低级一点的IR,在这个过程中编译器要进行类型推断等语义层面的处理;最后,翻译成LLVM的IR,并生成可执行的本地代码。**
|
||||
|
||||
对于静态类型的语言来说,我们根据准确的类型信息,就可以生成高效的本地代码,这也是C语言性能高的原因。比如,我们用C语言来写一下foo函数:
|
||||
|
||||
```
|
||||
long foo(long x, long y){
|
||||
return x+y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Clang的LLVM IR跟Julia生成的基本一样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/e3/3962f55b94074a305f5a33226a67c6e3.jpg" alt="">
|
||||
|
||||
生成的汇编代码也差不多:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/64/65c786870b31ac1bd831407daa8fa064.jpg" alt="">
|
||||
|
||||
所以,对于这样的程序,Julia的编译后的本地代码,跟C语言的比起来可以说是完全一样。那性能高也就不足为奇了。
|
||||
|
||||
你可能由此就会下结论:因为Julia能够借助LLVM生成本地代码,这就是它性能高的原因。
|
||||
|
||||
**且慢!事情没有这么简单。**为什么这么说?因为在基于前面生成的机器码的这个例子中,当参数是整型的时候,运行效率自然是会比较快。但是,你别忘了Julia是动态类型的语言。我们在Julia中声明foo函数的时候,并没有指定参数的数据类型。如果参数类型变了,会怎样呢?
|
||||
|
||||
## Julia的最大突破:生成多个版本的目标代码
|
||||
|
||||
实际上,我们可以给它传递不同的参数,比如可以传递两个浮点数给它,甚至传递两个向量或者矩阵给它,都能得到正确的结果:
|
||||
|
||||
```
|
||||
julia> foo(2.1, 3.2)
|
||||
5.300000000000001
|
||||
|
||||
julia> foo([1,2,3], [3,4,5])
|
||||
3-element Array{Int64,1}:
|
||||
4
|
||||
6
|
||||
8
|
||||
|
||||
```
|
||||
|
||||
显然,如果上面两次对foo()函数的调用,我们也是用之前生成的汇编代码,那是行不通的。因为之前的汇编代码只能用于处理64位的整数。
|
||||
|
||||
实际上,如果我们观察调用foo(2.1, 3.2)时,Julia生成的LLVM IR和汇编代码,就会发现,它智能地适应了新的数据类型,生成了用于处理浮点数的代码,使用了不同的指令和寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/51/6ea3a61f54ffb74c66d6c5386f0e7c51.jpg" alt="">
|
||||
|
||||
你可以用同样的方法,来试一下 `foo([1,2,3], [3,4,5])` 对应的LLVM IR和汇编代码。这个就要复杂一点了,因为它要处理数组的存储。但不管怎样,Julia生成的代码确实是适应了它的参数类型的。
|
||||
|
||||
数学中的很多算法,其实是概念层面的,它不关心涉及的数字是32位整数、64位整数,还是一个浮点数。但同样是实现一个加法操作,对于计算机内部实现来说,不同的数据类型对应的指令则是完全不同的,那么编译器就要弥合抽象的算法和计算机的具体实现之间的差别。
|
||||
|
||||
对于C语言这样的静态语言来说,它需要针对x、y的各种不同的数据类型,分别编写不同的函数。这些函数的逻辑是一样的,但就是因为数据类型不同,我们就要写很多遍。这是不太合理的,太啰嗦了。
|
||||
|
||||
对于Python这样的动态类型语言来说呢,倒是简洁地写一遍就可以了。但在运行时,对于每一次运算,我们都要根据数据类型来选择合适的操作。这样就大大拉低了整体的运行效率。
|
||||
|
||||
所以,这才是Julia真正的突破:**它能针对同一个算法,根据运行时获得的数据,进行类型推断,并编译生成最优化的本地代码。**在每种参数类型组合的情况下,只要编译一次,就可以被缓存下来,可以使用很多次,从而使得程序运行的总体性能很高。
|
||||
|
||||
你对比一下JavaScript编译器基于类型推断的编译优化,就会发现它们之间的明显的不同。JavaScript编译器一般只会针对类型推断生成一个版本的目标代码,而Julia则会针对每种参数类型组合,都生成一个版本。
|
||||
|
||||
不过,既然Julia编译器存在多个版本的目标代码,那么在运行期,就要有一个程序来确定到底采用哪个版本的目标代码,这就是Julia的一个非常重要的功能:**函数分派算法**。
|
||||
|
||||
函数分派,就是指让编译器在编译时或运行时来确定采用函数的哪个实现版本。针对函数分派,我再给你讲一下Julia的一个特色功能,多重分派。这个知识点有助于你加深对于函数分派的理解,也有助于你理解函数式编程的特点。
|
||||
|
||||
## Julia的多重分派功能
|
||||
|
||||
我们在编程的时候,经常会涉及同一个函数名称的多个实现。比如在做面向对象编程的时候,同一个类里可以有多个相同名称的方法,但参数不同,这种现象有时被叫做**重载(Overload)**;同时,在这个类的子类里,也可以定义跟父类完全相同的方法,这种现象叫做**覆盖(Override)**。
|
||||
|
||||
而程序在调用一个方法的时候,到底调用的是哪个实现,有时候我们在编译期就能确定下来,有时候却必须到运行期才能确定(就像多态的情形),这两种情形就分别叫做**静态分派(Static Dispatch)和动态分派(Dynamic Dispatch)**。
|
||||
|
||||
方法的分派还有另一个分类:**单一分派(Single Dispatch)和多重分派(Multiple Dispatch)**。传统的面向对象的语言使用的都是单一分派。比如,在面向对象语言里面,实现加法的运算:
|
||||
|
||||
```
|
||||
a.add(b)
|
||||
|
||||
```
|
||||
|
||||
这里我们假设a和b都有多个add方法的版本,但实际上,无论怎样分派,程序的调用都是分派到a对象的方法上。这是因为,对于add方法,实质上它的第一个参数是对象a(编译成目标代码时,a会成为第一个参数,以便访问封装在a里面的数据),也就是相当于这样一个函数:
|
||||
|
||||
```
|
||||
add(a, b)
|
||||
|
||||
```
|
||||
|
||||
所以,**面向对象的方法分派相当于是由第一个参数决定的。这种就是单一分派**。
|
||||
|
||||
实际上,采用面向对象的编程方式,在方法分派时经常会让人觉得很别扭。你回顾一下,我在讲[Python编译器](https://time.geekbang.org/column/article/261063)的时候,讲到加法操作采用的实现是第一个操作数对象的类型里,定义的与加法有关的函数。**但为什么它是用第一个对象的方法,而不是第二个对象的呢?如果第一个对象和第二个对象的类型不同怎么办呢?**(这就是我在那讲中留给你的问题)
|
||||
|
||||
还有一个很不方便的地方。如果你增加了一种新的数据类型,比如矩阵(Matrix),它要能够跟整数、浮点数等进行加减乘除运算,但你没有办法给Integer和Float这些已有的类增加方法。
|
||||
|
||||
所以,针对这些很别扭的情况,Julia和Lisp等函数式语言,就支持**多重分派**的方式。
|
||||
|
||||
你只需要定义几个相同名称的函数(在Julia里,这被叫做同一个函数的多个方法),编译器在运行时会根据参数,决定分派给哪个方法。
|
||||
|
||||
我们来看下面这个例子,foo函数有两个方法,根据调用参数的不同,分别分派给不同的方法。
|
||||
|
||||
```
|
||||
julia> foo(x::Int64, y::Int64) = x + y #第一个方法
|
||||
foo (generic function with 1 method)
|
||||
|
||||
julia> foo(x, y) = x - y #第二个方法
|
||||
foo (generic function with 2 methods)
|
||||
|
||||
julia> methods(foo) #显示foo函数的所有方法
|
||||
# 2 methods for generic function "foo":
|
||||
[1] foo(x::Int64, y::Int64) in Main at REPL[38]:1
|
||||
[2] foo(x, y) in Main at REPL[39]:1
|
||||
|
||||
julia> foo(2, 3) #分派到第一个方法
|
||||
5
|
||||
|
||||
julia> foo(2.0, 3) #分派到第二个方法
|
||||
-1.0
|
||||
|
||||
```
|
||||
|
||||
你可以发现,这种分派方法会**公平对待函数的所有参数,而不是由一个特殊的参数来决定。这种分派方法就叫做多重分派。**
|
||||
|
||||
在Julia中,其实“+”操作符(以及其他操作符)也是函数,它有上百个不同的方法,分别处理不同数据类型的加法操作。
|
||||
|
||||
```
|
||||
julia> methods(+)
|
||||
# 166 methods for generic function "+":
|
||||
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:282
|
||||
[2] +(x::Bool, y::Bool) in Base at bool.jl:96
|
||||
[3] +(x::Bool) in Base at bool.jl:93
|
||||
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:104
|
||||
[5] +(x::Bool, z::Complex) in Base at complex.jl:289
|
||||
[6] +(a::Float16, b::Float16) in Base at float.jl:398
|
||||
[7] +(x::Float32, y::Float32) in Base at float.jl:400
|
||||
[8] +(x::Float64, y::Float64) in Base at float.jl:401
|
||||
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:283
|
||||
...
|
||||
[165] +(J::LinearAlgebra.UniformScaling, F::LinearAlgebra.Hessenberg) in LinearAlgebra ... at hessenberg.jl:518
|
||||
[166] +(a, b, c, xs...) in Base at operators.jl:529
|
||||
|
||||
```
|
||||
|
||||
最重要的是,当你引入新的数据类型,想要支持加法运算的时候,你只需要为加法函数定义一系列新的方法,那么编译器就可以正确地分派了。这种实现方式就方便多了。这也是某些函数式编程语言的一个优势,你可以体会一下。
|
||||
|
||||
而且在Julia中,因为方法分派是动态实现的,所以分派算法的性能就很重要。你看,不同的语言特性的设计,它的运行时就要完成不同的任务。这就是真实世界中,各种编译器的魅力所在。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我给你介绍了一门并不是那么大众的语言,Julia。介绍它的原因,就是因为这门语言有自己非常独特的特点,非常值得我们学习。我希望你能记住以下几点核心的知识:
|
||||
|
||||
- **编译器的实现语言**:编译器在选择采用什么实现的语言上,拥有很大的自由度。Julia很别具一格地采用了Lisp作为主要的前端语言。不过,我个人猜测,既然Julia本身也算是一种Lisp实现,未来可能就可以用Julia取代flisp,来实现前端的功能,实现更大程度的自举(Bootstraping)了。当然,这仅仅是我自己的猜测。
|
||||
- **又是递归下降算法**:一如既往地,递归下降算法仍然是最常被用来实现语法分析的方法,Julia也不例外。
|
||||
- **多种IR**:Julia在AST之外,采用了“goto”格式的IR,还用到了LLVM的IR(实际上,LLVM内部在转换成本地代码之前,还有一个中间格式的IR)。
|
||||
- **多版本的目标代码**:Julia创造性地利用了LLVM的即时编译功能。它可以在运行期通过类型推断确定变量的类型,进行即时编译,根据不同的参数类型生成多个版本的目标代码,让程序员写一个程序就能适应多种数据类型,既降低了程序员的工作量,同时又保证了程序的高性能。这使得Julia同时拥有了动态类型语言的灵活性和静态类型语言的高性能。
|
||||
- **多重分派功能**:多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。
|
||||
|
||||
你学了这讲有什么体会呢?深入探究Julia这样的语言的实现过程,真的有助于我们大开脑洞,突破思维的限制,更好地融合编译原理的各方面的知识,从而为你的实际工作带来更加创新的思路。
|
||||
|
||||
这一讲的思维导图我也给你整理出来了,供你参考和复习回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/c6/2a0a7a0b377f70599f98e668d3f172c6.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
一个很有意思的问题:为什么Julia会为一个函数,根据不同的参数类型组合,生成多个版本的目标代码,而JavaScript的引擎一般只会保存一个版本的目标代码?这个问题你可以从多个角度进行思考,欢迎在留言区分享你的观点。
|
||||
|
||||
感谢你的阅读,如果你觉得有收获,欢迎把今天的内容分享给更多的朋友。
|
||||
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="23 | Julia编译器(二):如何利用LLVM的优化和后端功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/e7/897b2766cedaec6e4499664ccd046ae7.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我给你概要地介绍了一下Julia这门语言,带你一起解析了它的编译器的编译过程。另外我也讲到,Julia创造性地使用了LLVM,再加上它高效的分派机制,这就让一门脚本语言的运行速度,可以跟C、Java这种语言媲美。更重要的是,你用Julia本身,就可以编写需要高性能的数学函数包,而不用像Python那样,需要用另外的语言来编写(如C语言)高性能的代码。
|
||||
|
||||
那么今天这一讲,我就带你来了解一下Julia运用LLVM的一些细节。包括以下几个核心要点:
|
||||
|
||||
- **如何生成LLVM IR?**
|
||||
- **如何基于LLVM IR做优化?**
|
||||
- **如何利用内建(Intrinsics)函数实现性能优化和语义个性化?**
|
||||
|
||||
这样,在深入解读了这些问题和知识点以后,你对如何正确地利用LLVM,就能建立一个直观的认识了,从而为自己使用LLVM打下很好的基础。
|
||||
|
||||
好,首先,我们来了解一下Julia做即时编译的过程。
|
||||
|
||||
## 即时编译的过程
|
||||
|
||||
我们用LLDB来跟踪一下生成IR的过程。
|
||||
|
||||
```
|
||||
$ lldb #启动lldb
|
||||
(lldb)attach --name julia #附加到julia进程
|
||||
c #让julia进程继续运行
|
||||
|
||||
```
|
||||
|
||||
首先,在Julia的REPL中,输入一个简单的add函数的定义:
|
||||
|
||||
```
|
||||
julia> function add(a, b)
|
||||
x = a+b
|
||||
x
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
接着,在LLDB或GDB中设置一个断点“br emit_funciton”,这个断点是在codegen.cpp中。
|
||||
|
||||
```
|
||||
(lldb) br emit_function #添加断点
|
||||
|
||||
```
|
||||
|
||||
然后在Julia里执行函数add:
|
||||
|
||||
```
|
||||
julia> add(2,3)
|
||||
|
||||
```
|
||||
|
||||
这会触发Julia的编译过程,并且程序会停在断点上。我整理了一下调用栈的信息,你可以看看,即时编译是如何被触发的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/75/c87ce502a6299f9ec36920a3f5358c75.jpg" alt="">
|
||||
|
||||
通过跟踪执行和阅读源代码,你会发现Julia中最重要的几个源代码:
|
||||
|
||||
- **gf.c**:Julia以方法分派快速而著称。对于类似加法的这种运算,它会有上百个方法的实现,所以在运行时,就必须能迅速定位到准确的方法。分派就是在gf.c里。
|
||||
- **interpreter.c**:它是Julia的解释器。虽然Julia中的函数都是即时编译的,但在REPL中的简单的交互,靠解释执行就可以了。
|
||||
- **codegen.cpp**:生成LLVM IR的主要逻辑都在这里。
|
||||
|
||||
我希望你能自己动手跟踪执行一下,这样你就会彻底明白Julia的运行机制。
|
||||
|
||||
## Julia的IR:采用SSA形式
|
||||
|
||||
在上一讲中,你已经通过@code_lowered和@code_typed宏,查看过了Julia的IR。
|
||||
|
||||
[Julia的IR](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/ssair/ir.jl)也经历了一个发展演化过程,它的IR最早不是SSA的,而是后来才改成了[SSA形式](https://docs.julialang.org/en/v1/devdocs/ssair/)。这一方面是因为,SSA真的是有优势,它能简化优化算法的编写;另一方面也能看出,SSA确实是趋势呀,我们目前接触到的Graal、V8和LLVM的IR,都是SSA格式的。
|
||||
|
||||
Julia的IR主要承担了两方面的任务。
|
||||
|
||||
**第一是类型推断**,推断出来的类型被保存到IR中,以便于生成正确版本的代码。
|
||||
|
||||
**第二是基于这个IR做一些**[**优化**](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/optimize.jl),其实主要就是实现了[内联](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/ssair/inlining.jl)优化。内联优化是可以发生在比较早的阶段,你在Go的编译器中就会看到类似的现象。
|
||||
|
||||
你可以在Julia中写两个短的函数,让其中一个来调用另一个,看看它所生成的LLVM代码和汇编代码是否会被自动内联。
|
||||
|
||||
另外,你还可以查看一下传给emit_function函数的Julia IR是什么样子的。在LLDB里,你可以用下面的命令来显示src参数的值(其中,`jl_(obj)`是Julia为了调试方便提供的一个函数,它能够更好地[显示Julia对象的信息](https://docs.julialang.org/en/v1/devdocs/debuggingtips/#Displaying-Julia-variables-1),注意显示是在julia窗口中)。src参数里面包含了要编译的Julia代码的信息。
|
||||
|
||||
```
|
||||
(lldb) expr jl_(src)
|
||||
|
||||
```
|
||||
|
||||
为了让你能更容易看懂,我稍微整理了一下输出的信息的格式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/dc/3c618c6594d3f021fe36cafc28b222dc.jpg" alt="">
|
||||
|
||||
你会发现,这跟用**@code_typed(add(2,3))命令**打印出来的信息是一致的,只不过宏里显示的信息会更加简洁:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/25/f5be1674c76593192ae65c00f3402325.jpg" alt="">
|
||||
|
||||
接下来,查看emit_function函数,你就能够看到生成LLVM IR的整个过程。
|
||||
|
||||
## 生成LLVM IR
|
||||
|
||||
LLVM的IR有几个特点:
|
||||
|
||||
- 第一,它是SSA格式的。
|
||||
- 第二,LLVM IR有一个类型系统。类型系统能帮助生成正确的机器码,因为不同的字长对应的机器码指令是不同的。
|
||||
- 第三,LLVM的IR不像其他IR,一般只有内存格式,它还有文本格式和二进制格式。你完全可以用文本格式写一个程序,然后让LLVM读取,进行编译和执行。所以,LLVM的IR也可以叫做LLVM汇编。
|
||||
- 第四,LLVM的指令有丰富的元数据,这些元数据能够被用于分析和优化工作中。
|
||||
|
||||
基本上,生成IR的程序没那么复杂,就是用简单的语法制导的翻译即可,从AST或别的IR生成LLVM的IR,属于那种比较幼稚的翻译方法。
|
||||
|
||||
采用这种方法,哪怕一开始生成的IR比较冗余,也没有关系,因为我们可以在后面的优化过程中继续做优化。
|
||||
|
||||
在生成的IR里,会用到Julia的**内建函数**(Intrinsics),它代表的是一些基础的功能。
|
||||
|
||||
在[JavaScript的编译器](https://time.geekbang.org/column/article/263523)里,我们已经接触过**内置函数**(Built-in)的概念了。而在Julia的编译器中,内建函数和内置函数其实是不同的概念。
|
||||
|
||||
内置函数是标准的Julia函数,它可以有多个方法,根据不同的类型来分派。比如,取最大值、最小值的函数max()、min()这些,都是内置函数。
|
||||
|
||||
而内建函数只能针对特定的参数类型,没有多分派的能力。Julia会把基础的操作,都变成对内建函数的调用。在上面示例的IR中,就有一个add_in()函数,也就是对整型做加法运算,它就是内建函数。内建函数的目的是生成LLVM IR。Julia中有近百个内置函数。在[intrinsics.cpp](https://github.com/JuliaLang/julia/blob/v1.4.1/src/intrinsics.cpp)中,有为这些内置函数生成LLVM IR的代码。
|
||||
|
||||
这就是Julia生成LLVM IR的过程:遍历Julia的IR,并调用LLVM的IRBuilder类,生成合适的IR。在此过程中,会遇到很多内建函数,并调用内建函数输出LLVM IR的逻辑。
|
||||
|
||||
## 运行LLVM的Pass
|
||||
|
||||
我们之所以会使用LLVM,很重要的一个原因就是利用它里面的丰富的优化算法。
|
||||
|
||||
LLVM的优化过程被标准化成了一个个的Pass,并由一个PassManager来管理。你可以查看jitlayers.cpp中的[addOptimizationPasses](https://github.com/JuliaLang/julia/blob/v1.4.1/src/jitlayers.cpp#L113)()函数,看看Julia都使用了哪些Pass。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/7f/7894d70bbd53dc60f70feb0b63c1097f.jpg" alt="">
|
||||
|
||||
上面表格中的Pass都是LLVM中自带的Pass。你要注意,运用好这些Pass,会产生非常好的优化效果。比如,某个开源项目,由于对性能的要求比较高,所以即使在Windows平台上,仍然强烈建议使用Clang来编译,而Clang就是基于LLVM的。
|
||||
|
||||
除此之外,Julia还针对自己语言的特点,写了几个个性化的Pass。比如:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/56/12381d4dce04315f25cfb0fc40819256.jpg" alt="">
|
||||
|
||||
这些个性化的Pass是针对Julia本身的语言特点而编写的。比如对于垃圾收集,每种语言的实现策略都不太一样,因此就必须自己实现相应的Pass,去插入与垃圾收集有关的代码。再比如,Julia是面向科学计算的,比较在意数值计算的性能,所以自己写了两个Pass来更好地利用CPU的一些特殊指令集。
|
||||
|
||||
emit_function函数最后返回的是一个**模块(Module)对象**,这个模块里只有一个函数。这个模块会被加入到一个[JuliaOJIT](https://github.com/JuliaLang/julia/blob/v1.4.1/src/jitlayers.h#L105)对象中进行集中管理。Julia可以从JuliaOJIT中,查找一个函数并执行,这就是Julia能够即时编译并运行的原因。
|
||||
|
||||
不过,我们刚才说的都是生成LLVM IR和基于IR做优化。**那么,LLVM的IR又是如何生成机器码的呢?对于垃圾收集功能,LLVM是否能给予帮助呢?在使用LLVM方面还需要注意哪些方面的问题呢?**
|
||||
|
||||
## 利用LLVM的正确姿势
|
||||
|
||||
在这里,我给你总结一下LLVM的功能,并带你探讨一下如何恰当地利用LLVM的功能。
|
||||
|
||||
通过这门课,你其实已经能够建立这种认识:编译器后端的工作量更大,某种意义上也更重要。如果我们去手工实现每个优化算法,为每种架构、每种ABI来生成代码,那不仅工作量会很大,而且还很容易遇到各种各样需要处理的Bug。
|
||||
|
||||
使用LLVM,就大大降低了优化算法和生成目标代码的工作量。LLVM的一个成功的前端是Clang,支持对C、C++和Objective-C的编译,并且编译速度和优化效果都很优秀。既然它能做好这几种语言的优化和代码生成,那么用来支持你的语言,你也应该放心。
|
||||
|
||||
总体来说,LLVM能给语言的设计者提供这样几种帮助:
|
||||
|
||||
- **程序的优化功能**
|
||||
|
||||
你可以通过LLVM的API,从你的编译器的前端生成LLVM IR,然后再调用各种分析和优化的Pass进行处理,就能达到优化目标。
|
||||
|
||||
LLVM还提供了一个框架,让你能够编写自己的Pass,满足自己的一些个性化需求,就像Julia所做的那样。
|
||||
|
||||
LLVM IR还有元数据功能,来辅助一些优化算法的实现。比如,在做基于类型的别名分析(TPAA)的时候,需要用到在前端解析中获得类型信息的功能。你在生成LLVM IR的时候,就可以把这些类型信息附加上,这样有助于优化算法的运行。
|
||||
|
||||
- **目标代码生成功能**
|
||||
|
||||
LLVM支持对x86、ARM、PowerPC等各种CPU架构生成代码的功能。同时,你应该还记得,在[第8讲](https://time.geekbang.org/column/article/249261)中,我说过ABI也会影响代码的生成。而LLVM,也支持Windows、Linux和macOS的不同的ABI。
|
||||
|
||||
另外,你已经知道,在目标代码生成的过程中,一般会需要三大优化算法:指令选择、寄存器分配和指令排序算法。LLVM对此同样也给予了很好的支持,你直接使用这些算法就行了。
|
||||
|
||||
最后,LLVM的代码生成功能对CPU厂家也很友好,因为这些算法都是**目标独立**(Target-independent)的。如果硬件厂家推出了一个新的CPU,那它可以用LLVM提供的**TableGen工具**,来描述这款新CPU的架构,这样我们就能使用LLVM来为它生成目标代码了。
|
||||
|
||||
- **对垃圾收集的支持**
|
||||
|
||||
LLVM还支持垃圾收集的特性,比如会提供安全点、读屏障、写屏障功能等。这些知识点我会在第32讲“垃圾收集”的时候带你做详细的了解。
|
||||
|
||||
- **对Debug的支持**
|
||||
|
||||
我们知道,代码的跟踪调试对于程序开发是很重要的。如果一门语言是生成机器码的,那么要实现跟踪调试,我们必须往代码里插入一些调试信息,比如目标代码对应的源代码的位置、符号表等。这些调试信息是符合**DWARF**(Debugging With Attributed Record Formats,使用有属性的记录格式进行调试)标准的,这样GDB、LLDB等各种调试工具,就可以使用这些调试信息进行调试了。
|
||||
|
||||
- **对JIT的支持**
|
||||
|
||||
LLVM内置了对JIT的支持。你可以在运行时编译一个模块,生成的目标代码放在内存里,然后运行该模块。实际上,Julia的编译器能够像普通的解释型语言那样运行,就是运用了LLVM的JIT机制。
|
||||
|
||||
- **其他功能**
|
||||
|
||||
LLVM还在不断提供新的支持,比如支持在程序链接的时候进行过程间的优化,等等。
|
||||
|
||||
总而言之,研究Julia的编译器,就为我们使用LLVM提供了一个很好的样本。你在有需要的时候,也可以作为参考。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们主要研究了Julia如何实现中后端功能的,特别是在这个过程中,它是如何使用LLVM的,你要记住以下要点:
|
||||
|
||||
- Julia自己的IR也是采用**SSA格式**的。这个IR的主要用途是**类型推断和内联优化**。
|
||||
- Julia的IR会被**转化成LLVM的IR**,从而进一步利用LLVM的功能。在转换过程中,会用到Julia的**内建函数**,这些内建函数代表了Julia语言中,抽象度比较高的运算功能,你可以拿它们跟V8的IR中,代表JavaScript运算的高级节点作类比,比如加法计算节点。这些内建函数会生成体现Julia语言语义的LLVM IR。
|
||||
- 你可以使用**LLVM的Pass来实现代码优化**。不过使用哪些Pass、调用的顺序如何,是由你自己安排的,并且你还可以编写自己个性化的Pass。
|
||||
- **LLVM为程序优化和生成目标代码提供了可靠的支持**,值得重视。而Julia为使用LLVM,就提供了一个很好的参考。
|
||||
|
||||
本讲的思维导图我也给你整理出来了,供你参考和复习回顾知识点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/58/0cc76aeee4ced068a99227c0e74bc858.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
LLVM强调全生命周期优化的概念。那么我们来思考一个有趣的问题:能否让Julia也像Java的JIT功能一样,在运行时基于推理来做一些激进的优化?如何来实现呢?欢迎在留言区发表你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. LLVM的官网:[llvm.org](https://llvm.org/)。如果你想像Julia、Rust、Swift等语言一样充分利用LLVM,那么应该会经常到这里来查阅相关资料。
|
||||
1. LLVM的[源代码](https://github.com/llvm/llvm-project)。像LLVM这样的开源项目,不可能通过文档或者书籍来获得所有的信息。最后,你还是必须去阅读源代码,甚至要根据Clang等其他前端使用LLVM的输出做反向工程,才能掌握各种细节。LLVM的核心作者也推荐开发者源代码当作文档。
|
||||
1. [Working with LLVM](https://docs.julialang.org/en/v1/devdocs/llvm/):Julia的开发者文档中,有对如何使用LLVM的介绍。
|
||||
1. [LLVM’s Analysis and Transform Passes](https://llvm.org/docs/Passes.html):对LLVM中的各种Pass的介绍。要想使用好LLVM,你就要熟悉这些Pass和它们的使用场景。
|
||||
1. 在《编译原理之美》的[第25讲](https://time.geekbang.org/column/article/153192)和[第26讲](https://time.geekbang.org/column/article/154438),我对LLVM后端及其命令行工具做了介绍,并且还手工调用LLVM的API,示范了针对不同的语法结构(比如if结构)应该如何生成LLVM IR,最后即时编译并运行。你可以去参考看看。
|
||||
243
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/24 | Go语言编译器:把它当作教科书吧.md
Normal file
243
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/24 | Go语言编译器:把它当作教科书吧.md
Normal file
@@ -0,0 +1,243 @@
|
||||
<audio id="audio" title="24 | Go语言编译器:把它当作教科书吧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/2a/151e7d604cc8686457b5f1cb8fyyb12a.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我来带你研究一下Go语言自带的编译器,它可以被简称为gc。
|
||||
|
||||
我之所以要来带你研究Go语言的编译器,一方面是因为Go现在确实非常流行,很多云端服务都用Go开发,Docker项目更是巩固了Go语言的地位;另一方面,我希望你能把它当成编译原理的教学参考书来使用。这是因为:
|
||||
|
||||
- Go语言的编译器完全用Go语言本身来实现,它完全实现了从前端到后端的所有工作,而不像Java要分成多个编译器来实现不同的功能模块,不像Python缺少了后端,也不像Julia用了太多的语言。所以你研究它所采用的编译技术会更方便。
|
||||
- Go编译器里基本上使用的都是经典的算法:经典的递归下降算法、经典的SSA格式的IR和CFG、经典的优化算法、经典的Lower和代码生成,因此你可以通过一个编译器就把这些算法都贯穿起来。
|
||||
- 除了编译器,你还可以学习到一门语言的其他构成部分的实现思路,包括运行时(垃圾收集器、并发调度机制等)、标准库和工具链,甚至连链接器都是用Go语言自己实现的,从而对实现一门语言所需要做的工作有更完整的认识。
|
||||
- 最后,Go语言的实现继承了从Unix系统以来形成的一些良好的设计哲学,因为Go语言的核心设计者都是为Unix的发展,做出过重要贡献的极客。因此了解了Go语言编译器的实现机制,会提高你的软件设计品味。
|
||||
|
||||
扩展:每种语言都有它的个性,而这个个性跟语言设计者的背景密切相关。Go语言的核心设计者,是Unix领域的极客,包括Unix的创始人和C语言的共同发明人之一,Ken Tompson。Rob Pike也是Unix的核心作者。
|
||||
|
||||
Go语言的作者们显然希望新的语言体现出他们的设计哲学和口味。比如,致力于像Unix那样的简洁和优雅,并且致力于让Go再次成为一款经典作品。
|
||||
|
||||
所以,在已经研究了多个高级语言的编译器之后,我们可以拿Go语言的编译器,把整个编译过程再重新梳理和印证一遍。
|
||||
|
||||
好了,现在就开始我们今天探索的旅途吧。
|
||||
|
||||
首先,我们来看看Go语言编译器的前端。
|
||||
|
||||
重要提示:照例,你要下载Go语言的[源代码](https://github.com/golang/go/tree/release-branch.go1.14),本讲采用的是1.14.2版本。并且,你最好使用一个IDE,便于跟踪调试编译器的执行过程。<br>
|
||||
Go的源代码中附带的[介绍编译器的文档](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/README.md),写得很好、很清晰,你可以参考一下。
|
||||
|
||||
## 词法分析和语法分析
|
||||
|
||||
Go的编译器的词法分析和语法分析功能的实现,是在cmd/compile/internal/syntax目录下。
|
||||
|
||||
**词法分析器是scanner.go。**其实大部分编程语言的词法分析器的算法,都已经很标准了,我们在[Java编译器](https://time.geekbang.org/column/article/251937)里就曾经分析过。甚至它们处理标识符和关键字的方式也都一致,都是先作为标识符识别出来,然后再查表挑出关键字来。Go的词法分析器并没有像V8那样在不遗余力地压榨性能,它跟你平常编码的方式是很一致的,非常容易阅读。
|
||||
|
||||
**语法分析器是parser.go。**它是一个标准的手写的递归下降算法。在[解析二元表达式](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/syntax/parser.go#L656)的时候,Go的语法分析器也是采用了运算符优先级算法,这个已经是我们第N次见到这个算法了,所以你一定要掌握!不过,每个编译器的实现都不大一样,而Go的实现方式相当的简洁,你可以去自己看一下,或者用调试器来跟踪一下它的执行过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/4c/1e0b9ae47048ed77329d941c4a8e374c.jpg" alt="">
|
||||
|
||||
Go的AST的节点,是在nodes.go中定义的,它异常简洁,可以说简洁得让你惊讶。你可以欣赏一下。
|
||||
|
||||
**Go的语法分析器还有一个很有特色的地方,就是对错误的处理。**它在处理编译错误时,有一个原则,就是不要遇到一个错误就停止编译,而是要尽可能跳过当前这个出错的地方,继续往下编译,这样可以一次多报几个语法错误。
|
||||
|
||||
parser.go的处理方式是,当语法分析器在处理某个产生式的时候,如果发现了错误,那就记录下这个错误,并且往下跳过一些Token,直到找到一个Token是属于这个产生式的Follow集合的。这个时候编译器就认为找到了这个产生式的结尾。这样分析器就可以跳过这个语法单元,继续处理下面的语法单元。
|
||||
|
||||
比如,在[解析函数声明语句](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/syntax/parser.go#L614)时,如果Go的语法分析器没有找到函数名称,就报错“expecting name or (”,然后往后找到“{”或者“;”,这样就跳过了函数名称的声明部分,继续去编译后面的函数体部分。
|
||||
|
||||
在cmd/compile/internal/syntax目录下,还有词法分析器和语法分析器的测试程序,你可以去运行测试一下。
|
||||
|
||||
最后,如果你还想对Go语言的语法分析有更加深入地了解,我建议你去阅读一下[Go语言的规范](https://golang.org/ref/spec),它里面对于每个语法单元,都有EBNF格式的语法规则定义,比如对[语句的定义](https://golang.org/ref/spec#Statements)。你通过看代码、看语言规范,积累语法规则的第一手经验,以后再看到一段程序,你的脑子里就能反映出它的语法规则,并且能随手画出AST了,这是你学习编译原理需要建立的硬功夫。比如说,这里我节选了一段Go语言的规范中针对语句的部分语法规则。
|
||||
|
||||
```
|
||||
Statement =
|
||||
Declaration | LabeledStmt | SimpleStmt |
|
||||
GoStmt | ReturnStmt | BreakStmt | ContinueStmt | GotoStmt |
|
||||
FallthroughStmt | Block | IfStmt | SwitchStmt | SelectStmt |
|
||||
ForStmt | DeferStmt .
|
||||
|
||||
SimpleStmt = EmptyStmt | ExpressionStmt | SendStmt | IncDecStmt |
|
||||
Assignment | ShortVarDecl .
|
||||
|
||||
```
|
||||
|
||||
好,在了解了Go语言编译器的语法分析工作以后,接下来,我们再来看看它的语义分析阶段。
|
||||
|
||||
## 语义分析(类型检查和AST变换)
|
||||
|
||||
语义分析的程序,是在cmd/compile/internal/gc目录下(注意,gc的意思是Go Compiler,不是垃圾收集的意思)。在入口代码main.go中,你能看到整个编译过程的主干步骤。
|
||||
|
||||
语义分析的主要程序是在[typecheck.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/typecheck.go)中。**这里你要注意,**不要被“typecheck”的名称所误导,它其实不仅是做类型检查,还做了名称消解(Name Resolution)和类型推导。
|
||||
|
||||
你已经知道,名称消解算法的特点,是分阶段完成。举个例子,在给表达式“a=b”中的变量b做引用消解之前,编译器必须先处理完b的定义,比如“var b Person”,这样才知道符号b指的是一个Person对象。
|
||||
|
||||
另外,在前面学习[Java编译器](https://time.geekbang.org/column/article/253750)的时候,你已经知道,对方法体中的本地变量的消解,必须放在最后,才能保证变量的使用总是引用到在它前面的变量声明。Go的编译器也是采用了相同的实现思路,你可以借此再回顾一下这个知识点,加深认识。
|
||||
|
||||
在语义分析阶段,Go的编译器还做了一些AST变换的工作。其中就有[内联优化](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/inl.go)和[逃逸分析](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/escape.go)这两项工作。在我们之前解析的编译器当中,这两项工作都是基于专门做优化的IR(比如Sea of Nodes)来做的,而在Go的编译器里,却可以基于AST来做这两项优化。你看,是不是真实世界中的编译器,才能让你如此开阔眼界?
|
||||
|
||||
你可以用“-m”参数来编译程序,它会打印出与内联和逃逸方面有关的优化。你可以带上多个“-m”参数,打印出嵌套层次更深的算法步骤的决策。
|
||||
|
||||
```
|
||||
go build -gcflags '-m -m' hello.go
|
||||
|
||||
```
|
||||
|
||||
好了,现在我们借gc编译器,又复习了一遍语义分析中的一些关键知识点:名称消解算法要分阶段,在语义分析阶段会对AST做一些变换。我们继续来看gc编译器下一步的处理工作。
|
||||
|
||||
## 生成SSA格式的IR
|
||||
|
||||
gc编译器在做完语义分析以后,下一步就是生成IR了。并且,gc的IR也是SSA格式的。你可以通过gc,来进一步了解如何生成和处理SSA格式的IR。
|
||||
|
||||
好,首先,我们来看看Go语言的IR是什么样子的。针对下面的示例代码foo.go,我们来看下它对应的SSA格式的IR:
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
func Foo(a int) int {
|
||||
var b int
|
||||
if (a > 10) {
|
||||
b = a
|
||||
} else {
|
||||
b = 10
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在命令行中输入下面的命令,让gc打印出为foo函数生成的IR。在当前目录下,你能看到一个ssa.html文件,你可以在浏览器中打开它。
|
||||
|
||||
```
|
||||
GOSSAFUNC=Foo go build -gcflags '-S' foo.go
|
||||
|
||||
```
|
||||
|
||||
在这个文件当中,你能看到编译器对IR做了多步的处理,也能看到每次处理后所生成的IR。
|
||||
|
||||
gc的IR是基于控制流图(CFG)的。一个函数会被分成多个基本块,基本块中包含了一行行的指令。点击某个变量,你能看到它的定义和使用情况(def-use链,图中显示成绿色)。你还能看到,图中灰色的变量,根据定义和使用关系,会发现它们没有被使用,所以是死代码,可以删除。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/66/a8c648560c0b03b23c3b39a95cbd5b66.jpg" alt="">
|
||||
|
||||
针对第一个阶段(Start阶段),我来给你解释一下每行指令的含义(可参考genericOps.go),帮助你了解Go语言的IR的设计特点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/a5/b1d104eaac5d7f04a3dc59f7a6d2a2a5.jpg" alt="">
|
||||
|
||||
你可以参考代码库中[介绍SSA的文档](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/README.md),里面介绍了Go的SSA的几个主要概念。
|
||||
|
||||
下面我来给你解读一下。
|
||||
|
||||
**首先是**[**值(Value)**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/value.go#L16)**。**Value是SSA的最主要构造单元,它可以定义一次、使用多次。在定义一个Value的时候,需要一个标识符(ID)作为名称、产生该Value的操作码([Op](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/op.go))、一个类型([Type](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L118),就是代码中<>里面的值),以及一些参数。
|
||||
|
||||
操作码有两类。一类是机器无关的,其定义在[genericOps.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/genericOps.go)中;一类是机器相关的,它是面向特定的CPU架构的,其定义在XXXOps.go中。比如,[AMD64Ops.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/AMD64Ops.go#L166)中是针对AMD64架构CPU的操作码信息。
|
||||
|
||||
在做Lower处理时,编译器会把机器无关的操作码转换为机器相关的操作码,有利于后序生成目标代码。机器无关的优化和机器相关的优化,分别作用在采用这两类不同操作码的IR上。
|
||||
|
||||
Value的类型信息,通常就是Go语言中的类型。但有几个类型是只会在SSA中用到的[特殊类型](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L1472),就像上面语句中的<mem>,即内存([TypeMem](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L1475))类型;以及TypeFlags,也就是CPU的标志位类型。</mem>
|
||||
|
||||
这里我要特别讲一下**内存类型**。内存类型代表的是全局的内存状态。如果一个操作码带有一个内存类型的参数,那就意味着该操作码依赖该内存状态。如果一个操作码的类型是内存类型,则意味着它会影响内存状态。
|
||||
|
||||
SSA的介绍文档中有一个例子,能帮助你理解内存类型的用法。
|
||||
|
||||
在这个例子中,程序首先会向地址a写入3这个值。这个时候,内存状态就修改了(从v1到了v10)。接着,把地址a的值写入地址b,内存状态又发生了一次修改。在IR中,第二行代码依赖第一行代码的内存状态(v10),因此就导致这行代码只能出现在定义了v10之后。
|
||||
|
||||
```
|
||||
// *a = 3 //向a地址写入3
|
||||
// *b = *a //向b地址写入a的值
|
||||
v10 = Store <mem> {int} v6 v8 v1
|
||||
v14 = Store <mem> {int} v7 v8 v10
|
||||
|
||||
```
|
||||
|
||||
这里你需要注意,对内存的读和写(各种IR一般都是使用Load和Store这两个词汇)是一类比较特殊的指令。其他的Value,我们都可以认为它们是在寄存器中的,是计算过程中的临时变量,所以它们在代码中的顺序只受数据流中依赖关系的制约。而一旦中间有读写内存的操作,那么代码顺序就会受到一定的限制。
|
||||
|
||||
我们可以跟在[Graal编译器](https://time.geekbang.org/column/article/256914)中学到的知识印证一下。当你读写一个Java对象的属性的时候,也会涉及内存的读写,这些操作对应的IR节点,在顺序上也是受到限制的,我们把它们叫做固定节点。
|
||||
|
||||
此外,Value结构中还包含了两个辅助信息字段:AuxInt和Aux。AuxInt是一个整型值,比如,在使用Const64指令中,AuxInt保存了常量的值;而Aux则可能是个复杂的结构体,用来保存每个操作码的个性化的信息。
|
||||
|
||||
**在IR中你还能看到基本块(**[**Block**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/block.go)**),这是第二个重要的数据结构。**Go编译器中的基本块有三种:简单(Plain)基本块,它只有一个后继基本块;退出(Exit)基本块,它的最后一个指令是一个返回指令;还有if基本块,它有一个控制值,并且它会根据该值是true还是false,跳转到不同的基本块。
|
||||
|
||||
**第三个数据结构是函数(**[**Func**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/func.go)**)。**函数是由多个基本块构成的。它必须有一个入口基本块(Entry Block),但可以有0到多个退出基本块,就像一个Go函数允许包含多个Return语句一样。
|
||||
|
||||
现在,你已经知道了Go的IR的关键概念和相关的数据结构了。Go的IR在运行时就是保存在Value、Block、Func等内存结构中,就像AST一样。它不像LLVM的bitcode还有文本格式、二进制格式,可以保存在文件中。
|
||||
|
||||
那么接下来,编译器就可以基于IR,来做优化了。
|
||||
|
||||
## 基于SSA格式的IR做优化
|
||||
|
||||
SSA格式的IR对编译器做优化很有帮助。
|
||||
|
||||
以死代码删除为例,Value结构中有一个Uses字段,记录了它的使用数。如果它出现在另一个Value的操作码的参数里,或者是某个基本块的控制变量,那么使用数就会加1;而如果Uses字段的值是0,那就证明这行代码没什么用,是死代码,可以删掉。
|
||||
|
||||
而你应该记得,在[第7讲](https://time.geekbang.org/column/article/248770)中曾提到过,我们需要对一个函数的所有基本块都扫描一遍甚至多遍,才能知道某个变量的活跃性,从而决定是否可以删除掉它。那相比起来,采用SSA格式,可以说简单太多了。
|
||||
|
||||
基于这样的IR来做优化,就是对IR做很多遍(Pass)的处理。在[cmd/compile/internal/ssa/compile.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/compile.go#L398)的代码里,列出了所有这些Pass,有将近50个。你能看到每个处理步骤执行的是哪个优化函数,你还可以在ssa.html中,看到每个Pass之后,IR都被做了哪些修改。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/yy/f8/yy0a23bdf773d2yy1d7234d95578eff8.jpg" alt="">
|
||||
|
||||
这些处理算法都是在[cmd/compile/internal/ssa](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/compile.go#L398)目录下。比如[cse.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/cse.go)里面是消除公共子表达式的算法,而[nilcheck.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/nilcheck.go)是被用来消除冗余的nil检查代码。
|
||||
|
||||
有些算法还带了测试程序(如[cse_test.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/cse_test.go),[nilcheck_test.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/nilcheck_test.go))。你可以去阅读一下,看看测试程序是如何构造测试数据的,并且你还可以通过Debugger来跟踪测试程序的执行过程,从而理解相关优化算法是如何实现的,这是一个很有效的学习方式。
|
||||
|
||||
另外,gc还有一些比较简单的优化算法,它们是基于一些规则,对IR做一些重写(rewrite)。Go的编译器使用了自己的一种DSL,来描述这些重写规则:针对机器无关的操作码的重写规则,是在[generic.rules](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/generic.rules)文件中;而针对机器有关的操作码的重写规则是在XXX.rules中,比如[AMD64.rules](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/AMD64.rules)。
|
||||
|
||||
我们来看几个例子:在generic.rules中,有这样一个机器无关的优化规则,它是把x*1的运算优化为x。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/fb/c091e058e96d6f166fa5737b8d9a80fb.jpg" alt="">
|
||||
|
||||
在AMD64.rules中,有一个机器相关的优化规则,这个规则是把MUL指令转换为LEA指令,LEA指令比MUL指令消耗的时钟周期更少。
|
||||
|
||||
```
|
||||
(MUL(Q|L)const [ 3] x) -> (LEA(Q|L)2 x x)
|
||||
|
||||
```
|
||||
|
||||
generic.rules中的规则会被[rulegen.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/rulegen.go)解析,并生成Go代码[rewritegeneric.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/rewritegeneric.go)。而AMD64.rules中的规则,被解析后会生成[rewriteAMD64.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/rewriteAMD64.go)。其中,Lower的过程,也就是把机器无关的操作码转换为机器相关的操作码,它也是用这种重写规则实现的。
|
||||
|
||||
通过gc这种基于规则做指令转换的方法,你应该产生一个感悟,也就是在写软件的时候,我们经常要设计自己的DSL,让自己的软件更具灵活性。比如,gc要增加一个新的优化功能,只需要增加一条规则就行了。我们还可以再拿Graal编译器印证一下。你还记得,Graal在生成LIR的时候,要进行指令的选择,那些选择规则是用注解来生成的,而那些注解规则,也是一种DSL。
|
||||
|
||||
好了,谈完了优化,我们继续往下看。
|
||||
|
||||
## 生成机器码
|
||||
|
||||
最后,编译器就可以调用gc/ssa.go中的[genssa](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/ssa.go#L5899)方法,来生成汇编码了。
|
||||
|
||||
在ssa.html的最右边一栏,就是调用genssa方法以后生成的汇编代码(采用的是Go编译器特有的格式,其中有些指令,如PCDATA和FUNCDATA是用来与垃圾收集器配合的)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/93/60798f64cbcd63d45671412712b49893.jpg" alt="">
|
||||
|
||||
你可能会问,**编译器在生成机器码之前,不是还要做指令选择、寄存器分配、指令排序吗?**那我们看看gc是如何完成这几项任务的。
|
||||
|
||||
**寄存器分配**([regalloc.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/regalloc.go))作为一个Pass,已经在生成机器码之前执行了。它采用的是线性扫描算法(Linear Scan Register Allocator)。
|
||||
|
||||
**指令选择会分为两部分的工作。**一部分工作,是在优化算法中已经做了一些指令选择,我们前面提到的重写规则,就蕴含了根据IR的模式,来生成合适的指令的规则;另一部分工作,则放到了汇编器当中。
|
||||
|
||||
这就是Go的编译器与众不同的地方。原来,gc生成的汇编代码,是一种“伪汇编”,它是一种半抽象的汇编代码。在生成特定CPU的机器码的时候,它还会做一些转换,这个地方可以完成另一些指令选择的工作。
|
||||
|
||||
至于**指令排序**,我没看到过在gc编译器中的实现。我请教了谷歌的一位研究员,他给我的信息是:像AMD64这样的CPU,已经能够很好地支持乱序执行了,所以指令重排序给gc编译器的优化工作,带来的好处很有限。
|
||||
|
||||
而gc目前没有做指令排序,还有一个原因就是,指令重排序算法的实现代价比较高,而gc的一个重要设计目标,就是要求编译速度要快。
|
||||
|
||||
扩展:Go语言的另外两个编译器,gccgo和GoLLVM都具备指令重排序功能。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我给你介绍了gc编译器的主要特点。之所以能压缩在一讲里面,是因为你已经见识了好几款编译器,渐渐地可以触类旁通、举一反三了。
|
||||
|
||||
在gc里面,你能看到很多可以借鉴的成熟实践:
|
||||
|
||||
- **语法分析**:递归下降算法,加上针对二元表达式的运算符优先级算法;
|
||||
- **语义分析**:分阶段的名称消解算法,以及对AST的转换;
|
||||
- **优化**:采用了SSA格式的IR、控制流图(CFG)、多个Pass的优化框架,以及通过DSL支持的优化规则。
|
||||
|
||||
所以在这一讲的开头,我还建议你把Go语言的编译器作为你学习编译原理的“教学参考书”,建议你在图形化的IDE界面里,来跟踪调试每一个功能,这样你就能很方便去观察它的算法执行过程。
|
||||
|
||||
本讲的思维导图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/47/b4d6d2e094c9d2485303065945781047.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在gc编译器里面,内联优化是基于AST去做的。那么,它为什么没有基于SSA格式的IR来做呢?这两种不同的实现会有什么差异?欢迎你在留言区发表你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. [Introduction to the Go compiler](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/README.md) 官方文档,介绍了gc的主要结构。
|
||||
1. [Introduction to the Go compiler’s SSA backend](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/README.md) 官方文档,介绍了gc的SSA。
|
||||
1. Go compiler internals: adding a new statement to Go - [Part 1](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/)、[Part2](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-2/)。在这两篇博客里,作者做了一个实验:如果往Go里面增加一条新的语法规则,需要做哪些事情。你能贯穿性地了解一个编译器的方法。
|
||||
1. [Go compiler: SSA optimization rules description language](https://quasilyte.dev/blog/post/go_ssa_rules/)这篇博客,详细介绍了gc编译器的SSA优化规则描述语言的细节。
|
||||
1. [A Primer on Go Assembly](https://github.com/teh-cmc/go-internals/blob/master/chapter1_assembly_primer/README.md)和[A Quick Guide to Go’s Assembler](https://golang.org/doc/asm) 。gc编译器采用的汇编语言是它自己的一种格式,是“伪汇编”。这两篇文章中有Go汇编的细节。
|
||||
348
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/25 | MySQL编译器(一):解析一条SQL语句的执行过程.md
Normal file
348
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/25 | MySQL编译器(一):解析一条SQL语句的执行过程.md
Normal file
@@ -0,0 +1,348 @@
|
||||
<audio id="audio" title="25 | MySQL编译器(一):解析一条SQL语句的执行过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/96/9bffa131cff817f913137d5cd368b696.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。现在,就到了我们编译之旅的最后一站了,我们一起来探索一下MySQL编译器。
|
||||
|
||||
数据库系统能够接受SQL语句,并返回数据查询的结果,或者对数据库中的数据进行修改,可以说几乎每个程序员都使用过它。
|
||||
|
||||
而MySQL又是目前使用最广泛的数据库。所以,解析一下MySQL编译并执行SQL语句的过程,一方面能帮助你加深对数据库领域的编译技术的理解;另一方面,由于SQL是一种最成功的DSL(特定领域语言),所以理解了MySQL编译器的内部运作机制,也能加深你对所有使用数据操作类DSL的理解,比如文档数据库的查询语言。另外,解读SQL与它的运行时的关系,也有助于你在自己的领域成功地使用DSL技术。
|
||||
|
||||
**那么,数据库系统是如何使用编译技术的呢?**接下来,我就会花两讲的时间,带你进入到MySQL的内部,做一次全面的探秘。
|
||||
|
||||
今天这一讲,我先带你了解一下如何跟踪MySQL的运行,了解它处理一个SQL语句的过程,以及MySQL在词法分析和语法分析方面的实现机制。
|
||||
|
||||
好,让我们开始吧!
|
||||
|
||||
## 编译并调试MySQL
|
||||
|
||||
按照惯例,你要下载[MySQL的源代码](https://github.com/mysql/mysql-server)。我下载的是8.0版本的分支。
|
||||
|
||||
源代码里的主要目录及其作用如下,我们需要分析的代码基本都在sql目录下,它包含了编译器和服务端的核心组件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c6/b8c9a108f1370bace3b1d8b3300b7ec6.jpg" alt="">
|
||||
|
||||
MySQL的源代码主要是.cc结尾的,也就是说,MySQL主要是用C++编写的。另外,也有少量几个代码文件是用C语言编写的。
|
||||
|
||||
为了跟踪MySQL的执行过程,你要用Debug模式编译MySQL,具体步骤可以参考这篇[开发者文档](https://dev.mysql.com/doc/internals/en/cmake.html)。
|
||||
|
||||
如果你用单线程编译,大约需要1个小时。编译好以后,先初始化出一个数据库来:
|
||||
|
||||
```
|
||||
./mysqld --initialize --user=mysql
|
||||
|
||||
```
|
||||
|
||||
这个过程会为root@localhost用户,生成一个缺省的密码。
|
||||
|
||||
接着,运行MySQL服务器:
|
||||
|
||||
```
|
||||
./mysqld &
|
||||
|
||||
```
|
||||
|
||||
之后,通过客户端连接数据库服务器,这时我们就可以执行SQL了:
|
||||
|
||||
```
|
||||
./mysql -uroot -p #连接mysql server
|
||||
|
||||
```
|
||||
|
||||
最后,我们把GDB调试工具附加到mysqld进程上,就可以对它进行调试了。
|
||||
|
||||
```
|
||||
gdb -p `pidof mysqld` #pidof是一个工具,用于获取进程的id,你可以安装一下
|
||||
|
||||
```
|
||||
|
||||
提示:这一讲中,我是采用了一个CentOS 8的虚拟机来编译和调试MySQL。我也试过在macOS下编译,并用LLDB进行调试,也一样方便。
|
||||
|
||||
**注意**,你在调试程序的时候,有两个**设置断点**的好地方:
|
||||
|
||||
- **dispatch_command**:在sql/sql_parse.cc文件里。在接受客户端请求的时候(比如一个SQL语句),会在这里集中处理。
|
||||
- **my_message_sql**:在sql/mysqld.cc文件里。当系统需要输出错误信息的时候,会在这里集中处理。
|
||||
|
||||
这个时候,我们在MySQL的客户端输入一个查询命令,就可以从雇员表里查询姓和名了。在这个例子中,我采用的数据库是MySQL的一个[示例数据库employees](https://github.com/datacharmer/test_db),你可以根据它的文档来生成示例数据库。
|
||||
|
||||
```
|
||||
mysql> select first_name, last_name from employees; #从mysql库的user表中查询信息
|
||||
|
||||
```
|
||||
|
||||
这个命令被mysqld接收到以后,就会触发断点,并停止执行。这个时候,客户端也会老老实实地停在那里,等候从服务端传回数据。即使你在后端跟踪代码的过程会花很长的时间,客户端也不会超时,一直在安静地等待。给我的感觉就是,MySQL对于调试程序还是很友好的。
|
||||
|
||||
在GDB中输入bt命令,会打印出调用栈,这样你就能了解一个SQL语句,在MySQL中执行的完整过程。为了方便你理解和复习,这里我整理成了一个表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/5e/c8115701536a1d0ba093e804bf13735e.jpg" alt="">
|
||||
|
||||
我也把MySQL执行SQL语句时的一些重要程序入口记录了下来,这也需要你重点关注。它反映了执行SQL过程中的一些重要的处理阶段,包括语法分析、处理上下文、引用消解、优化和执行。你在这些地方都可以设置断点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/90/829ff647ecefed1ca2653696085f7a90.jpg" alt="">
|
||||
|
||||
好了,现在你就已经做好准备,能够分析MySQL的内部实现机制了。不过,由于MySQL执行的是SQL语言,它跟我们前面分析的高级语言有所不同。所以,我们先稍微回顾一下SQL语言的特点。
|
||||
|
||||
## SQL语言:数据库领域的DSL
|
||||
|
||||
SQL是结构化查询语言(Structural Query Language)的英文缩写。举个例子,这是一个很简单的SQL语句:
|
||||
|
||||
```
|
||||
select emp_no, first_name, last_name from employees;
|
||||
|
||||
```
|
||||
|
||||
其实在大部分情况下,SQL都是这样一个一个来做语句执行的。这些语句又分为DML(数据操纵语言)和DDL(数据定义语言)两类。前者是对数据的查询、修改和删除等操作,而后者是用来定义数据库和表的结构(又叫模式)。
|
||||
|
||||
我们平常最多使用的是DML。而DML中,执行起来最复杂的是select语句。所以,在本讲,我都是用select语句来给你举例子。
|
||||
|
||||
那么,SQL跟我们前面分析的高级语言相比有什么不同呢?
|
||||
|
||||
**第一个特点:SQL是声明式(Declarative)的**。这是什么意思呢?其实就是说,SQL语句能够表达它的计算逻辑,但它不需要描述控制流。
|
||||
|
||||
高级语言一般都有控制流,也就是详细规定了实现一个功能的流程:先调用什么功能,再调用什么功能,比如if语句、循环语句等等。这种方式叫做**命令式(imperative)编程**。
|
||||
|
||||
更深入一点,声明式编程说的是“要什么”,它不关心实现的过程;而命令式编程强调的是“如何做”。前者更接近人类社会的领域问题,而后者更接近计算机实现。
|
||||
|
||||
**第二个特点:SQL是一种特定领域语言(DSL,Domain Specific Language),专门针对关系数据库这个领域的**。SQL中的各个元素能够映射成关系代数中的操作术语,比如选择、投影、连接、笛卡尔积、交集、并集等操作。它采用的是表、字段、连接等要素,而不需要使用常见的高级语言的变量、类、函数等要素。
|
||||
|
||||
所以,SQL就给其他DSL的设计提供了一个很好的参考:
|
||||
|
||||
- **采用声明式,更加贴近领域需求。**比如,你可以设计一个报表的DSL,这个DSL只需要描述报表的特征,而不需要描述其实现过程。
|
||||
- **采用特定领域的模型、术语,甚至是数学理论。**比如,针对人工智能领域,你完全就可以用张量计算(力学概念)的术语来定义DSL。
|
||||
|
||||
好了,现在我们分析了SQL的特点,从而也让你了解了DSL的一些共性特点。那么接下来,顺着MySQL运行的脉络,我们先来了解一下MySQL是如何做词法分析和语法分析的。
|
||||
|
||||
## 词法和语法分析
|
||||
|
||||
词法分析的代码是在sql/sql_lex.cc中,入口是MYSQLlex()函数。在sql/lex.h中,有一个symbols[]数组,它定义了各类关键字、操作符。
|
||||
|
||||
**MySQL的词法分析器也是手写的,这给算法提供了一定的灵活性。**比如,SQL语句中,Token的解析是跟当前使用的字符集有关的。使用不同的字符集,词法分析器所占用的字节数是不一样的,判断合法字符的依据也是不同的。而字符集信息,取决于当前的系统的配置。词法分析器可以根据这些配置信息,正确地解析标识符和字符串。
|
||||
|
||||
**MySQL的语法分析器是用bison工具生成的,bison是一个语法分析器生成工具,它是GNU版本的yacc**。bison支持的语法分析算法是LALR算法,而LALR是LR算法家族中的一员,它能够支持大部分常见的语法规则。bison的规则文件是sql/sql_yacc.yy,经过编译后会生成sql/sql_yacc.cc文件。
|
||||
|
||||
sql_yacc.yy中,用你熟悉的EBNF格式定义了MySQL的语法规则。我节选了与select语句有关的规则,如下所示,从中你可以体会一下,SQL语句的语法是怎样被一层一层定义出来的:
|
||||
|
||||
```
|
||||
select_stmt:
|
||||
query_expression
|
||||
| ...
|
||||
| select_stmt_with_into
|
||||
;
|
||||
query_expression:
|
||||
query_expression_body opt_order_clause opt_limit_clause
|
||||
| with_clause query_expression_body opt_order_clause opt_limit_clause
|
||||
| ...
|
||||
;
|
||||
query_expression_body:
|
||||
query_primary
|
||||
| query_expression_body UNION_SYM union_option query_primary
|
||||
| ...
|
||||
;
|
||||
query_primary:
|
||||
query_specification
|
||||
| table_value_constructor
|
||||
| explicit_table
|
||||
;
|
||||
query_specification:
|
||||
...
|
||||
| SELECT_SYM /*select关键字*/
|
||||
select_options /*distinct等选项*/
|
||||
select_item_list /*select项列表*/
|
||||
opt_from_clause /*可选:from子句*/
|
||||
opt_where_clause /*可选:where子句*/
|
||||
opt_group_clause /*可选:group子句*/
|
||||
opt_having_clause /*可选:having子句*/
|
||||
opt_window_clause /*可选:window子句*/
|
||||
;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
其中,query_expression就是一个最基础的select语句,它包含了SELECT关键字、字段列表、from子句、where子句等。
|
||||
|
||||
你可以看一下select_options、opt_from_clause和其他几个以opt开头的规则,它们都是SQL语句的组成部分。opt是可选的意思,也就是它的产生式可能产生ε。
|
||||
|
||||
```
|
||||
opt_from_clause:
|
||||
/* Empty. */
|
||||
| from_clause
|
||||
;
|
||||
|
||||
|
||||
```
|
||||
|
||||
另外,你还可以看一下表达式部分的语法。在MySQL编译器当中,对于二元运算,你可以大胆地写成左递归的文法。因为它的语法分析的算法用的是LALR,这个算法能够自动处理左递归。
|
||||
|
||||
一般研究表达式的时候,我们总是会关注编译器是如何处理结合性和优先级的。那么,bison是如何处理的呢?
|
||||
|
||||
原来,bison里面有专门的规则,可以规定运算符的优先级和结合性。在sql_yacc.yy中,你会看到如下所示的规则片段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/20/4e0d2706eb5e26143ae125c05bd2e720.jpg" alt="">
|
||||
|
||||
你可以看一下bit_expr的产生式,它其实完全把加减乘数等运算符并列就行了。
|
||||
|
||||
```
|
||||
bit_expr :
|
||||
...
|
||||
| bit_expr '+' bit_expr %prec '+'
|
||||
| bit_expr '-' bit_expr %prec '-'
|
||||
| bit_expr '*' bit_expr %prec '*'
|
||||
| bit_expr '/' bit_expr %prec '/'
|
||||
...
|
||||
| simple_expr
|
||||
|
||||
```
|
||||
|
||||
如果你只是用到加减乘除的运算,那就可以不用在产生式的后面加%prec这个标记。但由于加减乘除这几个还可以用在其他地方,比如“-a”可以用来表示把a取负值;减号可以用在一元表达式当中,这会比用在二元表达式中有更高的优先级。也就是说,为了区分同一个Token在不同上下文中的优先级,我们可以用%prec,来说明该优先级是[上下文依赖](https://www.gnu.org/software/bison/manual/html_node/Contextual-Precedence.html)的。
|
||||
|
||||
好了,在了解了词法分析器和语法分析器以后,我们接着来跟踪一下MySQL的执行,看看编译器所生成的解析树和AST是什么样子的。
|
||||
|
||||
在sql_class.cc的sql_parser()方法中,编译器执行完解析程序之后,会返回解析树的根节点root,在GDB中通过p命令,可以逐步打印出整个解析树。你会看到,它的根节点是一个PT_select_stmt指针(见图3)。
|
||||
|
||||
解析树的节点是在语法规则中规定的,这是一些C++的代码,它们会嵌入到语法规则中去。
|
||||
|
||||
下面展示的这个语法规则就表明,编译器在解析完query_expression规则以后,要创建一个PT_query_expression的节点,其构造函数的参数分别是三个子规则所形成的节点。对于query_expression_body和query_primary这两个规则,它们会直接把子节点返回,因为它们都只有一个子节点。这样就会简化解析树,让它更像一棵AST。关于AST和解析树(也叫CST)的区别,我在解析Python的编译器中讲过了,你可以回忆一下。
|
||||
|
||||
```
|
||||
query_expression:
|
||||
query_expression_body
|
||||
opt_order_clause
|
||||
opt_limit_clause
|
||||
{
|
||||
$$ = NEW_PTN PT_query_expression($1, $2, $3); /*创建节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
query_expression_body:
|
||||
query_primary
|
||||
{
|
||||
$$ = $1; /*直接返回query_primary的节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
query_primary:
|
||||
query_specification
|
||||
{
|
||||
$$= $1; /*直接返回query_specification的节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
|
||||
```
|
||||
|
||||
最后,对于“select first_name, last_name from employees”这样一个简单的SQL语句,它所形成的解析树如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/26/007f91d9f3fe4c3349722201bec44226.jpg" alt="">
|
||||
|
||||
而对于“select 2 + 3”这样一个做表达式计算的SQL语句,所形成的解析树如下。你会看到,它跟普通的高级语言的表达式的AST很相似:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/db/da090cf1095e2aef738a69a5851ffcdb.jpg" alt="">
|
||||
|
||||
图4中的PT_query_expression等类,就是解析树的节点,它们都是Parse_tree_node的子类(PT是Parse Tree的缩写)。这些类主要定义在sql/parse_tree_nodes.h和parse_tree_items.h文件中。
|
||||
|
||||
其中,Item代表了与“值”有关的节点,它的子类能够用于表示字段、常量和表达式等。你可以通过Item的val_int()、val_str()等方法获取它的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/04/cfa126a6144186deafe7d9caff56f304.jpg" alt="">
|
||||
|
||||
由于SQL是一个个单独的语句,所以select、insert、update等语句,它们都各自有不同的根节点,都是Parse_tree_root的子类。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/2a/4a1152566c2ccab84d2f5022f44a022a.jpg" alt="">
|
||||
|
||||
好了,现在你就已经了解了SQL的解析过程和它所生成的AST了。前面我说过,MySQL采用的是LALR算法,因此我们可以借助MySQL编译器,来加深一下对LR算法家族的理解。
|
||||
|
||||
## 重温LR算法
|
||||
|
||||
你在阅读yacc.yy文件的时候,在注释里,你会发现如何跟踪语法分析器的执行过程的一些信息。
|
||||
|
||||
你可以用下面的命令,带上“-debug”参数,来启动MySQL服务器:
|
||||
|
||||
```
|
||||
mysqld --debug="d,parser_debug"
|
||||
|
||||
```
|
||||
|
||||
然后,你可以通过客户端执行一个简单的SQL语句:“select 2+3*5”。在终端,会输出语法分析的过程。这里我截取了一部分界面,通过这些输出信息,你能看出LR算法执行过程中的移进、规约过程,以及工作区内和预读的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/91/69e4644e93a5156a6695eff41d162891.jpg" alt="">
|
||||
|
||||
我来给你简单地复现一下这个解析过程。
|
||||
|
||||
**第1步,编译器处于状态0,并且预读了一个select关键字。**你已经知道,LR算法是基于一个DFA的。在这里的输出信息中,你能看到某些状态的编号达到了一千多,所以这个DFA还是比较大的。
|
||||
|
||||
**第2步,把select关键字移进工作区,并进入状态42。**这个时候,编译器已经知道后面跟着的一定是一个select语句了,也就是会使用下面的语法规则:
|
||||
|
||||
```
|
||||
query_specification:
|
||||
...
|
||||
| SELECT_SYM /*select关键字*/
|
||||
select_options /*distinct等选项*/
|
||||
select_item_list /*select项列表*/
|
||||
opt_from_clause /*可选:from子句*/
|
||||
opt_where_clause /*可选:where子句*/
|
||||
opt_group_clause /*可选:group子句*/
|
||||
opt_having_clause /*可选:having子句*/
|
||||
opt_window_clause /*可选:window子句*/
|
||||
;
|
||||
|
||||
|
||||
```
|
||||
|
||||
为了给你一个直观的印象,这里我画了DFA的局部示意图(做了一定的简化),如下所示。你可以看到,在状态42,点符号位于“select”关键字之后、select_options之前。select_options代表了“distinct”这样的一些关键字,但也有可能为空。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/0b/474af6c5761e157cb82987fcd87a3c0b.jpg" alt="">
|
||||
|
||||
**第3步,因为预读到的Token是一个数字(NUM),这说明select_options产生式一定生成了一个ε,因为NUM是在select_options的Follow集合中。**
|
||||
|
||||
这就是LALR算法的特点,它不仅会依据预读的信息来做判断,还要依据Follow集合中的元素。所以编译器做了一个规约,也就是让select_options为空。
|
||||
|
||||
也就是,编译器依据“select_options->ε”做了一次规约,并进入了新的状态920。**注意**,状态42和920从DFA的角度来看,它们是同一个大状态。而DFA中包含了多个小状态,分别代表了不同的规约情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/e1/8f2444e7c1f100485d679cc543073de1.jpg" alt="">
|
||||
|
||||
**你还需要注意**,这个时候,老的状态都被压到了栈里,所以栈里会有0和42两个状态。栈里的这些状态,其实记录了推导的过程,让我们知道下一步要怎样继续去做推导。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/76/c3a585e8a1c3753137ff83fac5368576.jpg" alt="">
|
||||
|
||||
**第4步,移进NUM。**这时又进入一个新状态720。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/b9/048df36542d61ba8f8f688c58e00a3b9.jpg" alt="">
|
||||
|
||||
而旧的状态也会入栈,记录下推导路径:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/51/bcd744e5d278ce37d0abb1583ceccb51.jpg" alt="">
|
||||
|
||||
**第5~8步,依次依据NUM_literal->NUM、literal->NUM_literal、simple_expr->literal、bit_expr->simple_expr这四条产生式做规约。**这时候,编译器预读的Token是+号,所以你会看到,图中的红点停在+号前。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/7a/33b3f6b88214412b6d29b2ce2b03dc7a.jpg" alt="">
|
||||
|
||||
**第9~10步,移进+号和NUM**。这个时候,状态又重新回到了720。这跟第4步进入的状态是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/8d/e3976970cd368c8e9c1547bbc2c6f48d.jpg" alt="">
|
||||
|
||||
而栈里的目前有5个状态,记录了完整的推导路径。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/55/142e374173e90ba657579f67566bb755.jpg" alt="">
|
||||
|
||||
到这里,其实你就已经了解了LR算法做移进和规约的思路了。不过你还可以继续往下研究。由于栈里保留了完整的推导路径,因此MySQL编译器最后会依次规约回来,把栈里的元素清空,并且形成一棵完整的AST。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我带你初步探索了MySQL编译SQL语句的过程。你需要记住几个关键点:
|
||||
|
||||
- **掌握如何用GDB来跟踪MySQL的执行的方法**。你要特别注意的是,我给你梳理的那些关键的程序入口,它是你理解MySQL运行过程的地图。
|
||||
- SQL语言是**面向关系数据库的一种DSL,它是声明式的**,并采用了领域特定的模型和术语,可以为你设计自己的DSL提供启发。
|
||||
- **MySQL的语法分析器是采用bison工具生成的**。这至少说明,语法分析器生成工具是很有用的,连正式的数据库系统都在使用它,所以你也可以大胆地使用它,来提高你的工作效率。我在最后的参考资料中给出了bison的手册,希望你能自己阅读一下,做一些简单的练习,掌握bison这个工具。
|
||||
- 最后,**你一定要知道LR算法的运行原理**,知其所以然,这也会更加有助于你理解和用好工具。
|
||||
|
||||
我依然把本讲的内容给你整理成了一张知识地图,供你参考和复习回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/5b/04cc0ce4fb5d78d7d9aa18e03088f95b.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们今天讲到了DSL的概念。你能分享一下你的工作领域中的DSL吗?它们是否也是采用声明式的?里面用到了哪些特定领域的术语?欢迎在留言区分享。
|
||||
|
||||
感谢你的阅读。如果有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. MySQL的内行手册([MySQL Internals Manual](https://dev.mysql.com/doc/internals/en/))能提供一些重要的信息。但我发现文档内容经常跟源代码的版本不同步,比如介绍源代码的目录结构的信息就过时了,你要注意这点。
|
||||
1. bison的[手册](http://www.gnu.org/software/bison/manual/)。
|
||||
@@ -0,0 +1,220 @@
|
||||
<audio id="audio" title="26 | MySQL编译器(二):编译技术如何帮你提升数据库性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/12/a4fd5b8320728c2358f4c08996702112.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我们继续来探究MySQL编译器。
|
||||
|
||||
通过上一讲的学习,你已经了解了MySQL编译器是怎么做词法和语法分析的了。那么在做完语法分析以后,MySQL编译器又继续做了哪些处理,才能成功地执行这个SQL语句呢?
|
||||
|
||||
所以今天,我就带你来探索一下MySQL的实现机制,我会把重点放在SQL的语义分析和优化机制上。当你学完以后,你就能真正理解以下这些问题了:
|
||||
|
||||
- 高级语言的编译器具有语义分析功能,那么MySQL编译器也会做语义分析吗?它有没有引用消解问题?有没有作用域?有没有类型检查?
|
||||
- MySQL有没有类似高级语言的那种优化功能呢?
|
||||
|
||||
好,让我们开始今天的探究吧。不过,在讨论MySQL的编译过程之前,我想先带你了解一下MySQL会用到的一些重要的数据结构,因为你在解读代码的过程中经常会见到它们。
|
||||
|
||||
## 认识MySQL编译器的一些重要的数据结构
|
||||
|
||||
**第一组数据结构**,是下图中的几个重要的类或结构体,包括线程、保存编译上下文信息的LEX,以及保存编译结果SELECT_LEX_UNIT和SELECT_LEX。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/b7/ccd4a2dcae0c974b0b5254c51440f9b7.jpg" alt="">
|
||||
|
||||
**首先是THD,也就是线程对象。**对于每一个客户端的连接,MySQL编译器都会启动一个线程来处理它的查询请求。
|
||||
|
||||
THD中的一个重要数据成员是**LEX对象**。你可以把LEX对象想象成是编译SQL语句的工作区,保存了SQL语句编译过程中的上下文信息,编译器会把编译的成果放在这里,而编译过程中所需要的信息也是从这里查找。
|
||||
|
||||
在把SQL语句解析完毕以后,编译器会形成一些结构化的对象来表示一个查询。其中**SELECT_LEX_UNIT结构体**,就代表了一个**查询表达式**(Query Expression)。一个查询表达式可能包含了多个查询块,比如使用UNION的情况。
|
||||
|
||||
而**SELECT_LEX**则代表一个**基本的查询块**(Query Block),它里面的信息包括了所有的列和表达式、查询用到的表、where条件等。在SELECT_LEX中会保存查询块中涉及的表、字段和表达式等,它们也都有对应的数据结构。
|
||||
|
||||
**第二组需要了解的数据结构**,是表示表、字段等信息的对象。**Table_ident对象**保存了表的信息,包括数据库名、表名和所在的查询语句(SELECT_LEX_UNIT对象)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/d0/10567f1112bf04c0240c0ebc277067d0.jpg" alt="">
|
||||
|
||||
而字段和表达式等表示一个值的对象,用Item及其子类来表示。SQL语句中的每个字段、每个计算字段,最后都对应一个Item。where条件,其实也是用一个Item就能表示。具体包括:
|
||||
|
||||
- 字段(Item_field)。
|
||||
- 各种常数,包括数字、字符和null等(Item_basic_constant)。
|
||||
- 能够产生出值的运算(Item_result_field),包括算术表达式(Item_num_op)、存储过程(Item_func_sp)、子查询(Item_subselect)等。
|
||||
- 在语法分析过程中产生的Item(Parse_tree_item)。它们是一些占位符,因为在语法分析阶段,不容易一下子创建出真正的Item,这些Parse_tree_item需要在上下文分析阶段,被替换成真正的Item。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/c3/7aed46a44dcfb3bb6908db30cyy815c3.jpg" alt="">
|
||||
|
||||
好了,上面这些就是MySQL会用到的最核心的一些数据结构了。接下来的编译工作,就会生成和处理上述的数据结构。
|
||||
|
||||
## 上下文分析
|
||||
|
||||
我们先来看一下MySQL编译器的上下文分析工作。
|
||||
|
||||
你已经知道,语法分析仅仅完成的是上下文无关的分析,还有很多的工作,需要基于上下文来做处理。这些工作,就属于语义分析。
|
||||
|
||||
MySQL编译器中,每个AST节点,都会有一个**contextualize()方法**。从这个方法的名称来看,你就能知道它是做上下文处理的(contextualize,置于上下文中)。
|
||||
|
||||
对一个Select语句来说,编译器会调用其根节点PT_select_stmt的contextualize()方法,从而深度遍历整个AST,并调用每个节点的contextualize()方法。
|
||||
|
||||
**那么,MySQL编译器的上下文处理,都完成了什么工作呢?**
|
||||
|
||||
**首先,是检查数据库名、表名和字段名是否符合格式要求(在table.cc中实现)。**
|
||||
|
||||
比如,MySQL会规定表名、字段名等名称不能超过64个字符,字段名不能包含ASCII值为255的字符,等等。这些规则在词法分析阶段是不检查的,要留在语义分析阶段检查。
|
||||
|
||||
**然后,创建并填充SELECT_LEX_UNIT和SELECT_LEX对象。**
|
||||
|
||||
前面我提到了,SELECT_LEX_UNIT和SELECT_LEX中,保存了查询表达式和查询块所需的所有信息,依据这些信息,MySQL就可以执行实际的数据库查询操作。
|
||||
|
||||
那么,在contextualize的过程中,编译器就会生成上述对象,并填充它们的成员信息。
|
||||
|
||||
比如,对于查询中用到的表,在语法分析阶段就会生成Table_ident对象。但其中的数据库名称可能是缺失的,那么在上下文的分析处理当中,就会被编译器设置成当前连接所采用的默认数据库。这个信息可以从线程对象(THD)中获得,因为每个线程对应了一个数据库连接,而每个数据库连接是针对一个具体的数据库的。
|
||||
|
||||
好了,经过上下文分析的编译阶段以后,我们就拥有了可以执行查询的SELECT_LEX_UNIT和SELECT_LEX对象。可是,你可能会注意到一个问题:**为什么在语义分析阶段,MySQL没有做引用的消解呢?**不要着急,接下来我就给你揭晓这个答案。
|
||||
|
||||
## MySQL是如何做引用消解的?
|
||||
|
||||
我们在SQL语句中,会用到数据库名、表名、列名、表的别名、列的别名等信息,编译器肯定也需要检查它们是不是正确的。这就是引用消解(或名称消解)的过程。一般编译器是在语义分析阶段来做这项工作的,而MySQL是在执行SQL命令的时候才做引用消解。
|
||||
|
||||
引用消解的入口是在SQL命令的的prepare()方法中,它会去检查表名、列名都对不对。
|
||||
|
||||
通过GDB调试工具,我们可以跟踪编译器做引用消解的过程。你可以在my_message_sql()函数处设个断点,然后写个SQL语句,故意使用错误的表名或者列名,来看看MySQL是在什么地方检查出这些错误的。
|
||||
|
||||
比如说,你可以执行“select * from fake_table”,其中的fake_table这个表,在数据库中其实并不存在。
|
||||
|
||||
下面是打印出的调用栈。你会注意到,MySQL在准备执行SQL语句的过程中,会试图去打开fake_table表,这个时候编译器就会发现这个表不存在。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/9a/9c2e987a6fc797926593cd1f6c001d9a.jpg" alt="">
|
||||
|
||||
你还可以再试一下“select fake_column from departments”这个语句,也一样会查出,fake_column并不是departments表中的一列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/9f/a283dcc105f240c6760a78913a4d069f.jpg" alt="">
|
||||
|
||||
**那么,MySQL是如何知道哪些表和字段合法,哪些不合法的呢?**
|
||||
|
||||
原来,它是通过查表的定义,也就是数据库模式信息,或者可以称为数据字典、元数据。MySQL在一个专门的库中,保存了所有的模式信息,包括库、表、字段、存储过程等定义。
|
||||
|
||||
你可以跟高级语言做一下类比。高级语言,比如说Java也会定义一些类型,类型中包含了成员变量。那么,MySQL中的表,就相当于高级语言的类型;而表的字段(或列)就相当于高级语言的类型中的成员变量。所以,在这个方面,MySQL和高级语言做引用消解的思路其实是一样的。
|
||||
|
||||
但是,**高级语言在做引用消解的时候有作用域的概念,那么MySQL有没有类似的概念呢?**
|
||||
|
||||
有的。举个例子,假设一个SQL语句带了子查询,那么子查询中既可以引用本查询块中的表和字段,也可以引用父查询中的表和字段。这个时候就存在了两个作用域,比如下面这个查询语句:
|
||||
|
||||
```
|
||||
select dept_name from departments where dept_no in
|
||||
(select dept_no from dept_emp
|
||||
where dept_name != 'Sales' #引用了上一级作用域中的字段
|
||||
group by dept_no
|
||||
having count(*)> 20000)
|
||||
|
||||
```
|
||||
|
||||
其中的dept_name字段是dept_emp表中所没有的,它其实是上一级作用域中departments表中的字段。
|
||||
|
||||
提示:这个SQL当然写得很不优化,只是用来表现作用域的概念。
|
||||
|
||||
好。既然要用到作用域,那么MySQL的作用域是怎么表示的呢?
|
||||
|
||||
这就要用到**Name_resolution_context对象**。这个对象保存了当前作用域中的表,编译器可以在这些表里查找字段;它还保存了**对外层上下文的引用**(outer_context),这样MySQL就可以查找上一级作用域中的表和字段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/a8/40114ec45281b96a0c34fd0a237251a8.jpg" alt="">
|
||||
|
||||
好了,现在你就对MySQL如何做引用消解非常了解了。
|
||||
|
||||
我们知道,对于高级语言的编译器来说,接下来它还会做一些优化工作。**那么,MySQL是如何做优化的呢?它跟高级语言编译器的优化工作相比,又有什么区别呢?**
|
||||
|
||||
## MySQL编译器的优化功能
|
||||
|
||||
MySQL编译器的优化功能主要都在sql_optimizer.cc中。就像高级语言一样,MySQL编译器会支持一些常见的优化。我来举几个例子。
|
||||
|
||||
**第一个例子是常数传播优化**(const propagation)。假设有一个表foo,包含了x和y两列,那么SQL语句:“select * from foo where x = 12 and y=x”,会被优化成“select * from foo where x = 12 and y = 12”。你可以在propagate_cond_constants()函数上加个断点,查看常数传播优化是如何实现的。
|
||||
|
||||
**第二个例子是死代码消除**。比如,对于SQL语句:“select * from foo where x=2 and y=3 and x<y”,编译器会把它优化为“select * from foo where x=2 and y=3”,把“x<y”去掉了,这是因为x肯定是小于y的。该功能的实现是在remove_eq_conds()中。
|
||||
|
||||
**第三个例子是常数折叠**。这个优化工作我们应该很熟悉了,主要是对各种条件表达式做折叠,从而降低计算量。其实现是在sql_const_folding.cc中。
|
||||
|
||||
你要注意的是,上述的优化主要是针对条件表达式。因为MySQL在执行过程中,对于每一行数据,可能都需要执行一遍条件表达式,所以上述优化的效果会被放大很多倍,这就好比针对循环体的优化,是一个道理。
|
||||
|
||||
不过,**MySQL还有一种特殊的优化,是对查询计划的优化**。比如说,我们要连接employees、dept_emp和departments三张表做查询,数据库会怎么做呢?
|
||||
|
||||
最笨的办法,是针对第一张表的每条记录,依次扫描第二张表和第三张表的所有记录。这样的话,需要扫描多少行记录呢?是三张表的记录数相乘。基于我们的示例数据库的情况,这个数字是**8954亿**。
|
||||
|
||||
上述计算其实是做了一个**笛卡尔积**,这会导致处理量的迅速上升。而在数据库系统中,显然不需要用这么笨的方法。
|
||||
|
||||
你可以用explain语句,让MySQL输出执行计划,下面我们来看看MySQL具体是怎么做的:
|
||||
|
||||
```
|
||||
explain select employees.emp_no, first_name,
|
||||
departments.dept_no dept_name
|
||||
from employees, dept_emp, departments
|
||||
where employees.emp_no = dept_emp.emp_no
|
||||
and dept_emp.dept_no = departments.dept_no;
|
||||
|
||||
```
|
||||
|
||||
这是MySQL输出的执行计划:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/13/b0e1e8e2b1f9ef11dbd0150b8ae2cd13.jpg" alt="">
|
||||
|
||||
从输出的执行计划里,你能看出,MySQL实际的执行步骤分为了3步:
|
||||
|
||||
- 第1步,通过索引,遍历departments表;
|
||||
- 第2步,通过引用关系(ref),找到dept_emp表中,dept_no跟第1步的dept_no相等的记录,平均每个部门在dept_emp表中能查到3.7万行记录;
|
||||
- 第3步,基于第2步的结果,通过等值引用(eq_ref)关系,在employees表中找到相应的记录,每次找到的记录只有1行。这个查找可以通过employees表的主键进行。
|
||||
|
||||
根据这个执行计划来完成上述的操作,编译器只需要处理大约63万行的数据。因为通过索引查数据相比直接扫描表来说,处理每条记录花费的时间比较长,所以我们假设前者花费的时间是后者的3倍,那么就相当于扫描了63*3=189万行表数据,这仍然仅仅相当于做笛卡尔积的47万分之一。**我在一台虚拟机上运行该SQL花费的时间是5秒,而如果使用未经优化的方法,则需要花费27天!**
|
||||
|
||||
通过上面的例子,你就能直观地理解做查询优化的好处了。MySQL会通过一个JOIN对象,来为一个查询块(SELECT_LEX)做查询优化,你可以阅读JOIN的方法,来看看查询优化的具体实现。关于查询优化的具体算法,你需要去学习一下数据库的相关课程,我在本讲末尾也推荐了一点参考资料,所以我这里就不展开了。
|
||||
|
||||
从编译原理的角度来看,我们可以把查询计划中的每一步,看做是一条指令。MySQL的引擎,就相当于能够执行这些指令的一台虚拟机。
|
||||
|
||||
如果再做进一步了解,你就会发现,**MySQL的执行引擎和存储引擎是分开的**。存储引擎提供了一些基础的方法(比如通过索引,或者扫描表)来获取表数据,而做连接、计算等功能,是在MySQL的执行引擎中完成的。
|
||||
|
||||
好了,现在你就已经大致知道了,一条SQL语句从解析到执行的完整过程。但我们知道,普通的高级语言在做完优化后,生成机器码,这样性能更高。那么,**是否可以把SQL语句编译成机器码,从而获得更高的性能呢?**
|
||||
|
||||
## 能否把SQL语句编译成机器码?
|
||||
|
||||
MySQL编译器在执行SQL语句的过程中,除了查找数据、做表间连接等数据密集型的操作以外,其实还有一些地方是需要计算的。比如:
|
||||
|
||||
- **where条件**:对每一行扫描到的数据都需要执行一次。
|
||||
- **计算列**:有的列是需要计算出来的。
|
||||
- **聚合函数**:像sum、max、min等函数,也是要对每一行数据做一次计算。
|
||||
|
||||
在研究MySQL的过程中,你会发现上述计算都是解释执行的。MySQL做解释执行的方式,基本上就是深度遍历AST。比如,你可以对代表where条件的Item求值,它会去调用它的下级节点做递归的计算。这种计算过程和其他解释执行的语言差不多,都是要在运行时判断数据的类型,进行必要的类型转换,最后执行特定的运算。因为很多的判断都要在运行时去做,所以程序运行的性能比较低。
|
||||
|
||||
另外,由于MySQL采用的是解释执行机制,所以它在语义分析阶段,其实也没有做类型检查。在编译过程中,不同类型的数据在运算的时候,会自动进行类型转换。比如,执行“`select'2' + 3`”,MySQL会输出5,这里就把字符串`'2'`转换成了整数。
|
||||
|
||||
**那么,我们能否把这些计算功能编译成本地代码呢?**
|
||||
|
||||
因为我们在编译期就知道每个字段的数据类型了,所以编译器其实是可以根据这些类型信息,生成优化的代码,从而提升SQL的执行效率。
|
||||
|
||||
这种思路理论上是可行的。不过,目前我还没有看到MySQL在这方面的工作,而是发现了另一个数据库系统PostgreSQL,做了这方面的优化。
|
||||
|
||||
PostgreSQL的团队发现,如果解释执行下面的语句,表达式计算所用的时间,占到了处理一行记录所需时间的56%。而基于LLVM实现JIT以后(编译成机器码执行),所用的时间只占到总执行时间的6%,这就使得SQL执行的整体性能整整提高了一倍。
|
||||
|
||||
```
|
||||
select count(*) from table_name where (x + y) > 100
|
||||
|
||||
```
|
||||
|
||||
中国用户对MySQL的用量这么大,如果能做到上述的优化,那么仅仅因此而减少的碳排放,就是一个很大的成绩!所以,你如果有兴趣的话,其实可以在这方面尝试一下!
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我们分析了MySQL做语义分析、优化和执行的原理,并探讨了一下能否把SQL编译成本地代码的问题。你要记住以下这些要点:
|
||||
|
||||
- MySQL也会做上下文分析,并生成能够代表SQL语句的内部数据结构;
|
||||
- MySQL做引用消解要基于数据库模式信息,并且也支持作用域;
|
||||
- MySQL会采用常数传播等优化方法,来优化查询条件,并且要通过查询优化算法,形成高效的执行计划;
|
||||
- 把SQL语句编译成机器码,会进一步提升数据库的性能,并降低能耗。
|
||||
|
||||
我把相应的知识点总结成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/61/d8dfbb4d207cd6a27fffe8ab860f6861.jpg" alt="">
|
||||
|
||||
总结这两讲对MySQL所采用的编译技术介绍,你会发现这样几个很有意思的地方:
|
||||
|
||||
- 第一,编译技术确实在数据库系统中扮演了很重要的作用。
|
||||
- 第二,数据库编译SQL语句的过程与高级语言有很大的相似性,它们都包含了词法分析、语法分析、语义分析和优化等处理。你对编译技术的了解,能够指导你更快地看懂MySQL的运行机制。另外,如果你也要设计类似的系统级软件,这就是一个很好的借鉴。
|
||||
|
||||
## 一课一思
|
||||
|
||||
关系数据库是通过内置的DSL编译器来驱动运行的软件。那么,你还知道哪些软件是采用这样的机制来运行的?你如果去实现这样的软件,能从MySQL的实现思路里借鉴哪些思路?欢迎在留言区分享你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
如果要加深对MySQL内部机制的了解,我推荐两本书:一本是OReilly的《Understanding MySQL Internals》,第二本是《Expert MySQL》。
|
||||
158
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/热点问题答疑 | 如何吃透7种真实的编译器?.md
Normal file
158
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/热点问题答疑 | 如何吃透7种真实的编译器?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="热点问题答疑 | 如何吃透7种真实的编译器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/47/9996c4f40442dc71ebbee2a962ce6547.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
到这里,咱们就已经解析完7个编译器了。在这个过程中,你可能也积累了不少问题。所以今天这一讲,我就把其中有代表性的问题,给你具体分析一下。这样,能帮助你更好地掌握本课程的学习思路。
|
||||
|
||||
## 问题1:如何真正吃透课程中讲到的7种编译器?
|
||||
|
||||
在课程中,我们是从解析实际编译器入手的。而每一个真实的编译器里面都包含了大量的实战技术和知识点,所以你在学习的时候,很容易在某个点被卡住。那第一个问题,我想先给你解答一下,“真实编译器解析篇”这个模块的学习方法。
|
||||
|
||||
我们知道,学习知识最好能找到一个比较缓的坡,让自己可以慢慢爬上去,而不是一下子面对一面高墙。**那么对于研究真实编译器,这个缓坡是什么呢?**
|
||||
|
||||
我的建议是,你可以把掌握一个具体的编译器的目标,分解成四个级别的任务,逐步提高难度,直到最后吃透。
|
||||
|
||||
**第一个级别,就是听一听文稿,看一看文稿中给出的示例程序和源代码的链接就可以了。**
|
||||
|
||||
这个级别最重要的目标是什么?是掌握我给你梳理出来的这个编译器的技术主线,掌握一张地图,这样你就能有一个宏观且直观的把握,并且能增强你对编译原理的核心基础知识点的认知,就可以了。
|
||||
|
||||
小提示:关于编译器的技术主线和知识地图,你可以期待一下在期中复习周中,即将发布的“划重点:7种编译器的核心概念和算法”内容。
|
||||
|
||||
在这个基础上,如果你还想再进一步,那么就可以挑战第二级的任务。
|
||||
|
||||
**第二个级别,是要动手做实验。**
|
||||
|
||||
你可以运行一下我给出的那些使用编译器的命令,打印输出调试信息,或者使用一下课程中提到的图形化工具。
|
||||
|
||||
比如,在Graal和V8编译器,你可以通过修改命令行的参数,观察生成的IR是什么样子。这样你就可以了解到,什么情况下才会触发即时编译、什么时候才会触发内联优化、生成的汇编代码是什么样子的,等等。
|
||||
|
||||
这样,通过动手做练习,你对这些编译器的认识就会更加具体,并且会有一种自己可以驾驭的感觉,赢得信心。
|
||||
|
||||
**第三个级别,是看源代码,并跟踪源代码的运行过程,从而进入到编译器的内部,去解析一个编译器的真相。**
|
||||
|
||||
完成这一级的任务,对你动手能力的要求更高。你最容易遇到的问题,是搭建一个调试环境。比如,调试Graal编译器要采用远程调试的模式,跟你调试一个普通应用还是不大一样的。而采用GDB、LLDB这样的工具,对很多同学来说可能也是一个挑战。
|
||||
|
||||
而且,你在编译源代码和调试的过程中也会遇到很多与配置有关的问题。比如,我用GDB来调试Julia和MySQL的时候,就发现最好是使用一个Linux虚拟机,因为macOS对GDB的支持不够好。
|
||||
|
||||
不过,上述困难都不是说真的有多难,而是需要你的耐心。遇到问题就解决问题,最终搭建出一个你能驾驭的环境,这个过程也会大大提升你的动手实践能力。
|
||||
|
||||
环境搭建好了,在跟踪程序执行的过程中,一样要需要耐心。你可能要跟踪执行很多步,才能梳理出程序的执行脉络和实现思路。我在课程中建议的那些断点的位置和梳理出来程序的入口,可以给你提供一些帮助。
|
||||
|
||||
可以说,只要你能做好第三级的工作,终归是能吃透编译器的运行机制的。这个时候,你其实已经差不多进入了高手的行列。比如,在实际编程工作中,当遇到一个特别棘手的问题的时候,你可以跟踪到编译器、虚拟机的内部实现机制上去定位和解决问题。
|
||||
|
||||
而我前面说了,掌握一个具体的编译器的目标,是有四个级别的任务。那你可能要问,都能剖析源代码了,还要进一步挑战什么呢?
|
||||
|
||||
**这第四个级别呢,就是把代码跟编译原理和算法结合起来,实现认识的升华。**
|
||||
|
||||
在第三级,当你阅读和跟踪程序执行的时候,会遇到一个认知上的挑战。对于某些程序,你每行代码都能看懂,但为什么这么写,你其实不明白。
|
||||
|
||||
像编译器这样的软件,在解决每一个关键问题的时候,肯定都是有理论和算法支撑的。这跟我们平常写一些应用程序不大一样,这些应用程序很少会涉及到比较深入的原理和算法。
|
||||
|
||||
我举个例子,在讲Java编译器中的[语法分析器](https://time.geekbang.org/column/article/252828)的时候,我提到几点。第一,它是用递归下降算法的;第二,它在避免左递归时,采用了经典的文法改写的方法;第三,在处理二元表达式时,采用了运算符优先级算法,它是一种简单的LR算法。
|
||||
|
||||
我提到的这三点中的每一点,都是一个编译原理的知识点或算法。如果对这些理论没有具体的了解,那你看代码的时候就看不出门道来。类似的例子还有很多。
|
||||
|
||||
所以,如果你其实在编译原理的基础理论和算法上都有不错的素养的话,你会直接带着自己的假设去代码里进行印证,这样你就会发现每段程序,其实都是有一个算法去对应的,这样你就真的做到融会贯通了。
|
||||
|
||||
**那如何才能达到第四级的境界,如何才能理论和实践兼修且互相联系呢?**
|
||||
|
||||
- 第一,你要掌握“预备知识”模块中的编译原理核心基础知识和算法。
|
||||
- 第二,你要阅读相关的论文和设计文档。有一些论文是一些经典的、奠基性的论文。比如,在讲[Sea of Nodes类型的IR](https://time.geekbang.org/column/article/256914)的时候,我介绍了三篇重要的论文,需要你去看。还有一些论文或设计文档是针对某个编译器的具体的技术点的,这些论文对于你掌握该编译器的设计思路也很有帮助。
|
||||
|
||||
达到第四级的境界,你其实已经可以参与编译器的开发,并能成为该领域的技术专家了。针对某个具体的技术点加以研究和钻研,你也可以写出很有见地的论文。
|
||||
|
||||
当然,我不会要求每个同学都成为一个编译器的专家,因为这真的要投入大量的精力和实践。你可以根据自己的技术领域和发展规划,设定自己的目标。
|
||||
|
||||
**我的建议是:**
|
||||
|
||||
1. 首先,每个同学肯定要完成第一级的目标。这一级目标的要求是能理解主线,有时候要多读几遍才行。
|
||||
1. 对于第二级目标,我建议你针对2~3门你感兴趣的语言,上手做一做实验。
|
||||
1. 对于第三级目标,我希望你能够针对1门语言,去做一下深入探索,找一找跟踪调试一个编译器、甚至修改编译器的源代码的感觉。
|
||||
1. 对于第四级目标,我希望你能够针对那些常见的编译原理算法,比如前端的词法分析、语法分析,能够在编译器里找到并理解它们的实现。至于那些更加深入的算法,可以作为延伸任务。
|
||||
|
||||
总的来说呢,“真实编译器”这个模块的课程内容,为你的学习提供了开放式的各种可能性。
|
||||
|
||||
好,接下来,我就针对同学们的提问和课程的思考题,来做一下解析。
|
||||
|
||||
## 问题2:多重分派是泛型实现吗?
|
||||
|
||||
>
|
||||
@d:“多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。”宫老师,多重分派是泛型实现吗?
|
||||
|
||||
|
||||
由于大多数同学目前使用的语言,采用的都是面向对象的编程范式,所以会比较熟悉像这样的一种函数或方法派发的方式:
|
||||
|
||||
```
|
||||
Mammal mammal = new Cow(); //Cow是Mammal的一个子类
|
||||
mammal.speak();
|
||||
|
||||
```
|
||||
|
||||
**这是调用了mammal的一个方法:speak。**那这个speak方法具体指的是哪个实现呢?根据面向对象的继承规则,这个方法可以是在Cow上定义的。如果Cow本身没有定义,就去它的父类中去逐级查找。所以,**speak()具体采用哪个实现,是完全由mammal对象的类型来确定的**。这就是单一分派。
|
||||
|
||||
我们认为,mammal对象实际上是speak方法的第一个参数,虽然在语法上,它并没有出现在参数列表中。而[Java的运行时机制](https://time.geekbang.org/column/article/257504),也确实是这么实现的。你可以通过查看编译生成的字节码或汇编代码来验证一下。你如果在方法中使用“this”对象,那么实际上访问的是方法的0号参数来获取对象的引用或地址。
|
||||
|
||||
在采用单一分派的情况下,对于二元(或者更多元)的运算的实现是比较别扭的,比如下面的整型和浮点型相加的方法,你需要在整型和浮点型的对象中,分别去实现整型加浮点型,以及浮点型加整型的计算:
|
||||
|
||||
```
|
||||
Integer a = 2;
|
||||
Float b = 3.1;
|
||||
a.add(b); //采用整型对象的add方法。
|
||||
b.add(a); //采用浮点型对象的add方法。
|
||||
|
||||
```
|
||||
|
||||
**但如果再增加新的类型怎么办呢?那么所有原有的类都要去修改,以便支持新的加法运算吗?**
|
||||
|
||||
多重分派的情况,就不是仅仅由第一个参数来确定函数的实现了,而是会依赖多个参数的组合。这就能很优雅地解决上述问题。在增加新的数据类型的时候,你只需要增加新的函数即可。
|
||||
|
||||
```
|
||||
add(Integer a, Float b);
|
||||
add(Float b, Integer a);
|
||||
add(Integer a, MyType b); //支持新的类型
|
||||
|
||||
```
|
||||
|
||||
不过,这里又有一个问题出现了。如果对每种具体的类型,都去实现一个函数的话,那么实现的工作量也很大。这个时候,我们就可以用上泛型了,或者叫参数化类型。
|
||||
|
||||
通过泛型的机制,我们可以让相同的实现逻辑只书写一次。在第三个模块“现代语言设计篇”中,专门有一讲给你进一步展开**泛型的实现机制**,到时你可以去深入学习下。
|
||||
|
||||
## 问题3:安全点是怎么回事?为什么编译器生成的某些汇编代码我看不懂?
|
||||
|
||||
>
|
||||
@智昂张智恩震:请问老师,和JVM握手就是插入safepoint的过程吗?具体的握手是在做什么?
|
||||
|
||||
|
||||
你在查看编译器生成的汇编代码的时候,经常会看到一些辅助性的代码逻辑。它们的作用不是要把你的代码翻译成汇编代码才生成的,而是要去实现一些运行时机制。
|
||||
|
||||
我举几个例子。
|
||||
|
||||
**第一个例子,是做逆优化。**比如V8中把函数编译成机器码,是基于对类型的推断。如果实际执行的时候,编译器发现类型跟推断不符,就要执行逆优化,跳转到解释器去执行。这个时候,你就会看到汇编代码里有一些指令,是用于做逆优化功能的。
|
||||
|
||||
**第二个例子,是在并行中会遇到的抢占式调度问题。**协程这种并发机制,是应用级的并发。一个线程上会有多个协程在运行。但是,如果其中一个协程的运行时间很长,就会占据太多的计算资源,让这个线程上的其他协程没有机会去运行。对于一些比较高级的协程调度器,比如Go语言的调度器,就能够把一个长时间运行的协程暂停下来,让其他协程来运行。怎么实现这种调度呢?那就要编译器在生成的代码里,去插入一些逻辑,配合调度器去做这种调度。
|
||||
|
||||
**第三个例子,是垃圾收集。**根据编译器所采用的垃圾收集算法,在进行垃圾收集时,可能会做内存的拷贝,把一个对象从一个地方拷贝到另一地方。这在某些情况下,会导致程序出错。比如,当你读一个Java对象的成员变量的值的时候,生成的汇编代码会根据对象的地址,加上一定的偏移量,得到该成员变量的地址。但这个时候,这个对象的地址被垃圾收集器改变了,那么程序的逻辑就错了。所以在做垃圾回收的时候,相关的线程一定要停在叫做“安全点(safepoint)”的地方,在这些地方去修改对象的地址,程序就不会出错。
|
||||
|
||||
@智昂张智恩震 同学提出的问题,就针对垃圾收集这种场景的。在Java生成的汇编代码里,程序要在安全点去跟运行时做一下互动(握手)。如果需要的话,当前线程就会被垃圾收集器停下,以便执行垃圾收集操作。
|
||||
|
||||
所以你看,只有了解了一门语言的运行时机制,才能懂得为什么要生成这样的代码。关于垃圾收集和并发机制,我也会在第三个模块中跟你去做进一步的探讨。
|
||||
|
||||
## 问题4:SSA只允许给变量赋一次值,循环中的变量是多次赋值的,不是矛盾了吗?
|
||||
|
||||
>
|
||||
@qinsi:关于思考题,SSA只允许给变量赋一次值,如果是循环的话就意味着要创建循环次数那么多的临时变量了?
|
||||
|
||||
|
||||
@qinsi 同学问的这个问题其实挺深入,也很有意思。
|
||||
|
||||
是这样的。我们在做编译的时候,大部分时候是做静态的分析,也就是仅仅基于程序从词法角度(Lexically)的定义,不看它运行时的状态。**注意**,我这里说的词法,不是指词法分析的词法,而是指程序文本中体现的“使用和定义”(use-def)关系、控制流等。词法作用域(Lexical Scope)中的词法,也是同一个意思。
|
||||
|
||||
所以,SSA中说的赋值,实际上是对该变量(或称作值)做了一个定义,体现了变量之间的“使用和定义”(use-def)关系,也就是体现了变量之间的数据依赖,或者说是数据流,因此可以用来做数据流分析,从而实现各种优化算法。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲的答疑,我首先帮你梳理了学习真实世界编译器的方法。一个真实的编译器里涉及的技术和知识点确实比较多,但有挑战就有应对方法。我给你梳理了四级的学习阶梯,你探索内容的多少,也可以根据自己的需求和兴趣来把握。按照这个学习路径,你既可以去做一些宏观的了解,也可以在某个具体点上去做深入,这是一个有弹性的学习体系。
|
||||
|
||||
另外,我也挑了几个有意思的问题做了解答,在解答中也对涉及的知识点做了延伸和扩展。其中一些知识点,我还会在第三个模块中做进一步的介绍,比如垃圾收集机制、并发机制,以及泛型等。等你学完第三个模块,再回头看实际编译器的时候,你的认知会再次迭代。
|
||||
|
||||
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。
|
||||
Reference in New Issue
Block a user