first commit

This commit is contained in:
张乾 2024-10-16 10:05:23 +08:00
parent 1121fdaab5
commit 9f7e624377
33 changed files with 9865 additions and 0 deletions

View File

@ -0,0 +1,265 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 运行时机制:程序如何运行,你有发言权
你好,我是宫文学。在语义分析之后,编译过程就开始进入中后端了。
经过前端阶段的处理分析编译器已经充分理解了源代码的含义准备好把前端处理的结果带有标注信息的AST、符号表翻译成目标代码了。
我在第1讲也说过如果想做好翻译工作编译器必须理解目标代码。而要理解目标代码它就必须要理解目标代码是如何被执行的。通常情况下程序有两种执行模式。
第一种执行模式是在物理机上运行。针对的是C、C++、Go这样的语言编译器直接将源代码编译成汇编代码或直接生成机器码然后生成能够在操作系统上运行的可执行程序。为了实现它们的后端编译器需要理解程序在底层的运行环境包括CPU、内存、操作系统跟程序的互动关系并要能理解汇编代码。
第二种执行模式是在虚拟机上运行。针对的是Java、Python、Erlang和Lua等语言它们能够在虚拟机上解释执行。这时候编译器要理解该语言的虚拟机的运行机制并生成能够被执行的IR。
理解了这两种执行模式的特点,我们也就能弄清楚用高级语言编写的程序是如何运行的,进而也就理解了编译器在中后端的任务是什么。接下来,我们就从最基础的物理机模式开始学习吧。
在物理机上运行
在计算机发展的早期科学家们确立了计算机的结构并一直延续至今这种结构就是冯·诺依曼结构。它的主要特点是数据和指令不加区别混合存储在同一个存储器中即主存或叫做内存用一个指令指针指向内存中指令的位置CPU就能自动加载这个位置的指令并执行。
在x86架构下这个指针是eip寄存器32位模式或rip寄存器64位模式。一条指令执行完毕指令指针自动增加并执行下一条指令。如果遇到跳转指令则跳转到另一个地址去执行。
图1计算机的运行机制
这其实就是计算机最基本的运行原理。这样你就可以在大脑中建立起像图1那样的直观结构。
通过图1你会看到计算机指令的执行基本上只跟两个硬件相关一个是CPU一个是内存。
CPU
CPU是计算机的核心。从硬件构成方面我们需要知道它的三个信息
第一CPU上面有寄存器并且可以直接由指令访问。寄存器的读写速度非常快大约是内存的100倍。所以我们编译后的代码要尽量充分利用寄存器而不是频繁地去访问内存。
第二CPU有高速缓存并且可能是多级的。高速缓存也比内存快。CPU在读取指令和数据的时候不是一次读取一条而是读取相邻的一批数据放到高速缓存里。接下来要读取的数据很可能已经在高速缓存里了通过这种机制来提高运行性能。因此编译器要尽量提高缓存的命中率。
第三CPU内部有多个功能单元有的负责计算有的负责解码等等。所以一个指令可以被切分成多个执行阶段每个阶段在不同的功能单元上运行这为实现指令级并行提供了硬件基础。在第8讲我还会和你详细解释这个话题。
好了掌握了这个知识点我们可以继续往下学习了。我们说CPU是运行指令的地方那指令到底是什么样子的呢
我们知道CPU有多种不同的架构比如x86架构、ARM架构等。不同架构的CPU它的指令是不一样的。不过它们的共性之处在于指令都是01这样的机器码。为了便于理解我们通常会用汇编代码来表示机器指令。比如b=a+2指令对应的汇编码可能是这样的
movl -4(%rbp), %eax #把%rbp-4内存地址的值拷贝到%eax寄存器
addl $2, %eax #把2加到%eax寄存器
movl %eax, -8(%rbp) #把%eax寄存器的值保存回内存地址是%rbp-8
上面的汇编代码采用的是GNU汇编器规定的格式。每条指令都包含了两部分操作码opcode和操作数oprand
图2汇编代码示例
操作码是让CPU执行的动作。这段示例代码中movl、addl是助记符Assembly Mnemonic其中的mov和add是指令l是后缀表示操作数的位数。
而操作数是指令的操作对象它可以是常数、寄存器和某个内存地址。图2示例的汇编代码中“$2”就是个常数在指令里我们把它叫做立即数而“%eax”是访问一个寄存器其中eax是寄存器的名称而带有括号的“-4(%rbp)”则是对内存的访问方式这个内存的地址是在rbp寄存器的值的基础上减去4。
如果你还想对指令、汇编代码有更多的了解可以再去查阅些资料学习比如去参考下我的《编译原理之美》中的第22、23、31这几讲。
这里要提一下虽然程序觉得自己一直在使用CPU但实际上背后有操作系统在做调度。操作系统是管理系统资源的而CPU是计算机的核心资源操作系统会把CPU的时间划分成多个时间片分配给不同的程序使用每个程序实际上都是在“断断续续”地使用CPU这就是操作系统的分时调度机制。在后面课程里讨论并发的时候我们会更加深入地探讨这个机制。
内存
好了,接下来我说说执行指令相关的另一个硬件:内存。
程序在运行时操作系统会给它分配一块虚拟的内存空间让它可以在运行期内使用。内存中的每个位置都有一个地址地址的长度决定了能够表示多大空间这叫做寻址空间。我们目前使用的都是64位的机器理论上你可以用一个64位的长整型来表示内存地址。
不过由于我们根本用不了这么大的内存所以AMD64架构的寻址空间只使用了48位。但这也有256TB远远超出了一般情况下的需求。所以像Windows这样的操作系统还会给予进一步的限制缩小程序的寻址空间。
图348位寻址空间有多大
但即使是在加了限制的情况下程序在逻辑上可使用的内存一般也会大于实际的物理内存。不过进程不会一下子使用那么多的内存只有在向操作系统申请内存的时候操作系统才会把一块物理内存映射成进程寻址空间内的一块内存。对应到图4中中间一条是物理内存上下两条是两个进程的寻址空间它们要比物理内存大。
对于有些物理内存的内容还可以映射进多个进程的地址空间以减少内存的使用。比如说如果进程1和进程2运行的是同一个可执行文件那么程序的代码段是可以在两个进程之间共享的。你在图中可以看到这种情况。
图4物理内存和逻辑内存的关系
另外,对于已经分配给进程的内存,如果进程很长时间不用,操作系统会把它写到磁盘上,以便腾出更多可用的物理内存。在需要的时候,再把这块空间的数据从磁盘中读回来。这就是操作系统的虚拟内存机制。
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
那么从程序角度来说,我们应该怎样使用内存呢?
本质上来说你想怎么用就怎么用并没有什么特别的限制。一个编译器的作者可以决定在哪儿放代码在哪儿放数据。当然了别的作者也可能采用其他的策略。比如C语言和Java虚拟机对内存的管理和使用策略就是不同的。
不过尽管如此大多数语言还是会采用一些通用的内存管理模式。以C语言为例会把内存划分为代码区、静态数据区、栈和堆如下所示。
图5C语言的内存布局方式
其中代码区也叫做文本段主要存放编译完成后的机器码也就是CPU指令静态数据区会保存程序中的全局变量和常量。这些内存是静态的、固定大小的在编译完毕以后就能确定清楚所占用空间的大小、代码区每个函数的地址以及静态数据区每个变量和常量的地址。这些内存在程序运行期间会一直被占用。
而堆和栈,属于程序动态、按需获取的内存。我来和你分析下这两种内存。
我们先看看栈Stack。使用栈的一个好处是操作系统会根据程序使用内存的需求自动地增加或减少栈的空间。通常来说操作系统会用一个寄存器保存栈顶的地址程序可以修改这个寄存器的值来获取或者释放空间。有的CPU还有专门的指令来管理栈比如x86架构会使用push和pop指令把数据写入栈或弹出栈并自动修改栈顶指针。
在程序里使用栈的场景是这样的程序的运行可以看做是在逐级调用函数或者叫过程。像下面的示例程序存在着main->bar->foo的调用结构这也就是控制流转移的过程。
int main(){
int a = 1;
foo(3);
bar();
}
int foo(int c){
int b = 2;
return b+c;
}
int bar(){
return foo(4) + 1;
图6程序逐级调用的过程
每次调用函数的过程中都需要一些空间来保存一些信息比如参数、需要保护的寄存器的值、返回地址、本地变量等这些信息叫做这个过程的活动记录Activation Record
注意活动记录是个逻辑概念。在物理实现上一些信息可以保存在寄存器里使得性能更高。比如说依据一些约定返回值和少于6个的参数是通过寄存器传递的。这里所说的“依据约定”是指在调用一个函数时如何传递参数、如何设定返回地址、如何获取返回值的这种约定我们把它称之为ABIApplication Binary Interface应用程序二进制接口。利用ABI使得我们可以用一种语言写的程序去调用另外的语言写的程序。
另一些信息会保存在栈里。每个函数或过程在栈里保存的信息叫做栈帧Stack Frame。我们可以自由设计栈帧的结构比如下图就是一种常见的设计
图7一种可能的栈帧结构
返回值一般放在最顶上这样它的地址是固定的。foo函数返回以后它的调用者可以到这里来取到返回值。在实际情况中ABI会规定优先通过寄存器来传递返回值比通过内存传递性能更高。
参数在调用foo函数时我们把它所需要一个整型参数写到栈帧的这个位置。同样我们也可以通过寄存器来传递参数而不是通过内存。
控制链接就是上一级栈帧也就是main函数的栈帧的地址。如果该函数用到了上一级作用域中的变量那么就可以顺着这个链接找到上一级作用域的栈帧并找到变量的值。
返回地址: foo函数执行完毕以后继续执行哪条指令。同样我们可以用寄存器来保存这个信息。
本地变量: foo函数的本地变量b的存储空间。
寄存器信息我们还经常在栈帧里保存寄存器的数据。如果在foo函数里要使用某个寄存器可能需要先把它的值保存下来防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者责任也就是使用寄存器的函数要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器但它又要调用别的函数为了防止别的函数把自己放在寄存器中的数据覆盖掉这个函数就要自己把寄存器信息保存在栈帧中。这种约定叫做调用者责任。
对于示例程序,在多级调用以后,栈里的信息可能是下面这个样子。如果你想看到这个信息,通常可以在调试程序的时候打印出来。
图8一个运行中的栈的示例
理解了栈的机制以后我们再来看看动态获取内存的第二种方式Heap
操作系统一般会提供一个API供应用申请内存。当应用程序用完之后要通过另一个API释放。如果忘记释放就会造成内存越用越少这叫做内存泄漏。
相对于栈来说,这是堆的一个缺点。不过,相应的好处是,应用在堆里申请的对象的生存期,可以由自己控制,不会像栈里的内存那样,在退出作用域之后就被自动收回。所以,如果数据的生存期超过了创建它的作用域的生存期,就必须在堆中申请内存。
扩展:反之,如果数据的生存期跟创建它的作用域一致的话,那么在栈里和堆里申请都是可以的。当然,肯定在栈里申请更划算。所以,编译优化中的逃逸分析,本质就是分析出哪些对象的生存期是跟函数或方法的生存期一致的,那么就不需要到堆里申请了。
另外,在并发的场景下,由于栈是线程独享的,而堆是多个线程共享的,所以在堆里申请内存的效率会更低,因为需要在多个线程之间同步,避免出现竞争。
那为了避免内存泄漏,在设计一门语言的时候,通常需要提供内存管理的方案。
一种方案是像C和C++那样由程序员自己负责内存的释放这对程序员的要求就比较高。另一种方案是像Java语言那样自动地管理内存这个特性也叫做垃圾收集。垃圾收集是语言的运行时功能能够通过一定的算法来回收不用的内存。
总结起来在计算机上运行一个程序我们需要跟两个硬件打交道一个是CPU它能够从内存中读取指令并顺序执行第二个硬件是内存内存使用模式有栈和堆两种方式两种方式有各自的优点和适用场景。
运行时系统
除了硬件支撑程序的运行还需要软件这些软件叫做运行时系统Runtime System或者叫运行时Runtime。前面我们提到的垃圾收集器就是一个运行时的软件。进行并发调度的软件也是运行时的组成部分。
实际上对于把源代码编译成机器码在操作系统上运行的语言来说比如C、C++操作系统本身就可以看做是它们的运行时系统。它可以帮助程序调度CPU资源、内存资源以及其他一些资源如IO端口。
但也有很多语言比如Java、Python、Erlang和Lua等它们不是直接在操作系统上运行的而是运行在虚拟机上。那么它们的执行模式有什么特点对编译有什么影响呢
在虚拟机上运行
虚拟机是计算机语言的一种运行时系统。虚拟机上运行的是中间代码而不是CPU可以直接认识的指令。
虚拟机有两种模型一种叫做栈机Stack Machine一种叫做寄存器机Register Machine。它们的区别主要在于如何获取指令的操作数。
栈机是从栈里获取,而寄存器机是从寄存器里获取。这两种虚拟机各有优缺点。
基于栈的虚拟机
首先说说栈机。JVM和Python中的解释器都采用了栈机的模型。在本讲中我主要介绍Java的虚拟机的运行机制。
JVM中每一个线程都有一个JVM栈每次调用一个方法都会生成一个栈帧来支持这个方法的运行。这跟C语言很相似。但JVM的栈帧比C语言的复杂它包含了一个本地变量数组包括方法的参数和本地变量、操作数栈、到运行时常量池的引用等信息。
对比JVM的栈帧和C语言栈帧的设计你应该得到一些启示栈帧的结构是语言的作者可以自己设计的没有什么死规定。所以我们学知识也不要学死了以为栈帧只有一种结构。
注意我们这里提到了两个栈一个是类似于C语言的栈的方法栈另一个是方法栈里每个栈帧中的操作数栈。而我们说的栈机中的“栈”指的是这个操作数栈不要弄混了。
图9JVM中一个栈帧的结构
对于每个指令,解释器先要把它的操作数压到栈里。在执行指令时,从栈里弹出操作数,计算完毕以后,再把结果压回栈里。
以“2+3*5”为例它对应的栈机的代码如下
push 2 //把操作数2入栈
push 3 //把操作数3入栈
push 5 //把操作数5入栈, 栈里目前是2、 3、 5
imul //弹出5和3执行整数乘法运算得到15然后把结果入栈现在栈里是2、15
iadd //弹出15和2执行整数加法运算得到17然后把结果入栈最后栈里是17
提示:对于不同大小的常量操作数,实际上生成的指令会不同。这里只是示意。
注意一点要从AST生成上面的代码你只需要对AST做深度优先的遍历即可。先后经过的节点是2->3->5->*->+(注:这种把操作符放在后面的写法,叫做逆波兰表达式,也叫后缀表达式)。
图102+3*5对应的AST
生成上述栈机代码只需要深度优先地遍历AST并且只需要进行两种操作
在遇到字面量或者变量的时候生成push指令
在遇到操作符的时候,生成相应的操作指令即可。
你能看出,这个算法相当简单,这也是栈机最大的优点。
你还会注意到像imul和iadd这样的指令不需要带操作数因为指令所需的操作数就在栈顶。这是栈机的指令跟汇编语言的指令的最大区别。
注意imul和iadd中的i代表这两个指令是对整型值做操作。对浮点型、长整型等不同类型分别对应不同的指令前缀。
好了现在你已经了解了栈机的原理。基于对栈机的认知你再去阅读Java和Python的字节码就会更加容易了。而关于Python的虚拟机我还会在后续课程中详细展开。
基于寄存器的虚拟机
除了栈机之外另一种虚拟机是寄存器机。寄存器机使用寄存器名称来表示操作数所以它的指令也跟汇编代码相似像add这样的操作码后面要跟操作数。
在实践中早期版本的安卓系统中用于解释执行代码的Dalvik虚拟机就采用了寄存器模式而Erlang和Lua语言的虚拟机也是寄存器机。JavaScript引擎V8的比较新的版本中也引入了一个解释器Ignition它也是个寄存器机。
与栈机相比利用寄存器机编译所生成的代码更少因为省去了很多push指令。
不过寄存器机所指的寄存器不一定是真正的物理寄存器有可能只是栈帧中的一个位置。当然有的寄存器机在实现的时候确实会用到物理寄存器从而提高计算性能。我们在后面研究V8的Ignition解释器时会看到这种实现。
课程小结
本讲我带你了解了代码是如何被运行的,以及是在什么样的环境中运行的。这样,你才会知道如何让编译器生成正确的代码。
现有的程序有两大类执行模式。一类是编译成本地代码机器码运行在物理机和操作系统上这时候你需要掌握目标机器的汇编代码知道指令是如何跟CPU和内存打交道的也需要知道操作系统在其中扮演了什么角色。另一大类是在虚拟机上运行的虚拟机又分为栈机和寄存器机两大类你需要明确它们之间的区别才能知道为什么它们的IR是不同的又分别有什么优缺点。
不过现代程序的运行环境往往比较复杂。像Java等语言既可以解释执行字节码又能够即时编译成本地代码运行所以它们的运行时机制就更复杂一些。你要综合两种运行时机制的知识才能完整地理解JVM。
一课一思
我们现在已经知道,栈是一种自动管理内存的机制,你只要修改栈顶指针,就可以获得所需的内存。那么,你能否结合操作系统的知识,研究一下这个过程是如何实现的呢?
欢迎在留言区分享你的答案,如果这节课对你有帮助,也欢迎你把它分享给你的朋友。
参考资料
1.关于JVM栈帧的结构可以参考JVM Specification。-
2. 关于Java字节码的指令集可以参考Java Language Specification。

View File

@ -0,0 +1,322 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 中间代码:不是只有一副面孔
你好我是宫文学。今天这一讲我来带你认识一下中间代码IR
IR也就是中间代码Intermediate Representation有时也称Intermediate CodeIC它是编译器中很重要的一种数据结构。编译器在做完前端工作以后首先就是生成IR并在此基础上执行各种优化算法最后再生成目标代码。
所以说编译技术的IR非常重要它是运行各种优化算法、代码生成算法的基础。不过鉴于IR的设计一般与编译器密切相关而一些教科书可能更侧重于讲理论所以对IR的介绍就不那么具体。这就导致我们对IR有非常多的疑问比如
IR都有哪些不同的设计可以分成什么类型
IR有像高级语言和汇编代码那样的标准书写格式吗
IR可以采用什么数据结构来实现
为了帮助你把对IR的认识从抽象变得具体我今天就从全局的视角和你一起梳理下IR有关的认知。
首先我们来了解一下IR的用途并一起看看由于用途不同导致IR分成的多个层次。
IR的用途和层次
设计IR的目的是要满足编译器中的各种需求。需求的不同就会导致IR的设计不同。通常情况下IR有两种用途一种是用来做分析和变换的一种是直接用于解释执行的。我们先来看第一种。
编译器中基于IR的分析和处理工作一开始可以基于一些抽象层次比较高的语义这时所需要的IR更接近源代码。而在后面则会使用低层次的、更加接近目标代码的语义。
基于这种从高到低的抽象层次IR可以归结为HIR、MIR和LIR三类。
HIR基于源语言做一些分析和变换
假设你要开发一款IDE那最主要的功能包括发现语法错误、分析符号之间的依赖关系以便进行跳转、判断方法的重载等、根据需要自动生成或修改一些代码提供重构能力
这个时候你对IR的需求是能够准确表达源语言的语义就行了。这种类型的IR可以叫做High IR简称HIR。
其实AST和符号表就可以满足这个需求。也就是说AST也可以算作一种IR。如果你要开发IDE、代码翻译工具从一门语言翻译到另一门语言、代码生成工具、代码统计工具等使用AST加上符号表就够了。
当然有些HIR并不是树状结构比如可以采用线性结构但一般会保留诸如条件判断、循环、数组等抽象层次比较高的语法结构。
基于HIR可以做一些高层次的代码优化比如常数折叠、内联等。在Java和Go的编译器中你可以看到不少基于AST做的优化工作。
MIR独立于源语言和CPU架构做分析和优化
大量的优化算法是可以通用的没有必要依赖源语言的语法和语义也没有必要依赖具体的CPU架构。
这些优化包括部分算术优化、常量和变量传播、死代码删除等我会在下一讲和你介绍。实现这类分析和优化功能的IR可以叫做Middle IR简称MIR。
因为MIR跟源代码和目标代码都无关所以在讲解优化算法时通常是基于MIR比如三地址代码Three Address CodeTAC
TAC的特点是最多有三个地址也就是变量其中赋值符号的左边是用来写入的而右边最多可以有两个地址和一个操作符用于读取数据并计算。
我们来看一个例子示例函数foo
int foo (int a){
int b = 0;
if (a > 10)
b = a;
else
b = 10;
return b;
}
对应的TAC可能是
BB1:
b := 0
if a>10 goto BB3 //如果t是false(0),转到BB3
BB2:
b := 10
goto BB4
BB3:
b := a
BB4:
return b
可以看到TAC用goto语句取代了if语句、循环语句这种比较高级的语句当然也不会有类、继承这些高层的语言结构。但是它又没有涉及数据如何在内存读写等细节书写格式也不像汇编代码与具体的目标代码也是独立的。
所以,它的抽象程度算是不高不低。
LIR依赖于CPU架构做优化和代码生成
最后一类IR就是Low IR简称LIR。
这类IR的特点是它的指令通常可以与机器指令一一对应比较容易翻译成机器指令或汇编代码。因为LIR体现了CPU架构的底层特征因此可以做一些与具体CPU架构相关的优化。
比如下面是Java的JIT编译器输出的LIR信息里面的指令名称已经跟汇编代码很像了并且会直接使用AMD64架构的寄存器名称。
图1Java的JIT编译器的LIR
好了以上就是根据不同的使用目的和抽象层次所划分出来的不同IR的关键知识点了。
HIR、MIR和LIR这种划分方法主要是参考“鲸书Advanced Compiler Design and Implementation”的提法。对此有兴趣的话你可以参考一下这本书。
在实际操作时有时候IR的划分标准不一定跟鲸书一致。在有的编译器里比如Graal编译器把相对高层次的IR叫做HIR相对低层次的叫做LIR而没有MIR。你只要知道它们代表了不同的抽象层次就足够了。
其实在一个编译器里有时候会使用抽象层次从高到低的多种IR从便于“人”理解到便于“机器”理解。而编译过程可以理解为抽象层次高的IR一直lower到抽象层次低的IR的过程并且在每种IR上都会做一些适合这种IR的分析和处理工作直到最后生成了优化的目标代码。
扩展lower这个词的意思就是把对计算机程序的表示从抽象层次比较高的、便于人理解的格式转化为抽象层次比较低的、便于机器理解的格式。
有些IR的设计本身就混合了多个抽象层次的元素比如Java的Graal编译器里就采用了这种设计。Graal的IR采用的是一种图结构但随着优化阶段的进展图中的一些节点会逐步从语义比较抽象的节点lower到体现具体架构特征的节点。
P-code用于解释执行的IR
好了前3类IR是从抽象层次来划分的它们都是用来做分析和变换的。我们继续看看第二种直接用于解释执行的IR。这类IR还有一个名称叫做P-code也就是Portable Code的意思。由于它与具体机器无关因此可以很容易地运行在多种电脑上。这类IR对编译器来说就是做编译的目标代码。
到这里你一下子就会想到Java的字节码就是这种IR。除此之外Python、Erlang也有自己的字节码.NET平台、Visual Basic程序也不例外。
其实你也完全可以基于AST实现一个全功能的解释器只不过性能会差一些。对于专门用来解释执行IR通常会有一些特别的设计跟虚拟机配合来尽量提升运行速度。
需要注意的是P-code也可能被进一步编译形成可以直接执行的机器码。Java的字节码就是这样的例子。因此在这门课程里我会带你探究Java的两个编译器一个把源代码编译成字节码一个把字节码编译成目标代码支持JIT和AOT两种方式
好了通过了解IR的不同用途你应该会对IR的概念更清晰一些。用途不同对IR的需求也就不同IR的设计自然也就会不同。这跟软件设计是由需求决定的是同一个道理。
接下来的一个问题是IR是怎样书写的呢
IR的呈现格式
虽然说是中间代码,但总得有一个书写格式吧,就像源代码和汇编代码那样。
其实IR通常是没有书写格式的。一方面大多数的IR跟AST一样只是编译过程中的一个数据结构而已或者说只有内存格式。比如LLVM的IR在内存里是一些对象和接口。
另一方面为了调试的需要你可以把IR以文本的方式输出用于显示和分析。在这门课里你也会看到很多IR的输出格式。比如下面是Julia的IR
图2Julia语言输出的IR信息
在少量情况下IR有比较严格的输出格式不仅用于显示和分析还可以作为结果保存并可以重新读入编译器中。比如LLVM的bitcode可以保存成文本和二进制两种格式这两种格式间还可以相互转换。
我们以C语言为例来看下fun1函数及其对应的LLVM IR的文本格式和二进制格式
//fun1.c
int fun1(int a, int b){
int c = 10;
return a+b+c;
}
LLVM IR的文本格式用“clang -emit-llvm -S fun1.c -o fun1.ll”命令生成这里只节选了主要部分
; ModuleID = 'fun1.c'
source_filename = "function-call1.c"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.14.0"
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @fun1(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
store i32 10, i32* %5, align 4
%6 = load i32, i32* %3, align 4
%7 = load i32, i32* %4, align 4
%8 = add nsw i32 %6, %7
%9 = load i32, i32* %5, align 4
%10 = add nsw i32 %8, %9
ret i32 %10
}
二进制格式用“clang -emit-llvm -c fun1.c -o fun1.bc”命令生成用“hexdump -C fun1.bc”命令显示
图3LLVM IR的二进制格式
IR的数据结构
既然我们一直说IR会表现为内存中的数据结构那它到底是什么结构呢
在实际的实现中有线性结构、树结构、有向无环图DAG、程序依赖图PDG等多种格式。编译器会根据需要选择合适的数据结构。在运行某些算法的时候采用某个数据结构可能会更顺畅而采用另一些结构可能会带来内在的阻滞。所以我们一定要根据具体要处理的工作的特点来选择合适的数据结构。
那我们接下来,就具体看看每种格式的特点。
第一种类似TAC的线性结构Linear Form
你可以把代码表示成一行行的指令或语句,用数组或者列表保存就行了。其中的符号,需要引用符号表,来提供类型等信息。
这种线性结构有时候也被称作goto格式。因为高级语言里的条件语句、循环语句要变成用goto语句跳转的方式。
第二种:树结构
树结构当然可以用作IRAST就是一种树结构。
很多资料中讲指令选择的时候也会用到一种树状的结构便于执行树覆盖算法。这个树结构就属于一种LIR。
树结构的缺点是可能有冗余的子树。比如语句“a=5; b=(2+a)+a*3; ”形成的AST就有冗余。如果基于这个树结构生成代码可能会做两次从内存中读取a的值的操作并存到两个临时变量中。
图4冗余的子树
第三种有向无环图Directed Acyclic GraphDAG
DAG结构是在树结构的基础上消除了冗余的子树。比如上面的例子转化成DAG以后对a的内存访问只做一次就行了。
图5DAG结构消除了冗余的子树
在LLVM的目标代码生成环节就使用了DAG来表示基本块内的代码。
第四种程序依赖图Program Dependence GraphPDG
程序依赖图,是显式地把程序中的数据依赖和控制依赖表示出来,形成一个图状的数据结构。基于这个数据结构,我们再做一些优化算法的时候,会更容易实现。
所以现在有很多编译器在运行优化算法的时候都基于类似PDG的数据结构比如我在课程后面会分析的Java的JIT编译器和JavaScript的编译器。
这种数据结构里因为会有很多图节点又被形象地称为“节点之海Sea of Nodes”。你在很多文章中都会看到这个词。
以上就是常用于IR的数据结构了。接下来我再介绍一个重要的IR设计范式SSA格式。
SSA格式的IR
SSA是Static Single Assignment的缩写也就是静态单赋值。这是IR的一种设计范式它要求一个变量只能被赋值一次。我们来看个例子。
“y = x1 + x2 + x3 + x4”的普通TAC如下
y := x1 + x2;
y := y + x3;
y := y + x4;
其中y被赋值了三次如果我们写成SSA的形式就只能写成下面的样子
t1 := x1 + x2;
t2 := t1 + x3;
y := t2 + x4;
那我们为什么要费力写成这种形式呢还要为此多添加t1和t2两个临时变量
原因是使用SSA的形式体现了精确的“使用-定义use-def”关系。并且由于变量的值定义出来以后就不再变化使得基于SSA更容易运行一些优化算法。在后面的课程中我会通过实际的例子带你体会这一点。
在SSA格式的IR中还会涉及一个你经常会碰到的但有些特别的指令叫做 phi指令。它是什么意思呢我们看一个例子。
同样对于示例代码foo
int foo (int a){
int b = 0;
if (a > 10)
b = a;
else
b = 10;
return b;
}
它对应的SSA格式的IR可以写成
BB1:
b1 := 0
if a>10 goto BB3
BB2:
b2 := 10
goto BB4
BB3:
b3 := a
BB4:
b4 := phi(BB2, BB3, b2, b3)
return b4
其中变量b有4个版本b1是初始值b2是else块BB2的取值b3是if块BB3的取值最后一个基本块BB4要把b的最后取值作为函数返回值。很明显b的取值有可能是b2也有可能是b3。这时候就需要phi指令了。
phi指令会根据控制流的实际情况确定b4的值。如果BB4的前序节点是BB2那么b4的取值是b2而如果BB4的前序节点是BB3那么b4的取值就是b3。所以你会看到如果要满足SSA的要求也就是一个变量只能赋值一次那么在遇到有程序分支的情况下就必须引入phi指令。关于这一点你也会在课程后面经常见到它。
最后我要指出的是由于SSA格式的优点现代语言用于优化的IR很多都是基于SSA的了包括我们本课程涉及的Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器以及LLVM工具等。所以你一定要高度重视SSA。
课程小结
今天这一讲我希望你能记住关于IR的几个重要概念
根据抽象层次和使用目的不同可以设计不同的IR
IR可能采取多种数据结构每种结构适合不同的处理工作
由于SSA格式的优点主流的编译器都在采用这种范式来设计IR。
通过学习IR你会形成看待编译过程的一个新视角整个编译过程就是生成从高抽象度到低抽象度的一系列IR以及发生在这些IR上的分析与处理过程。
我还展示了三地址代码、LLVM IR等一些具体的IR设计希望能给你增加一些直观印象。在有的教科书里还会有三元式、四元式、逆波兰格式等不同的设计你也可以参考。而在后面的课程里你会接触到每门编译器的IR从而对IR的理解更加具体和丰满。
本讲的思维导图如下:
一课一思
你能试着把下面这段简单的程序改写成TAC和SSA格式吗
int bar(a){
int sum = 0;
for (int i = 0; i< a; i++){
sum = sum+i;
}
return sum;
}
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。
参考资料
关于程序依赖图的论文参考The Program Dependence Graph and its Use in Optimization。
更多的关于LLVM IR的介绍你可以参考《编译原理之美》的第25、26讲以及LLVM官方文档。
对Java字节码的介绍你可以参考《编译原理之美》的第32讲还可以参考Java Language Specification。
鲸书Advanced Compiler Design and Implementation第4章。

View File

@ -0,0 +1,380 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 代码优化:跟编译器做朋友,让你的代码飞起来
你好,我是宫文学。
一门语言的性能高低是它能否成功的关键。拿JavaScript来说十多年来它的性能多次得到成倍的提升这也是前端技术栈如此丰富和强大的根本原因。
因此,编译器会无所不用其极地做优化,而优化工作在编译器的运行时间中,也占据了很大的比例。
不过,对编译技术的初学者来说,通常会搞不清楚编译器到底做了哪些优化,这些优化的实现思路又是怎样的。
所以今天这一讲我就重点给你普及下编译器所做的优化工作及其工作原理。在这个过程中你还会弄明白很多似曾相识的术语比如在前端必须了解的AST、终结符、非终结符等在中后端必须熟悉的常数折叠、值编号、公共子表达式消除等。只有这样你才算是入门了。
首先,我带你认识一些常见的代码优化方法。
常见的代码优化方法
对代码做优化的方法有很多。如果要把它们分一下类的话,可以按照下面两个维度:
第一个分类维度是机器无关的优化与机器相关的优化。机器无关的优化与硬件特征无关比如把常数值在编译期计算出来常数折叠。而机器相关的优化则需要利用某硬件特有的特征比如SIMD指令可以在一条指令里完成多个数据的计算。
第二个分类维度,是优化的范围。本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。
但优化算法很多,仅仅按照这两个维度分类,仍显粗糙。所以,我就按照优化的实现思路再分分类,让你了解起来更轻松一些。
思路1把常量提前计算出来
程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来。比如 “x=2*3”可以优化成“x=6”。这种优化方法叫做常数折叠Constant Folding
而如果你一旦知道x的值其实是一个常量那你就可以把所有用到x的地方替换成这个常量这叫做常数传播Constant Propagation。如果有“y=x*2”这样一个语句那么就能计算出来“y=12”。所以说常数传播会导致更多的常数折叠。
就算不能引起新的常数折叠比如说“z=a+x”替换成“z=a+6”以后计算速度也会更快。因为对于很多CPU来说“a+x”和“a+6”对应的指令是不一样的。前者可能要生成两条指令比如先把a放到寄存器上再把x加上去而后者用一条指令就行了因为常数可以作为操作数。
更有用的是常数传播可能导致分支判断条件是常量因此导致一个分支的代码不需要被执行。这种优化叫做稀疏有条件的常数传播Sparse Conditional Constant Propagation
a = 2
b = 3
if(a<b){ //判断语句去掉
... //直接执行这个代码块
}
else{
... //else分支会去掉
}
思路2用低代价的方法做计算
完成相同的计算可以用代价更低的方法。比如“x=x+0”这行代码操作前后x没有任何变化所以这样的代码可以删掉又比如“x=x*0” 可以简化成“x=0”。这类利用代数运算的规则所做的简化叫做代数简化Algebra Simplification
对于很多CPU来说乘法运算改成移位运算速度会更快。比如“x*2”等价于“x<<1x*9等价于x<<3+x这种采用代价更低的运算的方法也叫做强度折减Strength Reduction
思路3消除重复的计算
下面的示例代码中第三行可以被替换成“z:=2*x” 因为y的值就等于x。这个时候可能x的值已经在寄存器中所以直接采用x运算速度会更快。这种优化叫做拷贝传播Copy Propagation
x := a + b
y := x
z := 2 * y
值编号Value Numbering也能减少重复计算。值编号是把相同的值在系统里给一个相同的编号并且只计算一次即可。比如Wikipedia上的这个案例
w := 3
x := 3
y := x + 4
z := w + 4
其中w和x的值是一样的因此编号是相同的。这会进一步导致y和z的编号也是相同的。进而它们可以简化成
w := 3
x := w
y := w + 4
z := y
值编号又可以分为两种本地值编号在一个基本块中和全局值编号GVN在一个函数范围内
还有一种优化方法叫做公共子表达式消除Common Subexpression EliminationCSE也会减少计算次数。下面这两行代码x和y右边的形式是一样的如果这两行代码之间a和b的值没有发生变化比如采用SSA形式那么x和y的值一定是一样的。
x := a + b
y := a + b
那我们就可以让y等于x从而减少了一次对“a+b”的计算这就是公共子表达式消除。
x := a + b
y := x
部分冗余消除Partial Redundancy EliminationPRE是公共子表达式消除的一种特殊情况。比如这个来自Wikipedia的例子中一个分支有“x+4”这个公共子表达式而另一个分支则没有。
if (some_condition) {
// some code that does not alter x
y = x + 4;
}
else {
// other code that does not alter x
}
z = x + 4;
但是上述代码仍然可以优化使得在if结构中“x+4”这个值肯定会被计算一次因此“z=x+4”就可以被优化。
if (some_condition) {
// some code that does not alter x
t = x + 4;
y = t;
}
else {
// other code that does not alter x
t = x + 4;
}
z = t;
思路4化零为整向量计算
很多CPU支持向量运算也就是SIMDSingle Instruction Multiple Data指令。这就可以在一条指令里计算多个数据。比如AVX-512指令集可以使用512位的寄存器做运算这个指令集的一条add指令相当于一次能把16个整数加到另16个整数上以1当16呀。
比如把16万个整数相加应该怎样写程序呢普通方法是循环16万次每次读1个数据并做累加。向量化的方法是每次读取16个用AVX-512指令做加法计算一共循环计算1万次最后再把得到的16个数字相加就行了。
向量优化的一个例子是超字级并行Superword-Level ParallelismSLP)。它是把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。
向量优化的另一个例子是循环向量化Loop Vectorization我会在下面针对循环的优化思路中讲到它。
思路5化整为零各个优化
另一个思路是反着的,是化整为零。
很多语言都有结构和对象这样的复合数据类型内部包含了多个成员变量这种数据类型叫做聚合体aggregates。通常为这些对象申请内存的时候是一次就申请一整块能放下里面的所有成员。但这样做非常不利于做优化。
通常的优化算法都是针对标量Scalar的。如果经过分析发现可以把聚合体打散像使用单个本地变量也就是标量一样使用聚合体的成员变量那就有可能带来其他优化的机会。比如可以把聚合体的成员变量放在寄存器中进行计算根本不需要访问内存。
这种优化叫做聚合体的标量替换Scalar Replacement of AggregatesSROA。在研究Java的JIT编译器时我们会见到一个这类优化的例子。
思路6针对循环重点优化
在编译器中对循环的优化从来都是重点因为程序中最多的计算量都是被各种循环消耗掉的。所以对循环做优化会起到事半功倍的效果。如果一个循环执行了10000次那么你的优化效果就会被扩大10000倍。
对循环做优化,有很多种方法,我来和你介绍几种常用的。
第一种归纳变量优化Induction Variable Optimization
看下面这个循环其中的变量j是由循环变量派生出来的这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的因此可以尝试做强度折减优化。示例代码中的乘法可以由加法替代。
int j = 0;
for (int i = 1; i < 100; i++) {
j = 2*i; //2*i可以替换成j+2
}
return j;
第二种边界检查消除Unnecessary Bounds-checking Elimination
当引用一个数组成员的时候,通常要检查下标是否越界。在循环里面,如果每次都要检查的话,代价就会相当高(例如做多个数组的向量运算的时候)。如果编译器能够确定,在循环中使用的数组下标(通常是循环变量或者基于循环变量的归纳变量)不会越界,那就可以消除掉边界检查的代码,从而大大提高性能。
第三种循环展开Loop Unrolling
把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。比如:
for (int i = 0; i< 100; i++){
sum = sum + i;
}
优化后可以变成:
for (int i = 0; i< 100; i+=5){
sum = sum + i;
sum = sum + i + 1;
sum = sum + i + 2;
sum = sum + i + 3;
sum = sum + i + 4;
}
进一步循环体内的5条语句就可以优化成1条语句“sum = sum + i*5 + 10;”。
减少循环次数,本身就能减少循环条件的执行次数。同时,它还会增加一个基本块中的指令数量,从而为指令排序的优化算法创造机会。指令排序会在下一讲中介绍。
第四种循环向量化Loop Vectorization
在循环展开的基础上我们有机会把多次计算优化成一个向量计算。比如如果要循环16万次对一个包含了16万个整数的数组做汇总就可以变成循环1万次每次用向量化的指令计算16个整数。
第五种重组Reassociation
在循环结构中使用代数简化和重组能获得更大的收益。比如如下对数组的循环操作其中数组a[i,j]的地址是“a+i*N+j”。但这个运算每次循环就要计算一次一共要计算M*N次。但其实这个地址表达式的前半截“a+i*N”不需要每次都在内循环里计算只要在外循环计算就行了。
for (i = 0; i< M; i++){
for (j = 0; j<N; j++){
a[i,j] = b + a[i,j];
}
}
优化后的代码相当于:
for (i = 0; i< M; i++){
t=a+i*N;
for (j = 0; j<N; j++){
*(t+j) = b + *(t+j);
}
}
第六种循环不变代码外提Loop-Invariant Code MotionLICM
在循环结构中,如果发现有些代码其实跟循环无关,那就应该提到循环外面去,避免一次次重复计算。
第七种代码提升Code Hoisting或Expression Hoisting
在下面的if结构中then块和else块都有“z=x+y”这个语句它可以提到if语句的外面。
if (x > y)
...
z = x + y
...
}
else{
z = x + y
...
}
这样变换以后至少代码量会降低。但是如果这个if结构是在循环里面那么可以继续借助循环不变代码外提优化把“z=x+y”从循环体中提出来从而降低计算量。
z = x + y
for(int i = 0; i < 10000; i++){
if (x > y)
...
}
else{
...
}
}
另外,前面说过的部分冗余优化,也可能会产生可以外提的代码,借助这一优化方法,可以形成进一步优化的效果。
针对循环能做的优化还有不少,因为对循环做优化往往是收益很高的!
思路7减少过程调用的开销
你知道,当程序调用一个函数的时候,开销是很大的,比如保存原来的栈指针、保存某些寄存器的值、保存返回地址、设置参数,等等。其中很多都是内存读写操作,速度比较慢。
所以,如果能做一些优化,减少这些开销,那么带来的优化效果会是很显著的,具体的优化方法主要有下面几种。
第一种尾调用优化Tail-call Optimization和尾递归优化Tail-recursion Elimination
尾调用就是一个函数的最后一句,是对另一个函数的调用。比如,下面这段示例代码:
f(){
...
return g(a,b);
}
而如果g()本身就是f()的最后一行代码那么f()的栈帧已经没有什么用了可以撤销掉了修改栈顶指针的值然后直接跳转到g()的代码去执行就像f()和g()是同一个函数一样。这样可以让g()复用f()的栈空间,减少内存消耗,也减少一些内存读写操作(比如,保护寄存器、写入返回地址等)。
如果f()和g()是同一个函数这就叫做尾递归。很多同学都应该知道尾递归是可以转化为一个循环的。我们在第3讲改写左递归文法为右递归文法的时候就曾经用循环代替了递归调用。尾递归转化为循环不但可以节省栈帧的开销还可以进一步导致针对循环的各种优化。
第二种内联inlining
内联也叫做过程集成Procedure Integration就是把被调用函数的代码拷贝到调用者中从而避免函数调用。
对于我们现在使用的面向对象的语言来说有很多短方法比如getter、settter方法。这些方法内联以后不仅仅可以减少函数调用的开销还可以带来其他的优化机会。在探究Java的JIT编译器时我就会为你剖析一个内联的例子。
第三种内联扩展In-Line Expansion
内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
第四种叶子程序优化Leaf-Routine Optimization
叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。比如,你甚至可以不用生成栈帧,因为根据某些调用约定,程序可以访问栈顶之外一定大小的内存。这样就省去了保存原来栈顶、修改栈顶指针等一系列操作。
思路8对控制流做优化
通过对程序的控制流分析,我们可以发现很多优化的机会。这就好比在做公司管理,优化业务流程,就会提升经营效率。我们来看一下这方面的优化方法有哪些。
第一种不可达代码消除Unreacheable-code Elimination。根据控制流的分析发现有些代码是不可能到达的可以直接删掉比如return语句后面的代码。
第二种死代码删除Dead-code Elimination。通过对流程的分析发现某个变量赋值了以后后面根本没有再用到这个变量。这样的代码就是死代码就可以删除。
第三种If简化If Simplification)。在讲常量传播时我们就见到过如果有可能if条件肯定为真或者假那么就可以消除掉if结构中的then块、else块甚至整个消除if结构。
第四种循环简化Loop Simplification。也就是把空循环或者简单的循环变成直线代码从而增加了其他优化的机会比如指令的流水线化。
第五种循环反转Loop Inversion。这是对循环语句常做的一种优化就是把一个while循环改成一个repeat…until循环或者do…while循环。这样会使基本块的结构更简化从而更有利于其他优化。
第六种拉直Straightening。如果发现两个基本块是线性连接的那可以把它们合并从而增加优化机会。
第七种反分支Unswitching。也就是减少程序分支因为分支会导致程序从一个基本块跳到另一个基本块这样就不容易做优化。比如把循环内部的if分支挪到循环外面去先做if判断然后再执行循环这样总的执行if判断的次数就会减少并且循环体里面的基本块不那么零碎就更加容易优化。
这七种优化方法,都是对控制流的优化,有的减少了基本块,有的减少了分支,有的直接删除了无用的代码。
代码优化所依赖的分析方法
前面我列举了很多优化方法,目的是让你认识到编译器花费大量时间去做的,到底都是一些什么工作。当然了,我只是和你列举了最常用的一些优化方法,不过这已经足够帮助你建立对代码优化的直觉认知了。我们在研究具体的编译器的时候,还会见到其他一些优化方法。不过你不用担心,根据上面讲到的各种优化思路,你可以举一反三,非常快速地理解这些新的优化方法。
上述优化方法有的比较简单比如常数折叠依据AST或MIR做点处理就可以完成。但有些优化就需要比较复杂的分析方法做支撑才能完成。这些分析方法包括控制流分析、数据流分析、依赖分析和别名分析等。
控制流分析Control-Flow AnalysisCFA。控制流分析是帮助我们建立对程序执行过程的理解比如哪里是程序入口哪里是出口哪些语句构成了一个基本块基本块之间跳转关系哪个结构是一个循环结构从而去做循环优化等等。
前面提到的控制流优化就是要基于对控制流的正确理解。下面要讲的数据流分析算法在做全局分析的时候也要基于控制流图CFG所以也需要以控制流分析为基础。
数据流分析Data-Flow AnalysisDFA。数据流分析能够帮助我们理解程序中的数据变化情况。我们看一个分析变量活跃性的例子。
如下图所示,它从后到前顺序扫描代码,花括号中的是在当前位置需要的变量的集合。如果某个变量不被需要,那就可以做死代码删除的优化。
经过多遍扫描和删除后,最后的代码会精简成一行:
关于数据流分析框架的详细描述你可以再参考下其他资料比如《编译原理之美》专栏第27和28两讲
除了做变量活跃性分析以外数据流分析方法还可以做很多有用的分析。比如可达定义分析Reaching Definitions Analysis、可用表达式分析Available Expressions Analysis、向上暴露使用分析Upward Exposed Uses Analysis、拷贝传播分析Copy-Propagation Analysis、常量传播分析Constant-Propagation Analysis、局部冗余分析Partial-Redundancy Analysis等。
就像基于变量活跃性分析可以做死代码删除的优化一样,上述分析是做其他很多优化的基础。
依赖分析Dependency Analysis。依赖分析就是分析出程序代码的控制依赖Control Dependency和数据依赖Data Dependency关系。这对指令排序和缓存优化很重要。
指令排序会在下一讲介绍。它能通过调整指令之间的顺序来提升执行效率。但指令排序不能打破指令间的依赖关系,否则程序的执行就不正确。
别名分析Alias Analysis。在C、C++等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
好了,你已经了解了优化的方法和所依赖的分析方法。那么,这些方法这么多,哪些优化方法更重要,优化的顺序又是什么呢?
优化方法的重要性和顺序
我们先看看哪些优化方法更重要。
有些优化,比如对循环的优化,对每门语言都很重要,因为循环优化的收益很大。
而有些优化对于特定的语言更加重要。在课程后面分析像Java、JavaScript这样的面向对象的现代语言时你会看到内联优化和逃逸分析的收益就比较大。而对于某些频繁使用尾递归的函数式编程语言来说尾递归的优化就必不可少否则性能损失太大。
至于优化的顺序有的优化适合在早期做基于HIR和MIR有的优化适合在后期做基于LIR和机器代码。并且你通过前面的例子也可以看到一般做完某个优化以后会给别的优化带来机会所以经常会在执行某个优化算法的时候调用了另一个优化算法而同样的优化算法也可能会运行好几遍。
课程小结
今天这讲,我带你认识了很多常见的优化方法和背后的分析方法。我们很难一下子记住所有的方法,但完全可以先对这些概念建立总体印象。这样可以避免在研究具体编译器时,我们产生“瞎子摸象”的感觉。
另外,熟悉我提到的那些名词术语也很重要,因为它们经常在代码注释和相关文献里出现。这些名词要成为你的一项基本功。
我把今天的课程内容,也整理成了思维导图,供你复习、参考。
在课程的第二个模块“真实编译器解析篇”的时候,我会和你分析某些优化算法具体的实现细节,并带你跟踪编译优化的过程。
根据我的经验当你写的程序对性能要求很高的时候你需要能够跟踪了解编译优化的过程看看如何才能达到最好的优化效果。我之前写过与内存计算有关的程序就特别关注如何才能让编译器做向量优化。因为是否使用向量性能差别很大。现在做AI工作的同学一定也有类似的需求。
还有些开源项目,它们的性能与内联关系密切。这就要做一定的调优,以确保使用频率最高、性能影响最大的函数全部内联。
还有Chrome、Android和Flutter共同使用的二维图形引擎Skia对性能很敏感所以即使在Windows平台上仍然要求用Clang编译。为啥坚持用Clang编译呢因为Skia跟LLVM的优化方法是紧密配合的换了其他编译器就达不到这么好的优化效果。
类似的例子还有很多。了解优化,能够充分利用编译器的优化能力,应该是我们想拥有的一项高级技能。
一课一思
你可以比较一下值编号和公共子表达式消除这两个优化方法,说说它们的相同点和不同点吗?你能举出一个例子来,是其中一个算法能做优化,而另一个算法不能的吗?
欢迎在留言区中分享你的思考,也欢迎你把这节课分享给你的朋友。
参考资料
龙书Compilers Principles, Techniques and Tools第9章机器无关的优化里面介绍了各种优化算法。
鲸书Advanced Compiler Design and Implementation中讲优化的算法有很多第7~15章你都可以看看。

View File

@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 代码生成:如何实现机器相关的优化?
你好,我是宫文学。我们继续来学习编译器后端的技术。
在编译过程的前几个阶段之后编译器生成了AST完成了语义检查并基于IR运行了各种优化算法。这些工作基本上都是机器无关的。但编译的最后一步也就是生成目标代码则必须是跟特定CPU架构相关的。
这就是编译器的后端。不过,后端不只是简单地生成目标代码,它还要完成与机器相关的一些优化工作,确保生成的目标代码的性能最高。
这一讲,我就从机器相关的优化入手,带你看看编译器是如何通过指令选择、寄存器分配、指令排序和基于机器代码的优化等步骤,完成整个代码生成的任务的。
首先,我们来看看编译器后端的任务:生成针对不同架构的目标代码。
生成针对不同CPU的目标代码
我们已经知道编译器的后端要把IR翻译成目标代码那么要生成的目标代码是什么样子的呢
我以foo.c函数为例
int foo(int a, int b){
return a + b + 10;
}
执行“clang -S foo.c -o foo.x86.s”命令你可以得到对应的x86架构下的汇编代码为了便于你理解我进行了简化
#序曲
pushq %rbp
movq %rsp, %rbp #%rbp是栈底指针
#函数体
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置偏移量为4
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置偏移量为8
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
addl -8(%rbp), %eax #把第2个参数加到%eax
addl $10, %eax #把立即数10加到%eax%eax同时是放返回值的地方
#尾声
popq %rbp
retq
小提示上述汇编代码采用的是GNU汇编器的代码格式源操作数在前面目的操作数在后面。
我在第1讲中说过要翻译成目标代码编译器必须要先懂得目标代码就像做汉译英一样我们必须要懂得英语。可是通常情况下我们会对汇编代码比较畏惧觉得汇编语言似乎很难学。其实不然。
补充说明:有些编译器,是先编译成汇编代码,再通过汇编器把汇编代码转变成机器码。而另一些编译器,是直接生成机器码,并写成目标文件,这样编译速度会更快一些。但这样的编译器一般就要带一个反汇编器,在调试等场合把机器码转化成汇编代码,这样我们看起来比较方便。-
因此,在本课程中,我有时会不区分机器码和汇编代码。我可能会说,编译器生成了某机器码,但实际写给你看的是汇编代码,因为文本化的汇编代码才方便阅读。你如果看到这样的表述,不要感到困惑。
那为什么我说汇编代码不难学呢你可以去查阅下各种不同CPU的指令。然后你就会发现这些指令其实主要就那么几种一类是做加减乘除的如add指令一类是做内存访问的如mov、lea指令一类是控制流程的如jmp、ret指令等等。说得夸张一点这就是个复杂的计算器。
只不过,相比于高级语言,汇编语言涉及的细节比较多。它是啰嗦,但并不复杂。那我再分享一个我学习汇编代码的高效方法:让编译器输出高级语言的汇编代码,多看一些各种情况下汇编代码的写法,自然就会对汇编语言越来越熟悉了。
不过虽然针对某一种CPU的汇编并不难但问题是不同架构的CPU其指令是不同的。编译器的后端每支持一种新的架构就要有一套新的代码。这对写一个编译器来说就是很大的工作量了。
我来举个例子。我们使用“clang -S -target armv7a-none-eabi foo.c -o foo.armv7a.s”命令生成一段针对ARM芯片的汇编代码
//序曲
sub sp, sp, #8 //把栈扩展8个字节用于放两个参数sp是栈顶指针
//函数体
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
str r1, [sp] //把第2个参数写到栈顶位置
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
add r0, r0, r1 //把r1加到r0,结果保存在r0
add r0, r0, #10 //把常量10加载到r0结果保存在r0,r0也是放返回值的地方
//尾声
add sp, sp, #8 //缩减栈
bx lr //返回
把这段代码与前面生成的针对x86架构的汇编代码比较一下你马上就会发现一些不同。这两种CPU完成相同功能所使用的汇编指令和寄存器都不同。我们来分析一下其中的原因。
x86的汇编mov指令的功能很强大可以从内存加载到寄存器也可以从寄存器保存回内存还可以从内存的一个地方拷贝到另一个地方、从一个寄存器拷贝到另一个寄存器。add指令的操作数也可以使用内存地址。
而在ARM的汇编中从寄存器到内存要使用str也就是Store指令而从内存到寄存器要使用ldr也就是Load指令。对于加法指令add而言两个操作数及计算结果都必须使用寄存器。
知识扩展ARM的这种指令风格叫做Load-Store架构。在这种架构下指令被分为内存访问Load和Store和ALU操作两大类而后者只能在寄存器上操作。各种RISC指令集都是Load-Store架构的比如PowerPC、RISC-V、ARM和MIPS等。-
而像x86这种CISC指令叫做Register-Memory架构在指令里可以混合使用内存地址和寄存器。
为了支持不同的架构,你可以通过手写算法来生成目标代码,但这样工作量显然会很大,维护负担也比较重。
另一种方法是编写“代码生成器的生成器”。也就是说你可以把CPU架构的各种信息比如有哪些指令、指令的特点、有哪些寄存器等描述出来然后基于这些信息生成目标代码的生成器就像根据语法规则用ANTLR、bison这样的工具来生成语法解析器一样。
经过这样的处理,虽然我们生成的目标代码是架构相关的,但中间的处理算法却可以尽量做成与架构无关的。
生成目标代码时的优化工作
生成目标代码的过程要进行多步处理。比如你一定注意到了前面foo.c函数示例程序生成的汇编代码是不够优化的它把参数信息从寄存器写到栈里然后再从栈里加载到寄存器用于计算。实际上改成更优化的算法是不需要内存访问的从而节省了内存读写需要花费的大量时间。
所以接下来,我就带你一起了解在目标代码生成过程中进行的优化处理,包括指令选择、寄存器分配、指令排序、基于机器代码的优化等步骤。在这个过程中,你会知道编译器的后端,是如何充分发挥硬件的性能的。
首先,我们看看指令选择,它的作用是在完成相同功能的情况下,选择代价更低的指令组合。
指令选择
为了理解指令选择有什么用,这里我和你分享三个例子吧。
第一个例子对于foo.c示例代码在编译时加上“-O2”指令就会得到如下的优化代码
#序曲
pushq %rbp
movq %rsp, %rbp
#函数体
leal 10(%rdi,%rsi), %eax
#尾声
popq %rbp
retq
它使用了lea指令可以一次完成三个数的相加并把结果保存到%eax。这样一个lea指令代替了三条指令一条mov两条add显然更优化。
这揭示了我们生成代码时面临的一种情况对于相同的源代码和IR编译器可以生成不同的指令而我们要选择代价最低的那个。
第二个例子对于“a[i]=b”这样一条语句要如何生成代码呢
你应该知道数组寻址的原理a[i]的地址就是从数组a的起始地址往后偏移i个单位。对于整型数组来说a[i]的地址就是a+i*4。所以我可以用两条指令实现这个赋值操作第一条指令计算a[i]的地址第二条指令把b的值写到这个地址。
数组操作是很常见的现象于是x86芯片专门提供了一种寻址方式简化了数组的寻址这就是间接内存访问。间接内存访问的完整形式是偏移量基址索引值字节数其地址是基址 + 索引值*字节数 + 偏移量。
所以如果我们把a的地址放到%rdii的值放到%rax那么a[i]的地址就是(%rdi,%rax,4)。这样的话a[i]=b用一条mov指令就能完成。
第三个例子。我们天天在用的x86家族的芯片它支持很多不同的指令集比如SSE、AVX、FMA等每个指令集里都有能完成加减乘除运算的指令。当然每个指令集适合使用的场景也不同我们要根据情况选择最合适的指令。
好了现在你已经知道了指令选择的作用了它在具体实现上有很多算法比如树覆盖算法以及BURS自底向上的重写系统等。
我们再看一下刚刚这段优化后的代码,你是不是发现了,优化后的算法对寄存器的使用也更加优化了。没错,接下来我们就分析下寄存器分配。
寄存器分配
优化后的代码去掉了内存操作直接基于寄存器做加法运算比优化之前的运行速度要快得多我在第5讲提到过内存访问比寄存器访问大约慢100倍
同样的ARM的汇编代码也可以使用“-O2”指令优化。优化完毕以后最后剩下的代码只有三行。而且因为不需要访问内存所以连栈顶指针都不需要挪动进一步减少了代码量。
add r0, r0, r1
add r0, r0, #10
bx lr
对于编译器来说肯定要尽量利用寄存器不去读写内存。因为内存读写对于CPU来说就是IO性能很低。特别是像函数中用到的本地变量和参数它们在退出作用域以后就没用了所以能放到寄存器里就放寄存器里吧。
在IR中通常我们会假设寄存器是无限的就像LLVM的IR但实际CPU中的寄存器是有限的。所以我们就要用一定的算法把寄存器分配给使用最频繁的变量比如循环中的变量。而对于超出物理寄存器数量的变量则“溢出”到栈里通过内存来保存。
寄存器分配的算法有很多种。一个使用比较广泛的算法是寄存器染色算法,它的特点是计算结果比较优化,但缺点是计算量比较大。
另一个常见的算法是线性扫描算法它的优点是计算速度快但缺点是有可能不够优化适合需要编译速度比较快的场景比如即时编译。在解析Graal编译器的时候你会看到这种算法的实现。
寄存器分配算法对性能的提升是非常显著的。接下来我要介绍的指令排序,对性能的提升同样非常显著。
指令排序
首先我们来看一个例子。下面示例程序中的params函数有6个参数
int params(int x1,int x2,int x3,int x4,int x5,int x6{
return x1 + x2 + x3 + x4 + x5 + x6 + 10;
}
把它编译成ARM汇编代码如下
//序曲
push {r11, lr} //把r11和lr保存到栈中lr里面是返回地址
mov r11, sp //把栈顶地址保存到r11
//函数体
add r0, r0, r1 //把参数2加到参数1保存在r0
ldr lr, [r11, #8] //把栈里的参数5加载到lr这里是把lr当通用寄存器用
add r0, r0, r2 //把参数3加到r0
ldr r12, [r11, #12] //把栈里的参数6加载到r12
add r0, r0, r3 //把参数4加到r0
add r0, r0, lr //把参数5加到r0
add r0, r0, r12 //把参数6加到r0
add r0, r0, #10 //把立即数加到r0
//尾声
pop {r11, pc} //弹出栈里保存的值。注意原来lr的值直接赋给了pc也就是程序计数器所以就跳转到了返回地址
根据编译时使用的调用约定其中有4个参数是通过寄存器传递的r0~r3还有两个参数是在栈里传递的。
值得注意的是在把参数5和参数6用于加法操作之前它们就被提前执行加载ldr命令了。那为什么会这样呢这就涉及到CPU执行指令的一种内部机制流水线Pipeline
原来CPU内部是分成多个功能单元的。对于一条指令每个功能单元处理完毕以后交给下一个功能单元然后它就可以接着再处理下一条指令。所以在同一时刻不同的功能单元实际上是在处理不同的指令。这样的话多条指令实质上是并行执行的从而减少了总的执行时间这种并行叫做指令级并行。
在下面的示意图中每个指令的执行被划分成了5个阶段每个阶段占用一个时钟周期如下图所示
图1多个功能单元并行
因为每个时钟周期都可以开始执行一条新指令所以虽然一条指令需要5个时钟周期才能执行完但在同一个时刻却可以有5条指令并行执行。
但是有的时候指令之间会存在依赖关系后一条指令必须等到前一条指令执行完毕才能运行在上一讲我们曾经提到过依赖分析指令排序就会用到依赖分析的结果。比如前面的示例程序中在使用参数5的值做加法之前必须要等它加载到内存。这样的话指令就不能并行了执行时间就会大大延长。
图2缺少充分的并行会导致总执行时间变长
讲到这里你就明白了为什么在示例程序中要把ldr指令提前执行目的就是为了更好地利用流水线技术实现指令级并行。
补充这里我把执行阶段分为5段只是给你举个例子。很多实际的CPU架构划分了更多的阶段。比如某类型的奔腾芯片支持21段那理论上也就意味着可以有21条指令并行执行但它的前提是必须做好指令排序的优化。-
另外现代一些CISC的CPU在硬件层面支持乱序执行Out-of-Order。一批指令给到CPU后它也会在内部打乱顺序去优化执行。而RISC芯片一般不支持乱序执行所以像ARM这样的芯片做指令排序就更加重要。
另外在上一讲我提到过对循环做优化的一种技术叫做循环展开Loop Unroll它会把循环体中的代码重复多次与之对应的是减少循环次数。这样一个基本块中就会有更多条指令增加了通过指令排序做优化的机会。
指令排序的算法也有很多种比如基于数据依赖图的List Scheduling算法。在后面的课程中我会带你考察一下真实世界中的编译器都使用了什么算法。
OK了解完指令排序以后还有什么优化可以做呢
窥孔优化Peephole Optimization
基于LIR或目标代码代码还有被进一步优化的可能性。这就是代码优化的特点。比如你在前面做了常数折叠以后后面的处理步骤修改了代码或生成新的代码以后可能还会产生出新的常数折叠的机会。另外有些优化也只有在目标代码的基础上才方便做。
给你举个例子吧:假设相邻两条指令,一条指令从寄存器保存数据到栈里,下一条指令又从栈里原封不动地把数据加载到原来的寄存器,那么这条加载指令就是冗余的,可以去掉。
str r0, [sp, #4] //把r0的值保存到栈顶+4的位置
ldr r0, [sp, #4] //把栈顶+4位置的值加载到r0寄存器
基于目标代码的优化最常用的方法是窥孔优化Peephole Optimization。窥孔优化的思路是提供一个固定大小的窗口比如能够容纳20条指令并检查窗口内的指令看看是否可以优化。然后再往下滑动窗口再次检查优化机会。
最后,还有一个因素会影响目标代码的生成,就是调用约定。
调用约定的影响
还记得前面示例的x86的汇编代码吗其中的%edi寄存器用来传递第一个参数%esi寄存器用来传递第二个参数这就是遵守了一种广泛用于Unix和Linux系统的调用约定“System V AMD64 ABI”。这个调用约定规定对于整型参数前6个参数可以用寄存器传递6个之后的参数就要基于栈来传递。
#序曲
pushq %rbp
movq %rsp, %rbp #%rbp是栈底指针
#函数体
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置偏移量为4
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置偏移量为8
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
addl -8(%rbp), %eax #把第2个参数加到%eax
addl $10, %eax #把立即数10加到%eax%eax同时是放返回值的地方。
#尾声
popq %rbp
retq
知识扩展ABI是Application Binary Interface的缩写也就是应用程序的二进制接口。通常ABI里面除了规定调用约定外还要包括二进制文件的格式、进程初始化的方式等更多内容。
而在看ARM的汇编代码时我们会发现它超过了4个参数就要通过栈来传递。实际上它遵循的是一种不同ABI叫做EABI嵌入式应用程序二进制接口。在调用Clang做编译的时候-target参数“armv7a-none-eabi”的最后一部分就是指定了EABI。
//序曲
sub sp, sp, #8 //把栈扩展8个字节用于放两个参数sp是栈顶指针
//函数体
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
str r1, [sp] //把第2个参数写到栈顶位置
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
add r0, r0, r1 //把r1加到r0,结果保存在r0
add r0, r0, #10 //把常量10加载到r0结果保存在r0,r0也是放返回值的地方
//尾声
add sp, sp, #8 //缩减栈
bx lr //返回
在实现编译器的时候你可以发明自己的调用约定比如哪些寄存器用来放参数、哪些用来放返回值等等。但是如果你要使用别的语言编译好的目标文件或者你想让自己的编译器生成的目标文件被别人使用那你就要遵守某种特定的ABI标准。
后端处理的整体过程
好了,到这里,我已经介绍完了生成目标代码过程中所做的各种优化处理。那么,我们怎么把它们串成一个整体呢?
图3典型的后端处理过程
在实际实现时,我们通常是先做指令选择,然后做一次指令排序。在分配完寄存器以后,还要再做一次指令排序,因为寄存器分配算法会产生新的指令排序优化的机会。比如,一些变量会溢出到栈里,从而增加了一些内存访问指令。
这个处理过程其实也是IR不断lower的过程。一开始是MIR在做了指令选择以后就变成了具体架构相关的LIR了。在没做寄存器分配之前我们在LIR中用到寄存器还是虚拟的数量是无限的做完分配以后就变成具体的物理寄存器的名称了。
与机器相关的优化如窥孔优化也会穿插在整个过程中。最后一个步骤是通过一个Emit目标代码的程序生成目标代码。因为IR已经被lower得很接近目标代码了所以这个翻译程序是比较简单的。
课程小结
今天这一讲,我带你认识了编译器在后端的主要工作,也就是生成目标代码时,所需要的各种优化和处理。你需要注意理解每一步处理的原理,比如到底为什么需要做指令选择,形成直观认识。
这一讲,我没有带你去深入算法的细节,而是希望先带你建立一个整体的认知。在我们考察真实的编译器时,你要注意研究它们的后端是如何实现的。
我把今天的课程内容,也整理成了思维导图,供你参考。
一课一思
用Clang或gcc来生成汇编代码对研究生成目标代码时的优化效果非常有帮助。你可以设计一个C语言的简单函数测试出编译器在指令选择、寄存器分配或指令排序的任意方面的优化效果吗
你可以比较下,带和不带“-O2”参数生成的汇编代码有什么不同。你还可以查看手册使用更多的选项比如对于x86架构你可以控制是否使用AVX指令集。这个练习会帮助你获得更多的直观理解。
在留言区,把你动手实验的成果分享出来吧,我们一起交流讨论。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
参考链接
关于汇编代码、寄存器、调用约定等内容更详细的介绍你可以参考《编译原理之美》的第23、24讲。
关于指令选择的算法你可以参考《编译原理之美》的第29讲我介绍了一个树覆盖算法。
关于寄存器分配的算法你也可以参考《编译原理之美》的第29讲我介绍了一个寄存器染色算法。
关于指令排序的算法你可以参考《编译原理之美》的第30讲深入去看一下基于数据依赖图的List Scheduling算法。

View File

@ -0,0 +1,339 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 Java编译器手写的编译器有什么优势
你好,我是宫文学。
从今天开始呢,我会带着你去考察实际编译器的具体实现机制,你可以从中学习和印证编译原理的基础知识,进而加深你对编译原理的理解。
我们探险的第一站是很多同学都很熟悉的Java语言我们一起来看看它的编译器里都有什么奥秘。我从97年就开始用它算是比较早了。当时我就对它的“一次编译到处运行”留下了很深的印象我在Windows下写的程序编译完毕以后放到Solaris上就能跑。现在看起来这可能不算什么但在当年我在Windows和Unix下写程序用的工具可是完全不同的。
到现在Java已经是一门非常成熟的语言了而且它也在不断进化与时俱进泛型、函数式编程、模块化等特性陆续都增加了进来。在服务端编程领域它也变得非常普及。
与此同时Java的编译器和虚拟机中所采用的技术也比20年前发生了天翻地覆的变化。对于这么一门成熟的、广泛普及的、又不断焕发新生机的语言来说研究它的编译技术会带来两个好处一方面Java编译器所采用的技术肯定是比较成熟的、靠谱的你在实现自己的编译功能时完全可以去参考和借鉴另一方面你可以借此深入了解Java的编译过程借此去实现一些高级的功能比方说按需生成字节码就像Spring这类工具一样。
因此我会花4讲的时间跟你一起探索Java的前端编译器javac。然后再花4讲的时间在Java的JIT编译器上。
那么针对Java编译器你可能会提出下面的问题
Java的编译器是用什么语言编写的
Java的词法分析器和语法分析器是工具生成的还是手工编写的为什么会这样选择
语法分析的算法分为自顶向下和自底向上的。那么Java的选择是什么呢有什么道理吗
如何自己动手修改Java编译器
这些问题,在今天的旅程结束后,你都会获得解答。并且,你还会获得一些额外的启发:噢,原来这个功能是可以这样做的呀!这是对你探险精神的奖励。
好吧,让我们开始吧。
第一步我们先初步了解一下Java的编译器。
初步了解Java的编译器
大多数Java工程师是通过javac命令来初次接触Java编译器的。假设你写了一个MyClass类
public class MyClass {
public int a = 2+3;
public int foo(){
int b = a + 10;
return b;
}
}
你可以用javac命令把MyClass.java文件编译成字节码文件
javac MyClass.java
那这个javac的可执行文件就是Java的编译器吗并不是。javac只是启动了一个Java虚拟机执行了一个Java程序跟我们平常用“java”命令运行一个程序是一样的。换句话说Java编译器本身也是用Java写的。
这就很有趣了。我们知道,计算机语言是用来编写软件的,而编译器也是一种软件。所以,一门语言的编译器,竟然可以用自己来实现。这种现象,叫做“自举”(Bootstrapping),这就好像一个人抓着自己的头发,要把自己提起来一样,多么神奇!实际上,一门语言的编译器,一开始肯定是要用其他语言来实现的。但等它成熟了以后,就会尝试实现自举。
既然Java编译器是用Java实现的那意味着你自己也可以写一个程序来调用Java的编译器。比如运行下面的示例代码也同样可以编译MyClass.java文件生成MyClass.class文件
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
public class CompileMyClass {
public static void main(String[] args) {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int result = compiler.run(null, null, null, "MyClass.java");
System.out.println("Compile result code = " + result);
}
}
其中javax.tools.JavaCompiler就是Java编译器的入口属于java.compiler模块。这个模块包含了Java语言的模型、注解的处理工具以及Java编译器的API。
javax.tools.JavaCompiler的实现是com.sun.tools.javac.main.JavaCompiler。它在jdk.compiler模块中这个模块里才是Java编译器的具体实现。
不过在探索Java编译器的实现原理之前你还需要从openjdk.java.net下载JDK的源代码我使用的版本是JDK14。在IDE中跟踪JavaCompiler的执行过程你就会看到它一步一步地都是使用了哪个类的哪个方法。Java的IDE工具一般都比较友好给我们的探索提供了很多便利。
不仅如此你还可以根据openjdk的文档从源代码构建出JDK。你还可以修改源代码并构建你自己的版本。
获得了源代码以后我建议你重点关注这几个地方的源代码这能帮助你迅速熟悉Java编译器的源代码结构。
首先是com.sun.source.tree包这个包里面是Java语言的AST模型。我们在写一个编译器的时候肯定要设计一个数据结构来保存AST那你就可以去参考一下Java是怎么做的。接下来我就挑其中几个比较常用的节点给你解释一下
ExpressionTree指的是表达式各种不同的表达式继承了这个接口比如BinaryTree代表了所有的二元表达式
StatementTree代表了语句它的下面又细分了各种不同的语句比如IfTree代表了If语句而BlockTree代表的是一个语句块。
图1com.sun.source.tree包里的Java语言的AST模型
然后是com.sun.tools.javac.parser.Lexer词法解析器接口它可以把字符流变成一个个的Token具体的实现在Scanner和JavaTokenizer类中。
接下来是com.sun.tools.javac.parser.Parser语法解析器接口它能够解析类型、语句和表达式具体的实现在JavacParser类中。
总结起来Java语言中与编译有关的功能放在了两个模块中其中java.compiler模块主要是对外的接口而jdk.compiler中有具体的实现。不过你要注意像com.sun.tools.javac.parser包中的类不是Java语言标准的组成部分如果你直接使用这些类可能导致代码在不同的JDK版本中不兼容。
现在我们已经熟悉了Java编译器的概要信息。在浏览这两个模块的代码时我们会发现里面的内容非常多。为了让自己不会迷失在其中我们需要找到一个方法。你已经知道编译器的前端分为词法分析、语法分析、语义分析等阶段那么我们就可以按照这个阶段一块一块地去探索。
首先我们看看Java的词法分析器。
词法分析器也是构造了一个有限自动机吗?
通过跟踪执行你会发现词法分析器的具体实现在JavaTokenizer类中。你可以先找到这个类在readToken()方法里打个断点,让程序运行到这里,然后查看词法分析的执行过程。
在学词法分析的时候,你肯定知道要构造一个有限自动机,而且当输入的字符发生变化的时候,自动机的状态也会产生变化。
图2一个有限自动机能够区分数字字面量状态1和标识符状态2
那么实战中Java做词法分析的逻辑是什么呢你可以先研究一下readToken()方法这个方法实现了主干的词法分析逻辑它能够从字符流中识别出一个个的Token来。
readToken的逻辑变成伪代码是这样的
循环读取字符
case 空白字符
处理,并继续循环
case 行结束符
处理,并继续循环
case A-Za-z$_
调用scanIden()识别标识符和关键字,并结束循环
case 0之后是X或x或者1-9
调用scanNumber()识别数字,并结束循环
case , ; ( ) [ ]等字符
返回代表这些符号的Token并结束循环
case isSpectial(),也就是% * + - | 等特殊字符
调用scanOperator()识别操作符
...
如果画成有限自动机,大致是这样的:
图3Java词法分析器的有限自动机
在第2讲中我提到过关键字和标识符的规则是冲突的
标识符的规则是以A-Za-z$_开头后续字符可以是A-Za-z$_、数字和其他的合法字符
关键字比如if也符合标识符的规则可以说是标识符的子集。
这种冲突是词法分析的一个技术点因为不到最后你不知道读入的是一个关键字还是一个普通的标识符。如果单纯按照有限自动机的算法去做词法分析想要区分int关键字和其他标识符的话你就会得到图4那样的一个有限自动机。
当输入的字符串是“int”的时候它会进入状态4。如果这个时候遇到结束字符就会提取出int关键字。除此之外“i”状态2、“in”状态3和“intA”状态5都属于标识符。
图4能够处理int和标识符语法的有限自动机
但是关键字有很多if、else、int、long、class…如果按照这个方式构造有限自动机就会很啰嗦。那么java是怎么处理这个问题的呢
Java编译器的处理方式比较简单分成了两步首先把所有的关键字和标识符都作为标识符识别出来然后再从里面把所有预定义的关键字挑出来。这比构造一个复杂的有限自动机实现起来更简单
通过这样的代码分析你可以发现Java的词法解析程序在主干上是遵循有限自动机的算法的但在很多局部的地方为了让词法分析的过程更简单高效采用了手写的算法。
我建议你在IDE中采用调试模式跟踪执行看看每一步的执行结果这样你能对Java词法分析的过程和结果有更直观的理解。另外你还可以写一个程序直接使用词法分析器做解析并打印出一个个Token。这会很有趣你可以试试看
接下来我们进一步研究一下Java的语法分析器。
语法分析器采用的是什么算法?
跟所有的语法分析器一样Java的语法分析器会把词法分析器生成的Token流生成一棵AST。
下面的AST就是MyClass.java示例代码对应的AST其中的JCXXX节点都是实现了com.sun.source.tree中的接口比如JCBinary实现了BinaryTree接口而JCLiteral实现了LiteralTree接口
图5MyClass.java对应的AST
我想你应该知道,语法分析的算法分为自顶向下和自底向上两种:
以LL算法为代表的自顶向下的算法比较直观、容易理解但需要解决左递归问题
以LR算法为代表的自底向上算法能够避免左递归问题但不那么直观不太容易理解。
那么Java编译器用的是什么算法呢
你可以打开com.sun.tools.javac.parser.JavacParser这个类看一下代码。比如你首先查看一下parseExpression()方法(也就是解析一个表达式)。阅读代码,你会看到这样的调用层次:
图6解析表达式时的调用层次
我们以解析“2+3”这样一个表达式来一层一层地理解下这个解析过程。
第1步需要匹配一个term。
term是什么呢其实它就是赋值表达式比如“a=2”或“b=3”等。算法里把这样一个匹配过程又分为两部分赋值符号左边的部分是term1其他部分是termRest。其中term1是必须匹配上的termRest是可选的。如果匹配上了termRest那么证明这是个赋值表达式否则就只是左边部分也就是term1。
如果你比较敏感的话,那仅仅分析第一步,你差不多就能知道这是什么算法了。
另外你可能还会对Rest这个单词特别敏感。你还记得我们在什么地方提到过Rest这个词汇吗是的在第3讲中我把左递归改写成右递归的时候那个右递归的部分我们一般就叫做XXXRest或XXXTail。
不过没关系,你可以先保留着疑问,我们继续往下看,来印证一下看法是不是对的。
第2步匹配term1。
term1又是什么呢term1是一个三元表达式比如a > 3 ? 1 : 2。其中比较操作符左边的部分是term2剩下的部分叫做term1Rest。其中term2是必须匹配的term1Rest是可选的。
第3步匹配term2。
term2代表了所有的二元表达式。它再次分为term3和term2Rest两部分前者是必须匹配的后者是可选的。
第4步匹配term3。
term3往下我就不深究了总之是返回一个字面量2。
第5步匹配term2Rest。
首先匹配“+”操作符然后匹配一个term3()这里是返回一个字面量3。
第6步回到term1()方法试图匹配term1Rest没有匹配上。
第7步回到term()方法试图匹配termRest也没有匹配上。
第8步从term()方法返回一个代表“2+3”的AST如下图所示
图7“2+3”对应的AST
讲到这儿,我想问问你:你从这样的分析中,得到了什么信息?
第一这是一个递归下降算法。因为它是通过逐级下降的方法来解析从term到term1、term2、term3直到最后是字面量这样最基础的表达式。
在第3讲里我说过递归下降算法是每个程序员都应该掌握的语法分析算法。你看像Java这么成熟的语言其实采用的也是递归下降算法。
第二Java采用了典型的消除左递归的算法。我带你回忆一下对于
add -> add + mul
这样的左递归的文法,它可以改成下面的非左递归文法:
add -> mul add'
add' -> + add' | ε
如果我再换一下表达方式就会变成Java语法解释器里的代码逻辑
term2 -> term3 term2Rest
term2Rest -> + term3 | ε
第三Java编译器对优先级和结合性的处理值得深究。
首先看看优先级。我们通常是通过语法逐级嵌套的方式来表达优先级的。比如按照下面的语法规则生成的AST乘法节点会在加法节点下面因此先于加法节点计算从而优先级更高。实际上Java做语法分析的时候term1->term2->term3的过程也是优先级逐步提高的过程。
add -> mul add'
add' -> + mul add' | ε
mul -> pri mul'
mul' -> * pri mul' | ε
可是在term2中实际上它解析了所有的二元表达式在语法规则上它把使用“&&”“ >”“+”“*” 这些不同优先级的操作符的表达式,都同等看待了。
term2 -> term3 term2Rest
term2Rest -> (&& | > | + | * |...) term3 | ε
不过,这里面包含了多个优先级的运算符,却并没有拆成很多个级别,这是怎么实现的呢?
我们再来看看结合性。对于“2+3+4”这样一个表达式我在第3讲是把右递归调用转换成一个循环让新建立的节点成为父节点从而维护正确的结合性。
如果你阅读term2Rest的代码就会发现它的处理逻辑跟第3讲是相同的也就是说它们都是用循环的方式来处理连续加法或者连续乘法并生成结合性正确的AST。
不过Java编译器的算法更厉害。它不仅能用一个循环处理连续的加法和连续的乘法对于“2+3*5”这样采用了多种不同优先级的操作符的表达式也能通过一个循环就处理掉了并且还保证了优先级的正确性。
在term2Rest中可以使用多个优先级的操作符从低到高的顺序如下
"||"
"&&"
"|"
"^"
"&"
"==" | "!="
"<" | ">" | "<=" | ">="
"<<" | ">>" | ">>>"
"+" | "-"
"*" | "/" | "%"
如果按照常规的写法我们处理上面10级优先级的操作符需要写10级嵌套的结构。而Java用一级就解决了。这个秘密就在term2Rest()的实现中。我们以“2*3+4*5”为例分析一下。
term2Rest()算法维护了一个操作数的栈odStack和操作符的栈opStack作为工作区。算法会根据odStack、opStack和后续操作符这三个信息决定如何生成优先级正确的AST。我把解析“2*3+4*5”时栈的变化画成了一张图。
图8解析“2*3+4*5”的时候odStack、opStack和后续操作符的变化
在一步一步解析的过程中当opStack的栈顶运算符的优先级大于等于后续运算符的优先级时就会基于odStack栈顶的两个元素创建一棵二元表达式的子树就像第2步那样。
反过来的话栈顶运算符的优先级小于后续运算符的优先级像第4步那样就会继续把操作数和操作符入栈而不是创建二元表达式。
这就可以保证优先级高的操作符形成的子树总会在最后的AST的下层从而优先级更高。
再仔细研究一下这个算法你会发现它是借助一个工作区自底向上地组装AST。是不是觉得很眼熟是不是想到了LR算法没错这就是一个简单LR算法。操作数栈和操作符栈是工作区然后要向后预读一个运算符决定是否做规约。只不过做规约的规则比较简单依据相邻的操作符的优先级就可以了。
其实这种处理表达式优先级的解析方法有一个专有的名字就叫做“运算符优先级解析器Operator-Precedence Parser”。Java编译器用这一个算法处理了10个优先级的二元表达式的解析同时又不用担心左递归问题确实很棒
课程小结
本节课我带你揭秘了Java编译器的一角我想强调这样几个重点。
第一你要大致熟悉一下Java语言中与编译有关的模块、包和类。这样在你需要的时候可以通过编程来调用编译器的功能在运行时动态编译Java程序并动态加载运行。
第二Java的词法分析总体上是遵循有限自动机的原理但也引入了不少的灵活性。比如在处理标识符和关键字的词法规则重叠的问题上是先都作为标识符识别出来然后再把其中的关键词挑出来。
第三Java的语法分析总体上是自顶向下的递归下降算法。在解决左递归问题时也采用了标准的改写文法的方法。但是在处理二元表达式时局部采用了自底向上的运算符优先级解析器使得算法更简洁。
当然了我没有覆盖所有的词法解析和语法解析的细节。但你按照今天这一讲的分析思路完全能看懂其他部分的代码。通过我帮你开的这个头我期待你继续钻研下去搞清楚Java的词法和语法解析功能的每个细节。
比如递归下降算法中最重要的是要减少试错次数一下子就能精准地知道应该采用哪个产生式。而你通过阅读代码会了解Java的编译器是如何解决这个问题的它在一些语法上会预读一个Token在另外的语法上会预读两个、三个Token以及加上一些与上下文有关的代码通过种种方式来减少回溯提高编译性能。这实际上就是采用了LL(k)算法的思路而k值是根据需要来增加的。
通过今天的分析你会发现Java编译器在做词法和语法分析的时候总体上遵循了编译原理中的知识点比如构造有限自动机、改写左递归文法等等但又巧妙地引入了不少的变化包括解决词法规则冲突、融合了自顶向下算法和自底向上算法、根据情况灵活地预读1到多个Token等。我相信对你会大有启发像这样的实战知识恐怕只有分析实际编译器才能获得更进一步地你以后也可以用这样漂亮的方法解决问题。这就是对你这次探险的奖励。
我把这一讲的知识点用思维导图整理出来了,供你参考:
一课一思
运算符优先级解析器非常实用我们通过练习巩固一下对它的认识。你能推导一下解析“a>b*2+3”的时候odStack、opStack和后续运算符都是什么吗你也可以跟踪Java编译器的执行过程验证一下你的推导结果。
你可以在留言区交一下作业。比如像这样:
step1: a
step2: a,b > * //用逗号分隔栈里的多个元素
...
我会在下一讲的留言区,通过置顶的方式公布标准答案。好了,这节课就到这里,感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,386 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 Java编译器语法分析之后还要做些什么
你好,我是宫文学。
上一讲我带你了解了Java语言编译器的词法分析和语法分析功能这两项工作是每个编译器都必须要完成的。那么根据第1讲我对编译过程的介绍接下来就应该是语义分析和生成IR了。对于javac编译器来说生成IR也就是字节码以后编译器就完成任务了。也就是说javac编译器基本上都是在实现一些前端的功能。
不过由于Java的语法特性很丰富所以即使只是前端它的编译功能也不少。那么除了引用消解和类型检查这两项基础工作之外你是否知道注解是在什么时候处理的呢泛型呢还有各种语法糖呢
所以今天这一讲我就带你把Java编译器的总体编译过程了解一遍。然后我会把重点放在语义分析中的引用消解、符号表的建立和注解的处理上。当你学完以后你就能真正理解以下这些问题了
符号表是教科书上提到的一种数据结构但它在Java编译器里是如何实现的编译器如何建立符号表
引用消解会涉及到作用域那么作用域在Java编译器里又是怎么实现的
在编译期是如何通过注解的方式生成新程序的?
为了方便你理解Java编译器内部的一些对象结构我画了一些类图如果你不习惯看类图的话可以参考下面的图表说明比如我用方框表示一个类用小圆圈表示一个接口几种线条分别代表继承关系、引用关系和接口实现
图1 :课程中用到的类图的图表说明
在课程开始之前,我想提醒几点:建议你在一个良好的学习环境进入今天的学习,因为你需要一步步地,仔细地跟住我的脚步,避免在探索过程中迷路;除此之外,你的手边还需要一个电脑,这样随时可以查看我在文章中提到的源代码。
了解整个编译过程
现在你可以打开jdk.compiler模块中的com.sun.tools.javac.comp包对应的源代码目录。
comp应该是Compile的缩写。这里面有一个com.sun.tools.javac.comp.CompileStates类它的意思是编译状态。其中有一个枚举类型CompileState里面列出了所有的编译阶段。
你会看到词法和语法分析只占了一个环节PARSE生成字节码占了一个环节而剩下的8个环节都可以看作是语义分析工作建立符号表、处理注解、属性计算、数据流分析、泛型处理、模式匹配处理、Lambda处理和去除其他语法糖
public enum CompileState {
INIT(0), //初始化
PARSE(1), //词法和语法分析
ENTER(2), //建立符号表
PROCESS(3), //处理注解
ATTR(4), //属性计算
FLOW(5), //数据流分析
TRANSTYPES(6), //去除语法糖:泛型处理
TRANSPATTERNS(7), //去除语法糖:模式匹配处理
UNLAMBDA(8), //去除语法糖LAMBDA处理(转换成方法)
LOWER(9), //去除语法糖内部类、foreach循环、断言等。
GENERATE(10); //生成字节码
...
}
另外你还可以打开com.sun.tools.javac.main.JavaCompiler的代码看看它的compile()方法。去掉一些细节,你会发现这样的代码主干,从中能看出编译处理的步骤:
processAnnotations( //3处理注解
enterTrees(stopIfError(CompileState.PARSE, //2建立符号表
initModules(stopIfError(CompileState.PARSE,
parseFiles(sourceFileObjects)) //1词法和语法分析
))
),classnames);
...
case SIMPLE:
generate( //10生成字节码
desugar( //6~9去除语法糖
flow( //5数据流分析
attribute(todo)))); //4属性计算
其中PARSE阶段的成果就是生成一个AST后续的语义分析阶段会基于它做进一步的处理
enterTrees()对应ENTER这个阶段的主要工作是建立符号表。
processAnnotations()对应PROCESS阶段它的工作是处理注解。
attribute()对应ATTR阶段这个阶段是做属性计算我会在下一讲中给你做详细的介绍。
flow()对应FLOW阶段主要是做数据流分析。我在第7讲中就提到过数据流分析那时候是用它来做代码优化。那么难道在前端也需要做数据流分析吗它会起到什么作用这些问题的答案我也会在下一讲中为你揭晓。
desugar()去除语法糖其实这里包括了TRANSTYPES处理泛型、TRANSPATTERNS处理模式匹配、UNLAMBDA处理Lambda和LOWER处理其他所有的语法糖比如内部类、foreach循环等四个阶段我会在第12讲给你介绍。
generate()生成字节码对应了GENERATE阶段这部分内容我也会在第12讲详细介绍。
在今天这一讲,我会给你介绍前两个阶段的工作:建立符号表和处理注解。
首先我们来看看Enter阶段也就是建立符号表的过程。
ENTER阶段建立符号表
Enter阶段的主要代码在com.sun.tools.javac.comp.Enter类中。在这个阶段会把程序中的各种符号加到符号表中。
建立符号表
在第5讲中我已经介绍了符号表的概念。符号表是一种数据结构它保存了程序中所有的定义信息也就是你定义的每个标识符不管是变量、类型还是方法、参数在符号表里都有一个条目。
那么,我们再深入看一下,什么是符号。
其实符号代表了一门语言的基础构成元素。在java.compiler模块中定义了Java语言的构成元素Element包括模块、包、类型、可执行元素、变量元素等。这其中的每个元素都是一种符号。
图2Java语言的基本构成元素
而在jdk.compiler模块中定义了这些元素的具体实现也就是Symbol符号。
图3Symbol及其子类
符号里记录了一些重要的属性信息比如名称name、类型type、分类kind、所有者owner还有一些标记位标志该符号是否是接口、是否是本地的、是否是私有的等等这些信息在语义分析和后续编译阶段都会使用。另外不同的符号还有一些不同的属性信息比如变量符号会记录其常数值constValue这在常数折叠优化时会用到。
那么Enter过程是怎样发生的呢你可以看一下com.sun.tools.javac.comp.MemberEnter类中的 visitVarDef()方法。
实际上当看到一个方法使用visit开头的时候你应该马上意识到这个方法是被用于一个Visitor模式的调用中。也就是说Enter过程是一个对AST的遍历过程遍历的时候会依次调用相应的visit方法。visitVarDef()是用于处理变量声明的。
我们还以MyClass的编译为例来探索一下。MyClass有一个成员变量a在Enter阶段编译器就会为a建立符号。
我们来看看它的创建过程:
public class MyClass {
public int a = 2+3;
public int foo(){
int b = a + 10;
return b;
}
}
我从visitVarDef()中挑出了最重要的三行代码,需要你着重关注。
...
//创建Symbol
VarSymbol v = new VarSymbol(0, tree.name, vartype, enclScope.owner);
...
tree.sym = v; //关联到AST节点
...
enclScope.enter(v); //添加到Scope中
...
第一行是创建Symbol。
第二行是把Symbol关联到对应的AST节点这里是变量声明的节点JCVaraibleDecl
你可以看一下各个AST节点的定义其中的类、方法、变量声明、编译单元以及标识符都带有一个sym成员变量用来关联到一个符号。这样后续在遍历树的时候你就很容易查到这个节点对应的Symbol了。
不过你要注意各种声明节点类声明、方法声明等对应的符号是符号的定义。而标识符对应的Symbol是对符号的引用。找到每个标识符对应的定义就是语义分析中的一项重要工作引用消解。不过引用消解不是在Enter阶段完成的而是在ATTR阶段。
你在跟踪编译器运行的时候可以在JCClassDecl等AST节点的sym变量上打个中断标记这样你就会知道sym是什么时候被赋值的从而也就了解了整个调用栈这样会比较省事。
延伸一句:当你调试一个大的系统时,选择好恰当的断点很重要,会让你事半功倍。
图4一些重要的AST节点的属性和方法其中多个AST节点中都有对Symbol的引用
最后来看一下第三行代码这行代码是把Symbol添加到Scope中。
什么是ScopeScope就是作用域。也就是说在Enter过程中作用域也被识别了出来每个符号都是保存在相应的作用域中的。
在第4讲我们曾经说过符号表可以采用与作用域同构的带层次的表格。Java编译器就是这么实现的。符号被直接保存进了它所在的词法作用域。
在具体实现上Java的作用域所涉及的类比较多我给你整理了一个类图你可以参考一下
图5与Scope有关的类
其中有几个关键的类和接口,需要给你介绍一下。
首先是com.sun.tools.javac.code.Scope$ScopeImpl类这是真正用来存放Symbol的容器类。通过next属性来指向上一级作用域形成嵌套的树状结构。
图6AST中的作用域
但是在处理AST时如何找到当前的作用域呢这就需要一个辅助类Env< AttrContext>。Env的意思是环境用来保存编译过程中的一些上下文信息其中就有当前节点所处的作用域Env.info.scope。下图展示的是在编译过程中所使用的Env的情况这些Env也构成了一个树状结构。
图7Env< AttrContext>构成的树状结构
然后是com.sun.source.tree.Scope接口这是对作用域的一个抽象可以获取当前作用域中的元素、上一级作用域、上一级方法以及上一级类。
好了,这就是与符号表有关的数据结构,后续的很多处理工作都要借助这个数据结构。比如,你可以思考一下,如何基于作用域来做引用消解?在下一讲,我会给你揭晓这个问题的答案。
两阶段的处理过程
前面讨论的是符号表的数据结构以及建立符号表的大致过程。接下来我们继续深究一下建立符号表算法的一个重要特点Enter过程是分两个阶段完成的。
你可以打开Enter类看看Enter类的头注释里面对这两个阶段做了说明。
第一个阶段:只是扫描所有的类(包括内部类),建立类的符号,并添加到作用域中。但是每个类定义的细节并没有确定,包括类所实现的接口、它的父类,以及所使用的类型参数。类的内部细节也没有去扫描,包括其成员变量、方法,以及方法的内部实现。
第二个阶段:确定一个类所缺失的所有细节信息,并加入到符号表中。
这两个阶段,第一个阶段做整个程序的扫描,把所有的类都识别出来。而第二个阶段是在需要的时候才进行处理的。
这里的问题是:为什么需要两个阶段?只用一个阶段不可以吗?
我借一个例子给你解释一下原因。你看看下面这段示例代码在Enter过程中编译器遍历了MyClass1的AST节点JCClassDecl并建立了一个ClassSymbol。但在遍历到它的成员变量a的时候会发现它不认识a的类型MyClass2因为MyClass2的声明是在后面的。
public class MyClass1{
MyClass2 a;
}
class MyClass2{
}
怎么办呢我们只好分成两个阶段去完成扫描。在第一个阶段我们为MyClass1和MyClass2都建立符号并且都放到符号表中第二阶段我们再去进一步扫描MyClass1的内部成员的时候就能为成员变量a标注正确的类型也就是MyClass2。
我在第4讲中说过语义分析的特点是上下文相关的。通过对比你会发现处理上下文相关情况和上下文无关情况的算法它们是不一样的。
语法解析算法处理的是上下文无关的情况因此无论自顶向下还是自底向上整个算法其实是线性执行的它会不断地消化掉Token最后产生AST。对于上下文相关的情况算法就要复杂一些。对AST各个节点的处理会出现相互依赖的情况并且经常会出现环形依赖因为两个类互相引用在Java语言里是很常见的。加上这些依赖关系以后AST就变成了一张图。
而语义分析算法实质上就是对图的遍历算法。我们知道图的遍历算法的复杂度是比较高的。编译器一般要采用一定的启发式Heuristic的算法人为地找出代价较低的遍历方式。Java编译器里也采用了启发式的算法我们尽量把对图的遍历简化为对树的遍历这样工作起来就会简单得多。
对AST的遍历采用了Visitor模式。下图中我列出了一些采用Visitor模式对AST进行处理的程序。Enter程序是其中的一个。
图8对AST进行处理的Visitor模式的部分程序
所以语义分析就是由各种对AST进行遍历的算法构成的。在跟踪Java编译器执行的过程中你还会发现多个处理阶段之间经常发生交错。比如对于方法体中声明的局部变量它的符号不是在ENTER阶段创建的而是在ATTR阶段又回过头来调用了与建立符号表有关的方法。你可以先想想这又是什么道理。这里留下一个伏笔我会在下一讲中给你解答。
系统符号表
前面说的符号表保存了用户编写的程序中的符号。可是还有一些符号是系统级的可以在不同的程序之间共享比如原始数据类型、java.lang.Object和java.lang.String等基础对象、缺省的模块名称、顶层的包名称等。
Java编译器在Symtab类中保存这些系统级的符号。系统符号表在编译的早期就被初始化好并用于后面的编译过程中。
以上就是ENTER阶段的所有内容。接着编译器就会进入下一个阶段PROCESS阶段也就是处理注解。
PROCESS阶段处理注解
注解是Java语言中的一个重要特性它是Java元编程能力的一个重要组成部分。所谓元编程简单地说就是用程序生成或修改程序的能力。
Java的注解需要在编译期被解析出来。在Java编译器中注解被看作是符号的元数据所以你可以看一下SymbolMetadata类它记录了附加在某个符号上的各种注解信息。
然后呢,编译器可以在三个时机使用这些注解:一是在编译时,二是在类加载时,三是在类运行时。
对于后两者编译器需要做的工作比较简单把注解内容解析出来放到class文件中。这样的话PROCESS阶段不需要做什么额外的工作。
而有些注解是要在编译期就处理的这些注解最后就没必要保存到class文件。因为它们的使命在编译阶段就完成了。
那在编译阶段会利用注解做什么呢最主要的用法是根据注解动态生成程序并且也被编译器编译。在后面探索Java的JIT编译器时你会看到采用这种思路来生成程序的实例。你可以用简单的注解就让注解处理程序生成很长的、充满“刻板代码”的程序。
我写了一个非常简单的示例程序来测试Java编译器处理注解的功能。该注解叫做HelloWorld
@Retention(RetentionPolicy.SOURCE) //注解用于编译期处理
@Target(ElementType.TYPE) //注解是针对类型的
public @interface HelloWorld {
}
针对这个注解需要写一个注解处理程序。当编译器在处理该注解的时候就会调用相应的注解处理程序。你可以看一下HelloWorldProcessor.java程序。它里面的主要逻辑是获取被注解的类的名称比如说叫Foo然后生成一个HelloFoo.java的程序。这个程序里有一个sayHello()方法能够打印出“Hello Foo”。如果被注解的类是Bar那就生成一个HelloBar.java并且打印“Hello Bar”。
我们看一下Foo的代码。你注意这里面有一个很有意思的现象在Foo里调用了HelloFoo但HelloFoo其实当前并没有生成
@HelloWorld
public class Foo {
//HelloFoo类是处理完注解后才生成的。
static HelloFoo helloFoo = new HelloFoo();
public static void main(String args[]){
helloFoo.sayHello();
}
}
你可以在命令行编译这三个程序。其中编译Foo的时候要用-processor选项指定所采用的注解处理器。
javac HelloWorld.java
javac HelloWorldProcessor.java
javac -processor HelloWorldProcessor Foo.java
在这个编译过程中你会发现当前目录下生成了HelloFoo.java文件并且在编译Foo.java之前就被编译了这样在Foo里才能调用HelloFoo的方法。
你可以在IDE里跟踪一下编译器对注解的处理过程。借此你也可以注意一下编译器是如何管理编译顺序的因为HelloFoo一定要在Foo之前被编译。
扩展Debug对注解的处理过程需要有一定的技巧请参考我为你整理的配置指南。
你会发现在Enter之后声明helloFoo这个成员变量的语句的vartype节点的类型是ErrorType证明这个时候编译器是没有找到HelloFoo的定义的。
图9在处理注解之前还没有生成HelloFoo
不过在编译器处理完毕注解以后HelloFoo就会被生成Foo类的ENTER过程还会重走一遍这个时候相关类型信息就正确了。
课程小结
好了本讲我们首先对Java的编译过程做了一个顶层的介绍然后分析了ENTER和PROCESS阶段所做的工作。希望你能有以下收获
对前端编译过程可以有更加细致的了解特别是对语义分析阶段会划分成多个小阶段。由于语法分析的本质是对图做处理所以实际执行过程不是简单地逐个阶段顺序执行而是经常交织在一起特别是ENTER阶段和ATTR阶段经常互相交错。
ENTER阶段建立了符号表这样一个重要的数据结构。我们现在知道Java的符号表是按照作用域的结构建立的而AST的每个节点都会对应某个作用域。
PROCESSOR阶段完成了对注解的处理。你可以在代码里引用将要生成的类做完注解处理后这些类会被生成并编译从而使得原来的程序能够找到正确的符号不会报编译错误。
在最后,为了帮你将今天的内容做一个梳理,我提供一张思维导图,供你参考,整理知识:
一课一思
在Java语言中对于下面的示例代码会产生几个作用域你觉得它们分别是什么
public class ScopeTest{
public int foo(int a){
if(a>0){
//一些代码
}
else{
//另一些代码
}
}
}
欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
参考资料
关于注解的官方教程,你可以参考这个链接。
扩展知识
Java编译器的功能很多。如果你有精力还可以探索一些有趣的细节功能比如你知道Java在编译阶段会自动生成缺省的构造函数吗
ENTER的第二个阶段任务是由TypeEnter类来完成的你可以查看一下这个类的说明。它内部划分成了4个小的阶段每个阶段完成一个更小一点的任务。其中的MemberPhase阶段会把类的成员都建立符号但MemberPhase还会做一件有趣的事情就是生成缺省的构造函数。
为什么说这个细节很有趣呢因为这是你第一次遇到在语义分析的过程中还要对AST做修改。下面我们看看这个过程。
首先,你需要重新回顾一下缺省构造函数的意思。
在编写Java程序时你可以不用写构造函数。对于下面这个MyClass5类我们没有写构造函数也能正常地实例化
public class MyClass5{
}
但在语义分析阶段实际上编译器会修改AST插入一个缺省构造函数相当于下面的代码。缺省的构造函数不带参数并且调用父类的一个不带参数构造方法对于MyClass5类来说父类是java.lang.Object类“super()”引用的就是Object类的不带参数的构造方法
public class MyClass3{
public MyClass3(){
super();
}
}
对应的AST如下其中JCMethodDecl这棵子树就是语义分析程序插入的。
图10生成缺省构造函数之后的AST以及关联的Symbol
新插入的构造方法是以JCMethodDecl为根节点的一棵子树。对于这个JCMethodDecl节点在属性标注做完以后形成了下面的属性。
名称:<init>
类型:()void也就是没有参数返回值为void。
符号生成了一个方法型的符号sym属性它的名称是<init>如果调用sym.isConstructor()方法返回true也就是说这个符号是一个构造方法。
在这个缺省构造方法里调用了“super();”这样一个语句,让父类有机会去做初始化工作,进而也让父类的父类有机会去做初始化工作,依次类推。
“super()”语句的叶子节点是一个JCIndent节点也就是标识符。这个标识符的名称是”super“而符号sym属性则通过变量引用消解指向了Object类的构造方法。
最后我们声明MyClass5类的时候也并没有声明它继承自Object。这个信息也是自动推断出来的并且会在类节点JCClassDecl的type属性中标注清楚。在图中你可以看到type.supertype_field指向了Object这个类型。
除了在自动生成的缺省构造函数里会调用super(),你还知道,当我们手写一个构造函数的时候,也可以在第一句里调用父类的一个构造方法(并且必须是在第一句)。
public class MyClass4 extends MyClass3{
public MyClass4(int a){
super(a); //这句可以省略,编译器可以自动生成
...
}
}
如果你不显式地调用super()编译器也会自动加入这样的一个调用并生成相应的AST。这个时候父类和子类的构造方法的参数也必须一致。也就是说如果子类的构造方法的签名是int, String那么父类也必须具备相同签名的一个构造方法否则没有办法自动生成对父类构造方法的调用语句编译器就会报错。我相信你很可能在编程时遇到过这种编译信息不过现在你应该就能清晰地了解为什么编译器会报这些类型的编译错误了。
总体来说Java的编译器会根据需要加入一些AST节点实现一些缺省的功能。其中包括缺省的构造方法、对父类构造方法的缺省调用以及缺省的父类Object

View File

@ -0,0 +1,429 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 Java编译器属性分析和数据流分析
你好,我是宫文学。
在上一讲我们主要讨论了语义分析中的ENTER和PROCESS阶段。今天我们继续往下探索看看ATTR和FLOW两个阶段。
ATTR的字面意思是做属性计算。在第4讲中我已经讲过了属性计算的概念你应该还记得什么是S属性什么是I属性。那么Java编译器会计算哪些属性又会如何计算呢
FLOW的字面意思是做数据流分析。通过第7讲你已经初步了解了数据流分析的算法。但那个时候是把数据流分析用于编译期后端的优化算法包括删除公共子表达式、变量传播、死代码删除等。而这里说的数据流分析属于编译器前端的工作。那么前端的数据流分析会做什么工作呢
这些问题的答案我今天都会为你一一揭晓。好了我们进入正题首先来看看ATTR阶段的工作属性分析。
ATTR属性分析
现在你可以打开com.sun.tools.javac.comp.Attr类的代码。在这个类的头注释里你会发现原来ATTR做了四件事分别在4个辅助类里实现
Check做类型检查。
Resolve做名称的消解也就是对于程序中出现的变量和方法关联到其定义。
ConstFold常量折叠比如对于“2+3”这种在编译期就可以计算出结果的表达式就直接计算出来。
Infer用于泛型中的类型参数推导。
我们首先来看Check也就是类型检查。
类型检查
类型检查是语义分析阶段的一项重要工作。静态类型系统的语言比如Java、C、Kotlin、Swift都可以通过类型检查避免很多编译错误。
那么一个基础的问题是Java都有哪些类型
你是不是会觉得这个问题挺幼稚Java的类型不就是原始数据类型再加上类、接口这些吗
说得对但是并不全面。你已经看到Java编译器中每个AST节点都有一个type属性。那么一个模块或者一个包的类型是什么一个方法的类型又是什么呢
在java.compile模块中定义了Java的语言模型其中有一个包是对Java的类型体系做了设计你可以看一下
图1Java的类型体系
这样你就能理解了原来模块和包的类型是NoType而方法的类型是可执行类型ExecutableType。你可以看一下源代码会发现要刻画一个可执行类型是比较复杂的竟然需要5个要素
returnType返回值类型
parameterTypes参数类型的列表
receiverType接收者类型也就是这个方法是定义在哪个类型类、接口、枚举上的
thrownTypes所抛出异常的类型列表
typeVariables类型参数的列表。
如果你学过C语言你应该记得描述一个函数的类型只需要这个列表中的前两项也就是返回值类型和参数类型就可以了。通过这样的对比想必你会对Java的可执行类型理解得更清楚。
然而通过一个接口体系来刻画类型还是不够细致Java又提供了一个TypeKind的枚举类型把某些类型做进一步的细化比如原始数据类型进一步细分为BOOLEAN、BYTE、SHORT等。这种设计方式可以减少接口的数量使类型体系更简洁。你也可以在编程中借鉴这种设计方式避免产生过多的、没有什么实际意义的子类型。
同样在jdk.compiler模块中有一些具体的类实现了上述类型体系的接口
图2类型体系的实现
好了现在你已经了解了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"”这个语句,它的类型检查过程分了四步,如下图所示:
图3类型检查的过程
第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讲中我就介绍过了引用消解的概念。给你举个例子当我们在程序中用到一个变量的时候必须知道它确切的定义在哪里。比如下面代码中第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的成员变量。
图4引用消解的实现
不过这里还有一个细节需要深究一下。还记得我在前一讲留了一个问题吗这个问题是对于方法体中的本地变量不是在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()方法体的作用域中。引用消解都处理完毕以后,符号表才会填充完整,如下图所示:
图5引用消解后符号表中添加了本地变量
常数折叠
在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属性中。你可以看看下图。
图6AST节点对应的常数值属性
扩展你看了这个图可能会有一个疑问常数值为什么不是保存在AST节点中而是保存在类型对象中类型带上一个值是什么意思常数值为2的整型和常数值为3的整型是不是一个类型-
这是因为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;
}
}
看代码还是比较抽象。我把数据流分析的逻辑用控制流图的方式表示出来,你看着会更直观。
图7活跃性分析
我们通过活跃性分析可以学习到数据流分析框架的5个要素
V代表被分析的值这里是alive代表了控制流是否会到达这里。
I是V的初始值这里的初始值是LIVE
D指分析方向。这个例子里是从上到下扫描基本块中的代码而有些分析是从下往上的。
F指转换函数也就是遇到每个语句的时候V如何变化。这里是在遇到return语句的时候把alive变为DEAD。
Λmeet运算也就是当控制流相交的时候从多个值中计算出一个值。你看看下图在没有else块的时候两条控制流中alive的值是不同的最后的取值是LIVE。
图8当没有else块的时候两条控制流中的alive值不同
在做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”会发生什么情况呢
编译器会扫描while里面有没有合适的break语句通过resolveBreaks()方法)。如果找不到,就意味着这个循环永远不会结束,那么循环体后面的语句就永远不会到达,从而导致编译器报错。
除了活跃性分析Flow阶段还做了其他三项分析异常分析、赋值分析和本地变量捕获分析。
为了方便你的学习我把Java编译器用到的几个数据流分析方法整理了一下放在下面的表格中
这几种分析方法,我建议你可以做几个例子,跟踪代码并研究一下,会加深你对数据流分析的直观理解。
异常分析、赋值分析和本地变量捕获的思路与活跃性分析类似它们都是按照数据流分析框架来处理的。也就是说对于每个分析方法你都要注意识别出它的五大要素值、初始值、转换规则、扫描方向以及meet运算规则。
课程小结
今天这一讲我们研究了Java编译过程中的属性分析和数据流分析两个阶段。
在属性分析阶段你能够看到Java是如何做类型检查、引用消解、常量折叠和推导类型参数的它们实际上是对类型type、符号sym、常量值constValue和类型参数这4类属性的处理工作。
我们也见识到了在编译器前端的数据流分析阶段是如何使用数据流分析方法的。通过数据流分析编译器能够做一些更加深入的语义检查比如检查控制流是否都经过了return语句以及是否有不可到达的代码、每个异常是否都被处理变量在使用前是否肯定被赋值等等。
总体来说在ATTR和FLOW这两个阶段编译器完成了主要的语义检查工作。如果你在设计一门语言的时候遇到了如何做语义检查的问题那你就可以参考一下这一讲的内容。
在最后,是本节课程知识点的思维导图,供你参考:
一课一思
数据流分析框架很重要你可以借助实例对它熟悉起来。那么你能针对赋值分析把它的5个元素列出来吗欢迎在留言区分享你的思考我会在下一讲的留言区通过置顶的方式公布标准答案。
如果你觉得有收获,欢迎你把今天的内容分享给你的朋友。
参考资料
关于数据流分析的理论性内容可以参考龙书Compilers Principles, Techniques and Tools第二版的9.2和9.3节。你也可以参考《编译原理之美》 的第27、28讲那里进行了比较直观的讲述。
关于半格这个数学工具你可以参考龙书第二版的9.3.1部分也同样可以参考《编译原理之美》的第28讲。

View File

@ -0,0 +1,315 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 Java编译器去除语法糖和生成字节码
你好我是宫文学。今天是Java编译器的最后一讲我们来探讨编译过程最后的两个步骤去除语法糖和生成字节码。
其实今天要讲的这两个编译步骤总体上都是为生成字节码服务的。在这一阶段编译器首先会把语法糖对应的AST转换成更基础的语法对应的AST然后基于AST和符号表来生成字节码。
从AST和符号表到变成字节码这可是一个很大的转变就像把源代码转化成AST一样。那么这个过程的实现思路是什么有什么难点呢
今天这一讲我们就一起来解决以上这些问题在这个过程中你对Java编译器的认识会变得更加完整。
好了,我们首先来看看去除语法糖这一处理步骤。
去除语法糖Syntactic Sugar
Java里面提供了很多的语法糖比如泛型、Lambda、自动装箱、自动拆箱、foreach循环、变长参数、内部类、枚举类、断言assert等等。
你可以这么理解语法糖:它就是提高我们编程便利性的一些语法设计。既然是提高便利性,那就意味着语法糖能做到的事情,用基础语法也能做到,只不过基础语法可能更啰嗦一点儿而已。
不过我们最终还是要把语法糖还原成基础语法结构。比如foreach循环会被还原成更加基础的for循环。那么问题来了在编译过程中究竟是如何去除语法糖的基础语法和语法糖又有什么区别呢
在第10讲中我提到过在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的变化过程如下其中橙色的节点就是被去掉的泛型。
图1对泛型的处理
然后我们分析下对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如下图所示
图2foreach循环被改造成普通的for循环
你可以通过反编译来获得这些没有语法糖的代码它跟示例代码中用到的基础for循环语句是一样的。
对foreach循环的处理是在Lower类的visitForeachLoop方法中。
其实你在阅读编译技术相关的文献时应该经常会看到Lower这个词。它的意思是让代码从对人更友好的状态变换到对机器更友好的状态。比如说语法糖对编程人员更友好而基础的语句则相对更加靠近机器实现的一端所以去除语法糖的过程是Lower。除了去除语法糖凡是把代码向着机器代码方向所做的变换都可以叫做Lower。以后你再见到Lower的时候是不是就非常清楚它的意思了呢。
好了通过对泛型和foreach循环的处理方式的探讨现在你应该已经大致了解了去除语法糖的过程。总体来说去除语法糖就是把AST做一些变换让它变成更基础的语法要素从而离生成字节码靠近了一步。
那么接下来,我们看看编译过程的最后一个环节:生成字节码。
生成字节码Bytecode Generation
一般来说,我们会有一个错觉,认为生成字节码比较难。
实际情况并非如此因为通过前面的建立符号表、属性计算、数据流分析、去除语法糖的过程我们已经得到了一棵标注了各种属性的AST以及保存了各种符号信息的符号表。最难的编译处理工作在这几个阶段都已经完成了。
在第8讲中我就介绍过目标代码的生成。其中比较难的工作是指令选择、寄存器分配和指令排序。而这些难点工作在生成字节码的过程中基本上是不存在的。在少量情况下编译器可能会需要做一点指令选择的工作但也都非常简单你在后面可以看到。
我们通过一个例子,来看看生成字节码的过程:
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讲曾经介绍过栈机的运行原理也提到过栈机的一个优点就是生成目标代码的算法比较简单。
你可以看一下我画的示意图,里面有生成字节码的步骤:
图3生成字节码
第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之间的数据。
如果字面量值变成128那指令就要变成“sipush 128”占据三个字节表示往栈里压入一个short数据其中操作数占据两个字节。
如果该常数超过了两个字节能表示的范围比如“32768”那就要改成另一个指令“ldc #2”,这是把常数放到常量池里,然后从常量池里加载。
这几个例子反映了由于字面量的长度不同,而选用了不同的指令。接着,我们再来看看数据类型对指令的影响。
前面例子中生成的这四个指令全部都是针对整数做运算的。这是因为我们已经在语义分析阶段计算出了各个AST节点的类型它们都是整型。但如果是针对长整型或浮点型的计算那么生成的字节码就会不一样。下面是针对单精度浮点型所生成的字节码。
第三,数据类型影响指令生成的另一个情况,是类型转换。
一方面阅读字节码的规范你会发现对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型
OK到这里我已经总结了影响指令生成的一些因素包括字面量的长度、数据类型等。你能体会到这些指令选择的逻辑都是很简单的基于当前AST节点的属性编译器就可以做成正确的翻译了所以它们基本上属于“直译”。而我们在第8讲中介绍指令选择算法的时候遇到的问题通常是结合了多个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等等。
图4生成字节码过程中的辅助类Item及其子类
每种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()方法,则会把栈顶数据保存到对象的成员变量中。
invoke() : 生成调用该Item代表的方法的字节码。
coerce():强制类型转换。
我们之前讨论的类型转换功能就是在coerce()方法里完成的。
第二个辅助类是Code类。它里面有各种emitXXX()方法,会生成各种字节码的指令。
总结起来,字节码生成的总体框架如下面的类图所示:
Gen类以visitor模式访问AST生成字节码最后生成的字节码保存在Symbol的code属性中。
在生成字节码的过程中编译器会针对不同的AST节点生成不同的Item并调用Item的load()、store()、invoke()等方法这些方法会进一步调用Code对象的emitXXX()方法,生成实际的字节码。
图5生成字节码过程中涉及的类
好了,这就是生成字节码的过程,你会发现它的思路是很清楚的。你可以写一些不同的测试代码,观察它生成的字节码,以及跟踪生成字节码的过程,很快你就能对各种字节码是如何生成的了然于胸了。
代码优化
到这里我们把去除语法糖和生成字节码两部分的内容都讲完了。但是在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可以用右子树代替。
图6对AST做代数简化
4.GEN阶段代数简化和活跃性分析
在生成字节码的时候也会做一些代数简化。比如在Gen.visitBinary()方法中有跟Lower.visitBinary()类似的逻辑。而整个生成代码的过程也有类似FLOW阶段的活跃性分析的逻辑对于不可达的代码就不再生成字节码。
看上去GEN阶段的优化算法是冗余的跟前面的阶段重复了。但是这其实有一个好处也就是可以把生成字节码的部分作为一个单独的库使用不用依赖前序阶段是否做了某些优化。
总结起来Java编译器在多个阶段都有一点代码优化工作但总体来看代码优化是很不足的。真正的高强度的优化还是要去看Java的JIT编译器。这些侧重于做优化的编译器有时就会被叫做“优化编译器Optimizing Compiler”。
课程小结
今天我带你分析了Java编译过程的最后两个步骤去除语法糖和字节码生成。你需要记住以下几点
语法糖是现代计算机语言中一个友好的特性。Java语言很多语法上的升级实际上都只是增加了一些语法糖而已。语法糖在Java编译过程中的去除语法糖环节会被还原成基础的语法。其实现机制是对AST做修改和转换。
生成字节码是一个比较机械的过程编译器只需要对AST进行深度优先的遍历即可。在这个过程中会用到前几个阶段形成的属性信息特别是类型信息。
我把本讲的知识点整理成了思维导图,供你参考:
之所以我花了4讲去介绍Java编译器的核心机制是因为像Java这样成熟的静态类型语言它的编译器的实现思路有很多借鉴意义比如词法分析和语法分析采用的算法、语义分析中多个阶段的划分和之间的相互关系、如何用各种方法检查语义错误、符号表的实现、语法糖和基础语法的关系等等。当你把Java编译器的脉络看清楚以后再去看其他静态类型语言的编译器的代码就会发现其中有很多地方是共通的你就能更快地熟悉起来。这样下来你对静态语言编译器的前端都会有个清晰的了解。
当然只了解前端部分是不够的Java还有专注于中后端功能的编译器也就是JIT编译器。我们这讲也已经说过了前端编译器的优化功能是有限的。那么如果想让Java代码高效运行就要依靠JIT编译器的优化功能和生成机器码的功能了。在后面的四讲中我会接着给你揭秘Java的JIT编译器。
一课一思
针对Java编译器这4讲的内容我们来做一个综合的思考题。假设你现在要写一个简单的DSL引擎比如让它能够处理一些自定义的公式最后要生成字节码你会如何让它最快地实现是否可以复用Java编译器的功能
欢迎你留言分享自己的观点。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
参考资料
Java语言规范第六章Java虚拟机指令集。

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 Java JIT编译器动手修改Graal编译器
你好,我是宫文学。
在前面的4讲当中我们已经解析了OpenJDK中的Java编译器它是把Java源代码编译成字节码然后交给JVM运行。
用过Java的人都知道在JVM中除了可以解释执行字节码以外还可以通过即时编译JIT技术生成机器码来执行程序这使得Java的性能很高甚至跟C++差不多。反之,如果不能达到很高的性能,一定会大大影响一门语言的流行。
但是,对很多同学来说,对于编译器中后端的了解,还是比较模糊的。比如说,你已经了解了中间代码、优化算法、指令选择等理论概念,那这些知识在实际的编译器中是如何落地的呢?
所以从今天开始我会花4讲的时间来带你了解Java的JIT编译器的组成部分和工作流程、它的IR的设计、一些重要的优化算法以及生成目标代码的过程等知识点。在这个过程中你还可以印证关于编译器中后端的一些知识点。
今天这一讲呢我首先会带你理解JIT编译的基本原理然后我会带你进入Graal编译器的代码内部一起去修改它、运行它、调试它让你获得第一手的实践经验消除你对JIT编译器的神秘感。
认识Java的JIT编译器
我们先来探究一下JIT编译器的原理。
在第5讲中我讲过程序运行的原理把一个指令指针指向一个内存地址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
图1分层编译
由于C2会做一些激进优化比如说它会根据程序运行的统计信息认为某些程序分支根本不会被执行从而根本不为这个分支生成代码。不过有时做出这种激进优化的假设其实并不成立那这个时候就要做一个逆优化Deoptimization退回到使用C1的代码或退回到用解释器执行。
触发即时编译需要检测热点代码。一般是以方法为单位虚拟机会看看该方法的运行频次是否很高如果运行特别频繁那么就会被认定为是热点代码从而就会被触发即时编译。甚至如果一个方法里有一个循环块是热点代码比如循环1.5万次以上这个时候也会触发编译器去做即时编译在这个方法还没运行完毕的时候就被替换成了机器码的版本。由于这个时候该方法的栈帧还在栈上所以我们把这个技术叫做栈上替换On-stack ReplacementOSR。栈上替换的技术难点在于让本地变量等数据无缝地迁移让运行过程可以正确地衔接。
Graal用Java编写的JIT编译器
如果想深入地研究Java所采用的JIT编译技术我们必须去看它的源码。可是对于大多数Java程序员来说如果去阅读C++编写的编译器代码,肯定会有些不适应。
一个好消息是Oracle公司推出了一个完全用Java语言编写的JIT编译器Graal并且也有开放源代码的社区版你可以下载安装并使用。
用Java开发一款编译器的优点是很明显的。
首先Java是内存安全的而C++程序的很多Bug都与内存管理有关比如可能不当地使用了指针之类的。
第二与Java配套的各种工具比如IDE更友好、更丰富。
第三Java的性能并不低所以能够满足对编译速度的需求。
最后用Java编译甚至还能节省内存的占用因为Java采用的是动态内存管理技术一些对象没用了其内存就会被回收。而用C++编写的话,可能会由于程序员的疏忽,导致一些内存没有被及时释放。
从Java9开始你就可以用Graal来替换JDK中的JIT编译器。这里有一个JVMCIJVM 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语言还是有点开脑洞的
图2GraalVM的架构
最后GraalVM还支持AOT编译这就让Java可以编译成本地代码让程序能更快地启动并投入高速运行。我听说最近的一些互联网公司已经在用Graal做AOT编译来生成本地镜像提高应用的启动时间从而能够更好地符合云原生技术的要求。
修改并运行Graal
那接下来我就带你一起动手修改一下Graal编译器在这个过程中你就能对Graal的程序结构熟悉起来消除对它的陌生感有助于后面深入探索其内部的实现机制。
在本课程中我采用了Graal的20.0.1版本的源代码。你可以参考Graal中的文档来做编译工作。
首先,下载源代码(指定了代码的分支):
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等编译器生成配置信息了。你可以参照文档来做好IDE的配置。
注意我用Eclipse和IntelliJ Idea都试了一下。Idea的使用体验更好一些。但用mx ideinit命令为Idea生成的配置文件只是针对JDK8的如果要改为JDK11还需要手工修改不少配置信息。-
在使用Idea的时候你要注意安装python插件文档中建议的其他插件可装可不装。-
在使用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机制的了。
另外,在这个实验中,你还可以通过“-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()));
...
打印输出的机器码数组如下:
我们光看这些机器码数组当然是看不出来有什么含义的但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
输出的汇编码信息如下:
我来解释一下这段汇编代码的含义:
好了现在你已经能够直观地了解JIT启动的时机检测出热点代码以及它最后生成的结果机器码。
但我们还想了解一下中间处理过程的细节因为这样才能理解编译器的工作机制。所以这个时候如果能够跟踪Graal的执行过程就好了就像调试一个我们自己编写的程序那样。那么我们能做到吗
当然是可以的。
跟踪Graal的运行
Graal是用Java编写的因此你也可以像调试普通程序一样调试它。你可以参考源代码中的这篇与调试有关的文档。
由于Graal是在JVM中运行的所以你要用到JVM的远程调试模式。我们仍然要运行Foo示例程序不过要加个“-d”参数表示让JVM运行在调试状态下。
mx -d vm \
-XX:+UnlockExperimentalVMOptions \
-XX:+EnableJVMCI \
-XX:+UseJVMCICompiler \
-XX:-TieredCompilation \
-XX:CompileOnly=Foo.add \
Foo
这个时候在JVM启动起来之后会在8000端口等待调试工具跟它连接。
你可以使用Eclipse或Idea做调试工具。我以Eclipse为例在前面运行“mx ideinit”的时候我就已经设置了一个远程调试的配置信息。
你可以打开“run>debug configurations…”菜单在弹出的对话框中选择Remote Java Application可以看到几个预制好的配置。
然后点击“compiler-attach-localhost-8000”你可以看到相关属性。其中连接信息正是本机的8000端口。
把Project改成“org.graalvm.compiler.hotspot”然后点击Debug按钮。
补充如果你使用的是Idea你也会找到一个预制好的远程调试配置项GraalDebug。直接点击就可以开始调试。
为了方便调试我在org.graalvm.compiler.hotspot.compileMethod()方法中设置了断点,所以到了断点的时候,程序就会停下来,而不是一直运行到结束。
当你点击Debug按钮以后Foo程序会继续运行。在触发了JIT功能以后JVM会启动一个新线程来运行Graal而Foo则继续在主线程里运行。因为Foo一直不会结束所以你可以从容地进行调试不用担心由于主线程的退出而导致运行Graal的线程也退出。
现在你可以跟踪Graal的编译过程看看能发现些什么。在这个过程中你需要一点耐心慢慢理解整个代码结构。
Graal执行过程的主要结构如下图所示。
图3Graal执行过程的主要结构
首先你会发现在编译开始的时候Graal编译器要把字节码转化成一个图的数据结构。而后续的编译过程都是对这个图的处理。这说明了这个图很重要而这个图就是Graal要用到的IR在Graal编译器中它也被叫做HIR。
接着你会看到整个编译过程被分成了前端和后端两个部分。前端部分使用的IR是HIR。而且在前端部分HIR又分成了高HighTier、中MidTier、低LowTier三层。在每个层次里都要执行很多遍Phase对图的处理。这些处理指的就是各种的优化和处理算法。而从高到低过渡的过程就是不断Lower的过程也就是把IR中较高抽象度的节点替换成了更靠近底层实现的节点。
在后端部分则要生成一种新的IR也就是我们在第6讲中提到过的LIR并且Graal也要对它进行多遍处理。最后一步就是生成目标代码。
下图中,我举了一个例子,列出了编译器在前端的三个层次以及在后端所做的优化和处理工作。
你要注意的是,在编译不同的方法时,所需要的优化工作也是不同的,具体执行的处理也就不一样。并且这些处理执行过程也不是线性执行的,而可能是一个处理程序调用了另一个处理程序,嵌套执行的。
图4一个例子前端三个层次和后端所做的处理
不过通过跟踪Graal的运行过程你可以留下一个直观的印象Graal编译器的核心工作就是对图IR的一遍遍处理。
在下一讲中我就会进一步讲述Graal的IR并会带你一起探讨优化算法的实现过程你可以了解到一个真实编译器的IR是怎样设计的。
课程小结
今天这一讲我带你大致了解了Java的JIT编译器。你需要重点关注以下几个核心要点
JIT可能会用到多个编译器有的编译速度快但不够优化比如C1客户端编译器有的够优化但编译速度慢比如C2服务端编译器所以在编译过程中会结合起来使用。
你还需要理解逆优化的概念,以及导致逆优化的原因。
另外我还带你了解了Graal这个用Java编写的Java JIT编译器。最重要的是通过查看它的代码、修改代码、运行和调试的过程你能够建立起对Graal编译器的亲切感不会觉得这些技术都是很高冷的不可接近的。当你开始动手修改的时候你就踏上了彻底掌握它的旅途。
你要熟练掌握调试方法并且熟练运用GraalVM的很多参数这会有利于你去做很多实验来深入掌握Graal。
本讲的思维导图我放在这里了,供你参考:
一课一思
你能否把示例程序的add函数改成一个需要计算量的函数然后你可以比较一下看看JIT前后性能相差了多少倍通过这样的一个例子你可以获得一些感性认识。
有相关的问题或者是思考呢,你都可以给我留言。如果你觉得有收获,你也可以把今天的内容分享给更多的朋友。
参考资料
GraalVM项目的官方网站graalvm.org。
Graal的Github地址。
Graal项目的出版物。有很多围绕这个项目来做研究的论文值得一读。

View File

@ -0,0 +1,288 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 Java JIT编译器Sea of Nodes为何如此强大
你好我是宫文学。这一讲我们继续来研究Graal编译器重点来了解一下它的IR的设计。
在上一讲中我们发现Graal在执行过程中创建了一个图的数据结构这个数据结构就是Graal的IR。之后的很多处理和优化算法都是基于这个IR的。可以说这个IR是Graal编译器的核心特性之一。
那么为什么这个IR采用的是图结构它有什么特点和优点编译器的优化算法又是如何基于这个IR来运行的呢
今天我就带你一起来攻破以上这些问题。在揭晓问题答案的过程中你对真实编译器中IR的设计和优化处理过程也就能获得直观的认识了。
基于图的IR
IR对于编译器非常重要因为它填补了高级语言和机器语言在语义上的巨大差别。比如说你在高级语言中是使用一个数组而翻译成最高效的x86机器码是用间接寻址的方式去访问一块连续的内存。所以IR的设计必须有利于实现这种转换并且还要有利于运行优化算法使得生成的代码更加高效。
在上一讲中通过跟踪Graal编译器的执行过程我们会发现它在一开始就把字节码翻译成了一种新的IR这个IR是用图的结构来表示的。那这个图长什么样子呢非常幸运的是我们可以用工具来直观地看到它的结构。
你可以从Oracle的网站上下载一个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
图1After parsing
图2After mid tier
Graal IR其实受到了“程序依赖图”的影响。我们在第6讲中提到过程序依赖图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 GraphCFG来表示的比如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一开始根本没有归属到特定的基本块所以也就没有必要专门去做代码的移动了。
另外我们之前讲本地优化和全局优化的时候也提到过它们的区别就是在整个函数范围内优化的范围是在基本块内还是会跨基本块。而Sea of Nodes没有过于受到基本块的束缚因此也就更容易做全局优化了。
那在概要地理解了Graal IR的数据结构之后接下来我们就具体了解一下Graal IR包括认识一下数据流与控制流的特点了解两种不同的节点浮动节点和固定节点以及认识一种特殊的节点FrameState。
数据流和控制流
我们已经知道Graal IR整合了两种图结构数据流图和控制流图。
首先,我们来看看它的数据流。
在下图中蓝色的边代表的是数据流也就是数据之间的依赖关系。参数1“P(0)”节点和参数2“P(1)”节点)的值流入到+号节点再流入到Return节点。
图3Foo.add()的数据流
在Graal IR的设计中Add节点有两个输入分别是x和y这两个输入是AddNode的两个属性。注意这个图中的箭头方向代表的是数据依赖关系也就是Add节点保持着对它的两个输入节点的引用这其实跟AST是一致的。而数据流向则是反过来的从x和y流向Add节点。
图4Add节点的数据依赖关系
查看AddNode的设计你会发现其父类中有两个成员变量x和y。它们用@input做了注解,这就意味着,这两个成员变量代表的是数据流图中的两条边。
图5Add节点及其各级父节点
另外Graal IR的数据流图是符合SSA格式的。也就是说每个节点代表了SSA中的一个值它只被定义一次也就相当于SSA中的每个变量只被赋值一次。
我们再来看看控制流。
下图中,红色的边代表的是控制流,控制流图代表的是程序执行方向的改变。进入或退出一个函数、条件分支语句、循环语句等,都会导致程序的执行从一个地方跳到另一个地方。
图6Foo.add()的控制流
数据流加上控制流就能完整表达程序的含义它等价于字节码也等价于更早期的AST。你可以从Start节点沿着控制流遍历这个图。当到达Return节点之前Return所依赖的数据x+y也需要计算出来。
add()方法的控制流很简单只有Start和Return两个节点。我们做一个稍微复杂一点的例子在Foo.add2()示例程序中调用两个函数getX()和getY()分别获取x和y成员变量。
public int add2(){
return getX() + getY();
}
对应的Graal图如下。它增加了两个节点分别是调用方法getX和getY这就导致了控制流发生变化。
图7Foo.add2()对应的IR
注意对于这个例子在使用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节点合并起来。
图8doif()方法对应的IR
IfNode作为一种控制流节点它保存着对下级节点的引用并用@Successor注解来标注。这意味着trueSuccessor和falseSuccessor两个成员变量代表着控制流中的两条边。当然你也会注意到If节点有一个数据流的输入这就是If的判断条件。IR会基于这个判断条件来决定控制流的走向。
图9IfNode及其各级父节点
跟控制流类似数据流也产生了两个分支分别是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节点。
图10Foo.add()中的FrameState节点
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图。
图11对Foo.add()所做的处理
在这些处理阶段的名称中你会看到我们在第7讲中提到的一些代码优化算法的名称如死代码删除。有了前面课程的铺垫你现在看它们应该就消除了一些陌生感。
另外你会发现在这些处理阶段中有一个Canonicalizer的阶段出现了好几次并且你可能对这个词也比较陌生所以下面我们不妨来看看这个阶段都做了些什么。
规范化Canonicalizer
Canonicalize的意思是规范化。如果某段程序有多种写法那么编译器会处理成一种统一的、标准的写法。
比如对于下面这个简单的函数它是把a乘以2。在CanonicalizerPhase运行之后乘法运算被替换成了移位运算也就是a<<1它的效果与乘以2是相同的但运行效率更高
public int doDouble(int a){
return 2*a;
}
图12未做规范化优化之前是乘法运算
图13做完规范化优化之后变成移位运算
你还可以试一下对某个变量取两次负号的操作。在规范化阶段以后两个负号就会被去掉直接返回a。
public int negneg(int a){
return -(-a);
}
规范化需要的操作都是对本节点进行修改和替换一般都不太复杂。某节点如果实现了Canonicalizable接口在CanonicalizerPhase就会对它做规范化。
在规范化阶段实现的优化算法包括常数折叠Constant Folding、强度折减Strength reduction、全局值编号Global Value NumberingGVN等等。它们的原理我在第7讲都介绍过这里就不赘述了。
课程小结
这一讲我给你介绍了Graal的IR它整合了控制流图与数据流图符合SSA格式有利于优化算法的编写和维护。
我还带你了解了对IR的一个优化处理过程规范化。规范化所需要的操作一般并不复杂它都是对本节点进行修改和替换。在下一讲中我会带你分析另外两个重要的算法内联和逃逸分析。
另外Graal的IR格式是声明式的Declarative它通过描述一个节点及其之间的关系来反映源代码的语义。而我们之前见到的类似三地址代码那样的格式是命令式的Imperative它的风格是通过命令直接告诉计算机来做一个个的动作。
声明式和命令式是编程的两种风格在Graal编译器里我们可以看到声明式的IR会更加简洁对概念的表达也更加清晰。我们在后面介绍MySQL编译器的实现机制当中在讲DSL的时候还会再回到这两个概念到时你还会有更加深刻的认识。
本讲的思维导图我也放在了这里,供你参考:
一课一思
了解了Graal IR的特点以后通过对比我们在第7讲中学过的优化算法你觉得哪些优化算法在Graal IR上实现起来会更方便为什么欢迎在留言区分享你的看法。
如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
参考资料
基于图的IR有三篇论文必须提到
程序依赖图J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization. July 1987。有关程序依赖图的概念在1987年就提出来了。
Click的论文A Simple Graph-Based Intermediate Representation。这篇文章比较易读属于必读文献。Click还发表了一些论文讲述了基于图的IR上的优化算法。
介绍Graal IR的论文Graal IR: An Extensible Declarative Intermediate Representation。这篇论文也很易读建议你一定要读一下。

View File

@ -0,0 +1,278 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 Java JIT编译器探究内联和逃逸分析的算法原理
你好,我是宫文学。
基于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();
}
图1将getter方法内联
在做了Lower处理以后LoadField会被展开成更底层的操作根据x和y的地址相对于对象地址的偏移量获取x和y的值。
图2计算字段x和y的地址
而要想正确地计算字段的偏移量我们还需要了解Java对象的内存布局。
在64位平台下每个Java对象头部都有8字节的标记字里面有对象ID的哈希值、与内存收集有关的标记位、与锁有关的标记位标记字后面是一个指向类定义的指针在64位平台下也是8位不过如果堆不是很大我们可以采用压缩指针只占4个字节在这后面才是x和y字段。因此x和y的偏移量分别是12和16。
图3内存中Java对象头占据的空间
在Low Tier编译完毕以后图2还会进一步被Lower形成AMD64架构下的地址。这样的话编译器再进一步翻译成汇编代码就很容易了。
图4生成AMD64架构的地址计算节点
内联优化除了会优化getter、setter这样的短方法它实际上还起到了另一个重要的作用即跨过程的优化。一般的优化算法只会局限在一个方法内部。而启动内联优化后多个方法会合并成一个方法所以就带来了更多的优化的可能性。
我们来看看下面这个inlining示例方法。它调用了一个atLeastTen方法这个方法对于
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图就是常规的方法调用而已
图5不启用内联时调用atLeastTen()方法
而一旦启用了内联选项就可以触发一系列的优化。在把字节码解析生成IR的时候编译器就启动了内联分析过程从而会发现this参数和常量3对于inliningTest方法根本是无用的在图里表现成了一些孤岛。在Mid Tier处理完毕之后inliningTest方法就直接返回常量10了。
图6启用内联后调用atLeastTen()方法
另外方法的类型也会影响inlining。如果方法是final的或者是private的那么它就不会被子类重载所以可以大胆地内联。
但如果存在着多重继承的类体系方法就有可能被重载这就会导致多态。在运行时JVM会根据对象的类型来确定到底采用哪个子类的具体实现。这种运行时确定具体方法的过程叫做虚分派Virtual Dispatch
在存在多态的情况下JIT编译器做内联就会遇到困难了。因为它不知道把哪个版本的实现内联进来。不过编译器仍然没有放弃。这时候所采用的技术就叫做“多态内联Polymorphic inlining”。
它的具体做法是,在运行时,编译器会统计在调用多态方法的时候,到底用了哪几个实现。然后针对这几个实现,同时实现几个分支的内联,并在一开头根据对象类型判断应该走哪个分支。这个方法的缺陷是生成的代码量会比较大,但它毕竟可以获得内联的好处。最后,如果实际运行中遇到的对象,与提前生成的几个分支都不匹配,那么编译器还可以继续用缺省的虚分派模式来做函数调用,保证程序不出错。
这个案例也表明了JIT编译器是如何充分利用运行时收集的信息来做优化的。对于AOT模式的编译来说由于无法收集到这些信息因此反倒无法做这种优化。
如果你想对多态内联做更深入的研究还可以参考这一篇经典论文《Inlining of Virtual Methods》。
总结起来,内联优化不仅能降低由于函数调用带来的开销,还能制造出新的优化机会,因此带来的优化效果非常显著。接下来,我们看看另一个能带来显著优化效果的算法:逃逸分析。
逃逸分析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图会发现它变成了下面的这个样子
图7对Person对象做标量替换
你会看到编译器连Person的ageSegement方法也内联进来了。最后优化后的函数相当于
public int escapeTest(int age){
return age<20 ? 1 : 2;
}
图7中的Conditional是一个条件计算的三元表达式。你看到这个优化结果的时候有没有感到震惊是的。善用编译器的优化算法就是会达到如此程度的优化。优化前后的代码的功能是一样的但优化后的代码变得如此简洁、直奔最终计算目标忽略中间我们自己编程所带来的冗余动作。
上面讲的都是没有逃逸出方法的情况。这种情况被叫做NoEscape。还有一种情况是虽然逃逸出了方法但没有逃逸出当前线程也就是说不可能被其他线程所访问这种逃逸叫做ArgEscape也就是它仅仅是通过方法的参数逃逸的。最后一种情况就是GlobalEscape也就是能够被其他线程所访问因此没有办法优化。
对于ArgEscape的情况虽然编译器不能做内存的栈上分配但也可以做一定的优化这就是锁消除或者同步消除。
我们知道,在并发场景下,锁对性能的影响非常之大。而很多线程安全的对象,比如一些集合对象,它们的内部都采用了锁来做线程的同步。如果我们可以确定这些对象没有逃逸出线程,那么就可以把这些同步逻辑优化掉,从而提高代码的性能。
好了现在你已经理解了逃逸分析的用途。那么逃逸分析的算法是怎么实现的呢这方面你可以去参考这篇经典论文《Escape Analysis for Java》。论文里的算法利用了一种数据结构叫做连接图Connection Graph。简单地说就是分析出了程序中对象之间的引用关系。
整个分析算法是建立在这样一种直觉认知上的基于一个连接图也就是对象之间的引用关系图如果A引用了B而A能够被线程外的程序所访问线程逃逸那么也就意味着B也是线程逃逸的。也就是说逃逸性是有传染能力的。通过这样的分析那些完全没被传染的对象就是NoEscape的只被ArgEscape对象传染的那就也是ArgEscape的。原理说起来就是这么简单。
另外,我们前面所做的分析都是静态分析,也就是基于对代码所做的分析。对于一个对象来说,只要存在任何一个控制流,使得它逃逸了,那么编译器就会无情地把它标注为是逃逸对象,也就不能做优化了。但是,还会出现一种情况,就是有可能这个分支的执行频率特别少,大部分情况下该对象都是不逃逸的。
所以Java的JIT编译器实际上又向前迈进了一步实现了部分逃逸分析Partial Escape Analysis。它会根据运行时的统计信息对不同的控制流分支做不同的处理。对于没有逃逸的分支仍然去做优化。在这里你能体会到编译器为了一点点的性能提升简直无所不用其极呀。
如果你还想对部分逃逸分析做进一步的研究那你可以参考这篇论文《Partial Escape Analysis and Scalar Replacement for Java》。
总结起来,逃逸分析能够让对象在栈上申请内存,做标量替换,从而大大减少对象处理的开销。这个算法对于对象生命周期比较短的场景,优化效果是非常明显的。
在讲内联和逃逸算法的时候,我们都发现,编译器会根据运行时的统计信息,通过推断来做一些优化,比如多态内联、部分逃逸分析。而这种优化模式,就叫做基于推理的优化。
基于推理的优化Speculative Optimization
我刚刚说过一般情况下编译器的优化工作是基于对代码所做的分析也就是静态分析。而JIT编译还有一个优势就是会根据运行时收集的统计信息来做优化。
我还是以Foo.atLeastTen()方法举例。在正常情况下它生成的HIR是下面的样子根据条件表达式的值a
图8基于静态分析编译atLeastTen()方法
而如果我们在主程序里调用atLeastTen方法是采用下面示例代码中的逻辑在启动JIT编译器时已经循环了上万次。而在这上万次当中只有9次i的值是小于10的那么编译器就会根据运行时的统计信息判断i的值大概率是大于10的。所以它会仅针对大于10的分支做编译。
而如果遇到小于10的情况则会导致逆优化。你会看到IR中有一个绿色的FrameState节点这个节点保存了栈帧的状态在逆优化时会被用到。
int i = 0;
while(true){
i++;
foo.atLeastTen(i);
...
}
图9基于推理优化只编译if语句的第1个分支返回10
我们把主程序修改一下再做一次实验。这次我们传给Foo.atLeastTen方法的参数是i%10也就是参数a的取值范围永远是在0到9之间。这一次JIT编译器会反过来仅针对a小于10的分支做编译而对a大于10的情况做逆优化处理。
int i = 0;
while(true){
i++;
foo.atLeastTen(i%10);
...
}
图10基于推理优化只编译if语句的第2个分支返回a
通过这个简单的例子你对JIT编译器基于推理的优化情况就有了一个直观的了解。对于atLeastTen这个简单的方法这样的优化似乎并不会带来太多的好处。但对于比较复杂的程序那既可以节省编译时间也能减少编译后的代码大小收益是很高的。比如对于程序中仅用于输出Debug信息的分支就根本不需要生成代码。
另外,这种基于推理的优化,还会带来其他额外的优化机会。比如对于逃逸分析而言,去掉了一些导致逃逸的分支以后,在剩下的分支中,对象并没有逃逸,所以也就可以做优化了!
总结起来基于运行时的统计信息进行推理的优化有时会比基于静态分析的AOT产生出性能更高的目标代码。所以现代编译技术的实践会强调“全生命周期”优化的概念。甚至即使是AOT产生的目标代码仍然可以在运行期通过JIT做进一步优化。LLVM项目的发起人之一Chris Lattner就曾写了一篇论文来提倡这个理念这也是LLVM的设计目标之一。
课程小结
今天我带你了解了Java JIT编译器中两个重要的优化算法这两个优化算法都会大大提升程序的执行效率。另外你还了解了在JIT编译中基于运行时的信息做推理优化的技术。
在课程中,我不可能带你去分析所有的优化算法,但你可以根据课程的内容去举一反三,研究一下里面的其他算法。如果你对这些算法掌握得比较清晰,那你就可以胜任编译器的开发工作了。因为编译器开发的真正的工作量,都在中后端。
另外,熟悉这些重要的优化算法的原理,还有助于你写出性能更高的程序。比如说,你要让高频使用的代码易于内联;在使用对象的时候,让它的作用范围清晰一些,不要做无用的关联,尽量不要逃逸出方法和线程之外,等等。
本讲的思维导图我也放在下面了,供你参考。
一课一思
今天的思考题,还是想请你设计一个场景,测试内联 vs 无内联,或者做逃逸优化 vs 无逃逸优化的性能差异。借此你也可以熟悉一下如何控制JVM的优化选项。欢迎在留言区分享你在测试中的发现。
关闭内联优化: -XX:-Inlining。JDK8缺省是打开的。-
显示内联优化详情:-XX:+PrintInlining。-
关闭逃逸分析:-XX:-DoEscapeAnalysis。JDK8缺省是打开的。-
显示逃逸分析详情:-XX:+PrintEscapeAnalysis。-
关闭标量替换:-XX:-EliminateAllocations。JDK8缺省是打开的。-
显示标量替换详情:-XX:+PrintEliminateAllocations。
参考资料
多态内联Inlining of Virtual Methods。
逃逸分析Escape Analysis for Java。
部分逃逸分析Partial Escape Analysis and Scalar Replacement for Java。

View File

@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 Java JIT编译器Graal的后端是如何工作的
你好,我是宫文学。
前面两讲中我介绍了Sea of Nodes类型的HIR以及基于HIR的各种分析处理这可以看做是编译器的中端。
可编译器最终还是要生成机器码的。那么这个过程是怎么实现的呢与硬件架构相关的LIR是什么样子的呢指令选择是怎么做的呢
这一讲我就带你了解Graal编译器的后端功能回答以上这些问题破除你对后端处理过程的神秘感。
首先,我们来直观地了解一下后端处理的流程。
后端的处理流程
在第14讲中我们在运行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来查看。
补充c1visualizer原本是用于查看Hopspot的C1编译器也就是客户端编译器的LIR的工具这也就是说Graal的LIR和C1的是一样的。另外该工具不能用太高版本的JDK运行我用的是JDK1.8。
图1atLeatTen()方法对应的LIR
在窗口的左侧,你能看到后端的处理流程。
首先是把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有关的类。你可以重点看看它们之间的相互关系。
图2HIR、LIR和CFG的关联关系
在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的子类主要放在三个包中你可以看看下面的类图。
图3LIR中的指令类型
首先在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节点。这个例子足够简单。实际上它简单到只是一棵树而不是图。
图4add1方法对应的HIR
你可以想一下,对于这么简单的一棵树,编译器要如何生成指令呢?
最简单的方法,是做一个语法制导的简单翻译。我们可以深度遍历这棵树,针对不同的节点,分别使用不同的规则来生成指令。比如:
在遇到参数节点的时候,我们要搞清楚它的存放位置。因为参数要么是在寄存器中,要么是在栈中,可以直接用于各种计算。
遇到常数节点的时候,我们记下这个常数,用于在下一条指令中作为立即数使用。
在遇到加法节点的时候生成一个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 #返回
补充:-
1.我去掉了一些额外的汇编代码比如用于跟JVM握手让JVM有机会做垃圾收集的代码。-
2. lea指令原本是用于计算地址的。上面的指令的意思是把rsi寄存器的值作为地址然后再偏移10个字节把新的地址放到eax寄存器。-
x86计算机支持间接寻址方式偏移量基址索引值字节数-
其地址是:基址 + 索引值*字节数 + 偏移量-
所以你可以利用这个特点计算出a+b*c+d的值。但c也就是字节数只能取1、2、4、8。就算让c取1那也能完成a+b+c的计算。并且它还可以在另一个操作数里指定把结果写到哪个寄存器而不像add指令只能从一个操作数加到另一个操作数上。这些优点使得x86汇编中经常使用lea指令做加法计算。
Graal编译器实际上大致也是这么做的。
首先它通过Schedule的步骤把HIR的节点排序并放入基本块。对于这个简单的程序只有一个基本块。
接着编译器会对基本块中的节点做遍历参考NodeLIRBuilder.java中的代码。针对每个节点转换Lower成LIR。
把参数节点转换成了MoveFromRegOp指令在示例程序里其实这是冗余的因为可以直接把存放参数的两个寄存器用于加法计算
把第一个加法节点转换成了CommutativeTwoOp指令
把第二个加法节点转换成了LeaOp指令并且把常数节点变成了LeaOp指令的操作数
Return节点生成了两条指令一条是把加法运算的值放到rax寄存器作为返回值这条我们知道是冗余的所以就要看看后面的优化算法如何消除这个冗余第二条是返回指令。
一开始生成的LIR使用的寄存器都是虚拟的寄存器名称用v1、v2、v3这些来表示。等把这些虚拟的寄存器对应到具体的物理寄存器以后就可以消除掉刚才提到的冗余代码了。
我们在c1visualizer中检查一下优化过程可以发现这是在LinearScanAssignLocationsPhase做的优化。加法指令中对参数1和参数2的引用变成了对物理寄存器的引用从而优化掉了两条指令。lea指令中的返回值也直接赋给了rax寄存器。这样呢也就省掉了把计算结果mov到rax的指令。这样优化后的LIR基本上已经等价于目标代码了。
好了通过这样一个分析你应该理解了从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是个静态成员所以它的地址要通过类的地址加上一定的偏移量来计算。
图5addMemory()方法对应的HIR
这里有一个小的知识点我在第14讲中也提过对内存操作的节点如图中的ReadNode是要加入控制流中的。因为内存里的值会由于别的操作而改变。如果你把它变成浮动节点就有可能破坏对内存读写的顺序从而出现错误。
回到主题我们来看看怎么为addMemory生成LIR。
如果还是像处理add1方法一样那么你就会这么做
计算m变量的地址并放入一个寄存器
基于这个地址取出m的值放入另一个寄存器
把m的值和参数a做加法。
不过这样做至少要生成3条指令。
在第8讲中我曾经讲过像AMD64这样使用复杂指令集CICS的架构具有强大的地址表达能力并且可以在做算术运算的时候直接使用内存。所以上面的三条指令其实能够缩减成一条指令。
这就需要编译器把刚才这种基于内存访问做加法的模式识别出来以便生成优化的LIR进而生成优化的目标代码。这也是指令选择算法要完成的任务。可是如何识别这种模式呢
跟踪Graal的执行你会发现HIR在生成LIR之前有一个对基本块中的节点做模式匹配的操作进而又调用匹配复杂表达式matchComplexExpressions。在这里编译器会把节点跟一个个匹配规则MatchStatement做匹配。注意匹配的时候是逆序做的相当于从树根开始遍历。
在匹配加法节点的时候Graal匹配上了一个MatchStatement这个规则的名字叫“addMemory”是专门针对涉及内存操作的加法运算提供的一个匹配规则。这个MatchStatement包含了一个匹配模式MatchPattern该模式的要求是
节点类型是AddNode
第一个输入也就是子节点是一个值节点value
第二个输入是一个ReadNode而且必须只有一个使用者singleUser=true
图6匹配规则和匹配模式
这个MatchStatement是在AMD64NodeMatchRules中用注解生成的。利用这样的一个匹配规则就能够匹配示例程序中的Add节点。
匹配上以后Graal会把AddNode和ReadNode做上特殊标记这样在生成LIR的时候就会按照新的生成规则。生成的LIR如下
你可以发现优化后编译器把取参数a的指令省略掉了直接使用了传入参数a的寄存器rsi
最后生成的目标代码如下:
movabs rax,0x797b00690 #把Foo类的地址放入rax寄存器
add esi,DWORD PTR [rax+0x68] #偏移0x68后是m的地址。做加法
mov eax,esi #设置返回值
ret #返回
到目前为止你已经了解了Graal是如何匹配一个模式并选择优化的指令的了。
你可以看看AMD64NodeMatchRules类它的里面定义了不少这种匹配规则。通过阅读和理解这些规则你就会对为什么要做指令选择有更加具体的理解了。
Graal的指令选择算法算是比较简单的。在HotSpot的C2编译器中指令选择采用的是BURSBottom-Up Rewrite System自底向上的重写系统。这个算法会更加复杂一点消耗的时间更长但优化效果更好一些。
这里我补充一个分享我曾经请教过ARM公司的研发人员他们目前认为Graal对针对AArch64的指令选择是比较初级的你可以参考这个幻灯片。所以他们也正在帮助Graal做改进。
后端的其他功能
出于突出特色功能的目的这一讲我着重讲了LIR的特点和指令选择算法。不过在考察编译器的后端的时候我们通常还要注意一些其他功能比如寄存器分配算法、指令排序等等。我这里就把Graal在这些功能上的实现特点给你简单地介绍一下你如果有兴趣的话可以根据我的提示去做深入了解
寄存器分配Graal采用了线性扫描Linear Scan算法。这个算法的特点是速度比较快但优化效果不如图染色算法。在HotSpot的C2中采用的是后者。
指令排序Graal没有为了实现指令级并行而去做指令排序。这里一个主要原因是现在的很多CPU都已经支持乱序out-of-order执行再做重排序的收益不大。
窥孔优化Graal在生成LIR的时候会做一点窥孔优化AMD64NodeLIRBuilder类的peephole方法。不过它的优化功能有限只实现了针对除法和求余数计算的一点优化。
从LIR生成目标代码由于LIR已经跟目标代码很接近了所以这个翻译过程已经比较简单没有太难的算法了需要的只是了解和熟悉汇编代码和调用约定。
课程小结
这一讲我带你对Graal的后端做了一个直观的认识让你的后端知识有了第一个真实世界中编译器的参考系。
第一把LIR从比较抽象的概念中落地。你现在可以确切地知道哪些指令是必须跟架构相关的而哪些指令可以跟架构无关。
第二把指令选择算法落地。虽然Graal的指令选择算法并不复杂但这毕竟提供了一个可以借鉴的思路是你认知的一个阶梯。如果你仔细阅读代码你还可以具象地了解到符合哪些模式的表达式是可以从指令选择中受益的。这又是一个理论印证实践的点。
我把这讲的思维导图也放在了下面,供你参考。
同时这一讲之后我们对Java编译器的探讨也就告一段落了。但是我希望你对它的研究不要停止。
我们讨论的两个编译器javac和Graal中的很多知识点你只要稍微深入挖掘一下就可以得出不错的成果了。比如我看到有国外的硕士学生研究了一下HotSpot就可以发表不错的论文。如果你是在校大学生我相信你也可以通过顺着这门课程提供的信息做一些研究从而得到不错的成果。如果是已经工作的同学我们可以在极客时间的社群比如留言区和部落里保持对Java编译技术的讨论也一定会对于你的工作有所助益。
一课一思
请你阅读AMD64NodeMatchRules中的匹配规则自己设计另一个例子能够测试出指令选择的效果。如果降低一下工作量的话你可以把它里面的某些规则解读一下在留言区发表你的见解。
好,就到这里。感谢你的阅读,欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。

View File

@ -0,0 +1,300 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 Python编译器如何用工具生成编译器
你好,我是宫文学。
最近几年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网站下载3.8.1版本的源代码。解压后你可以先自己浏览一下,看看能不能找到它的词法分析器、语法分析器、符号表处理程序、解释器等功能的代码。
Python源代码划分了多个子目录每个子目录的内容整理如下
首先你会发现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的“开发者指南”网站上有不少关于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命令打印输出后整理的结果
这个过程是运行Python并执行到词法分析环节你可以看到完整的程序执行路径
首先是python.c这个文件很短只是提供了一个main()函数。你运行python命令的时候就会先进入这里。
接着进入Modules/main.c文件这个文件里提供了运行环境的初始化等功能它能执行一个python文件也能启动REPL提供一个交互式界面。
之后是Python/pythonrun.c文件这是Python的解释器它调用词法分析器、语法分析器和字节码生成功能最后解释执行。
再之后来到Parser目录的parsetok.c文件这个文件会调度词法分析器和语法分析器完成语法分析过程最后生成AST。
最后是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
其中的第二列是Token的类型第三列是Token对应的字符串。各种Token类型的定义你可以在Grammar/Tokens文件中找到。
我们曾在研究Java编译器的时候探讨过如何解决关键字和标识符的词法规则冲突的问题。那么Python是怎么实现的呢
原来Python在词法分析阶段根本没有区分这两者只是都是作为“NAME”类型的Token来对待。
补充Python里面有两个词法分析器一个是用C语言实现的tokenizer.c一个是用Python实现的tokenizer.py。C语言版本的词法分析器由编译器使用性能更高。
所以Python的词法分析功能也比较常规。其实你会发现每个编译器的词法分析功能都大同小异你完全可以借鉴一个比较成熟的实现。Python跟Java的编译器稍微不同的一点就是没有区分关键字和标识符。
接下来,我们来关注下这节课的重点内容:语法分析功能。
Python的语法分析功能
在GDB中继续跟踪执行过程你会在parser.c中找到语法分析的相关逻辑
那么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使用的。这个程序能够基于语法规则生成解析表Parse Table供语法分析程序使用。有很多工具能帮助你生成语法解析器包括yaccGNU版本是bison、ANTLR等。
有了pgen这个工具你就可以通过修改规则文件来修改Python语言的语法比如你可以把函数声明中的关键字“def”换成“function”这样你就可以用新的语法来声明函数。
pgen能给你生成新的语法解析器。parser.c的注释中讲解了它的工作原理。它是把EBNF转化成一个NFA然后再把这个NFA转换成DFA。基于这个DFA在读取Token的时候编译器就知道如何做状态迁移并生成解析树。
这个过程你听上去是不是有点熟悉实际上我们在第2讲讨论正则表达式工具的时候就曾经把正则表达式转化成了NFA和DFA。基于这个技术我们既可以做词法解析也可以做语法解析。
实际上Python用的是LL(1)算法。我们来回忆一下LL(1)算法的特点针对每条语法规则最多预读一个Token编译器就可以知道该选择哪个产生式。这其实就是一个DFA从一条语法规则根据读入的Token迁移到下一条语法规则。
我们通过一个例子来看一下Python的语法分析特点这里采用的是我们熟悉的一个语法规则
add: mul ('+' mul)*
mul: pri ('*' pri)*
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。
第2步根据add的DFA走mul-1这条边去匹配mul规则。这时把mul对应的DFA入栈。在示意图中栈是从上往下延伸的。
第3步根据mul的DFA走pri-1这条边去匹配pri规则。这时把pri对应的DFA入栈。
第4步根据pri的DFA因为预读的Token是字面量2所以移进这个字面量并迁移到状态3。同时为字面量2建立解析树的节点。这个时候又会预读下一个Token'+'号。
第5步从栈里弹出pri的DFA并建立pri节点。因为成功匹配了一个pri所以mul的DFA迁移到状态1。
第6步因为目前预读的Token是'+'号所以mul规则匹配完毕把它的DFA也从栈里弹出。而add对应的DFA也迁移到了状态1。
第7步移进'+'号把add的DFA迁移到状态2预读了下一个Token字面量3。这个Token是在mul的First集合中的所以就走mul-2边去匹配一个mul。
按照这个思路继续做解析,直到最后,可以得到完整的解析树:
总结起来Python编译器采用了一个通用的语法分析程序以一个栈作为辅助的数据结构来完成各个语法规则的解析工作。当前正在解析的语法规则对应的DFA位于栈顶。一旦当前的语法规则匹配完毕那语法分析程序就可以把这个DFA弹出退回到上一级的语法规则。
所以说语法解析器生成工具会基于不同的语法规则来生成不同的DFA但语法解析程序是不变的。这样你随意修改语法规则都能够成功解析。
上面我直观地给你解读了一下解析过程。你可以用GDB来跟踪一下PyParser_AddToken()函数,从而了解得更具体。你在这个函数里,还能够看到像下面这样的语句,这是对外输出调试信息。
D(printf(" Push '%s'\n", d1->d_name)); //把某DFA入栈
你还可以用“-d”参数运行python然后在REPL里输入程序这样它就能打印出这些调试信息包括什么时候把DFA入栈、什么时候出栈等等。我截取了一部分输出信息你可以看一下。
在Python的语法规则里arith_expr指的是加减法的表达式term指的是乘除法的表达式atom指的是基础表达式。这套词汇也经常被用于语法规则中你可以熟悉起来。
好了现在你已经知道了语法解析的过程。不过你可能注意到了上面的语法解析过程形成的结果我没有叫做是AST而是叫做解析树Parse Tree。看到这里你可能会产生疑问解析源代码不就会产生AST吗怎么这里是生成一个叫做解析树的东西什么是解析树它跟AST有啥区别别着急下面我就来为你揭晓答案。
解析树和AST的区别
解析树又可以叫做CSTConcrete Syntax Tree具体语法树与AST抽象语法树是相对的一个具体一个抽象。
它俩的区别在于CST精确地反映了语法规则的推导过程而AST则更准确地表达了程序的结构。如果说CST是“形似”那么AST就是“神似”。
你可以看看在前面的这个例子中所形成的CST的特点。
首先加法是个二元运算符但在这里add节点下面对应了两个加法运算符跟原来加法的语义不符。第二很多节点都只有一个父节点这个其实可以省略让树结构更简洁。
所以我们期待的AST其实是这样的
这就是CST和AST的区别。
理解了这个知识点以后我们拿Python实际的CST和AST来做一下对比。在Python的命令行中输入下面的命令
>>> from pprint import pprint
>>> import parser
>>> cst = parser.expr('2+3+4') //对加法表达式做解析
>>> pprint(parser.st2list(cst)) //以美观的方式打印输出CST
你会得到这样的输出结果:
这是用缩进的方式显示了CST的树状结构其中的数字是符号和Token的编号。你可以从Token的字典dict里把它查出来从而以更加直观的方式显示CST。
我们借助一个lex函数来做美化的工作。现在再显示一下CST就更直观了
那么Python把CST转换成AST会是什么样子呢
你可以在命令行敲入下面的代码来显示AST。它虽然是以文本格式显示的但你能发现它是一个树状结构。这个树状结构就很简洁
如果你嫌这样不够直观还可以用另一个工具“instaviz”在命令行窗口用pip命令安装instaviz模块以图形化的方式更直观地来显示AST。instaviz是“Instant Visualization”立即可视化的意思它能够图形化显示AST。
$ pip install instaviz
然后启动Python并敲入下面的代码
instaviz会启动一个Web服务器你可以在浏览器里通过http://localhost:8080来访问它里面有图形化的AST。你可以看到这个AST比起CST来确实简洁太多了。
点击代表“2+3*4+5”表达式的节点你可以看到这棵子树的各个节点的属性信息
总结起来在编译器里我们经常需要把源代码转变成CST然后再转换成AST。生成CST是为了方便编译器的解析过程。而转换成AST后会让树结构更加精简并且在语义上更符合语言原本的定义。
那么Python是如何把CST转换成AST的呢这个过程分为两步。
首先Python采用了一种叫做ASDL的语言来定义了AST的结构。ASDL是“抽象语法定义语言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同样反映了程序的结构但更简洁并且支持准确的语义。
本讲的思维导图我也放在这里了,供你参考:
一课一思
这一讲我们提到Python的词法分析器没有区分标识符和关键字但这样为什么没有影响到Python的语法分析的功能呢你可以结合语法规则文件和对语法解析过程的理解谈谈你的看法。如果你能在源代码里找到确定的答案那就更好了
欢迎你在留言区中分享你的见解,也欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
参考资料
GDB的安装和配置参考这篇文章。

View File

@ -0,0 +1,316 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 Python编译器从AST到字节码
你好,我是宫文学。
今天这一讲我们继续来研究Python的编译器一起来看看它是如何做语义分析的以及是如何生成字节码的。学完这一讲以后你就能回答出下面几个问题了
像Python这样的动态语言在语义分析阶段都要做什么事情呢跟Java这样的静态类型语言有什么不同
Python的字节码有什么特点生成字节码的过程跟Java有什么不同
好了让我们开始吧。首先我们来了解一下从AST到生成字节码的整个过程。
编译过程
Python编译器把词法分析和语法分析叫做“解析Parse并且放在Parser目录下。而从AST到生成字节码的过程才叫做“编译Compile”。当然这里编译的含义是比较狭义的。你要注意不仅是Python编译器其他编译器也是这样来使用这两个词汇包括我们已经研究过的Java编译器你要熟悉这两个词汇的用法以便阅读英文文献。
Python的编译工作的主干代码是在Python/compile.c中它主要完成5项工作。
第一步检查future语句。future语句是Python的一个特性让你可以提前使用未来版本的特性提前适应语法和语义上的改变。这显然会影响到编译器如何工作。比如对于“8/7”用不同版本的语义去处理得到的结果是不一样的。有的会得到整数“1”有的会得到浮点数“1.14285…”,编译器内部实际上是调用了不同的除法函数。
第二步,建立符号表。
第三步,为基本块产生指令。
第四步,汇编过程:把所有基本块的代码组装在一起。
第五步,对字节码做窥孔优化。
其中的第一步它是Python语言的一个特性但不是我们编译技术关注的重点所以这里略过。我们从建立符号表开始。
语义分析:建立符号表和引用消解
通常来说,在语义分析阶段首先是建立符号表,然后在此基础上做引用消解和类型检查。
而Python是动态类型的语言类型检查应该是不需要了但引用消解还是要做的。并且你会发现Python的引用消解有其独特之处。
首先我们来看看Python的符号表是一个什么样的数据结构。在Include/symtable.h中定义了两个结构分别是符号表和符号表的条目
图1符号表和符号表条目
在编译的过程中针对每个模块也就是一个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
这个示例程序有模块、类和函数三个级别的块。它们分别对应一条符号表条目。
图2示例程序对应的符号表
你可以看到每个块里都有ste_symbols字段它是一个字典里面保存了本命名空间涉及的符号以及每个符号的各种标志位flags。关于标志位我下面会给你解释。
然后,我们再看看针对这个示例程序,符号表里的主要字段的取值:
好了通过这样一个例子你大概就知道了Python的符号表是怎样设计的了。下面我们来看看符号表的建立过程。
建立符号表的主程序是Python/symtable.c中的PySymtable_BuildObject()函数。
Python建立符号表的过程需要做两遍处理如下图所示。
图3Python建立符号表的过程
第一遍主要做了两件事情。第一件事情是建立一个个的块也就是符号表条目并形成树状结构就像示例程序那样第二件事情就是给块中的符号打上一定的标记flag
我们用GDB跟踪一下第一遍处理后生成的结果。你可以参考下图看一下我在Python的REPL中的输入信息
我在symtable_add_def_helper()函数中设置了断点便于调试。当编译器处理到foo函数的时候我在GDB中打印输出了一些信息
在这些输出信息中,你能看到前面我给你整理的表格中的信息,比如,符号表中各个字段的取值。
我重点想让你看的是foo块中各个符号的标志信息self和b是20c是2a是16。这是什么意思呢
ste_symbols = {'self': 20, 'b': 20, 'c': 2, 'a': 16}
这就需要看一下symtable.h中对这些标志位的定义
我给你整理成了一张更容易理解的图,你参考一下:
图4符号标志信息中每个位的含义
根据上述信息你会发现self和b其实是被标记了3号位和5号位意思是这两个变量是函数参数并且在foo中被使用。而a只标记了5号位意思是a这个变量在foo中被使用但这个变量又不是参数所以肯定是来自外部作用域的。我们再看看cc只在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}
图5symtable.h中对作用域的标志位
以变量a为例它的标志值是6160也就是二进制的1100000010000。其标记位设置如下其作用域的标志位是3也就是说a是个隐式的全局变量。而self、b和c的作用域标志位都是1它们的意思是本地变量。
图6作用域的标志位
在第二遍的分析过程中Python也做了一些语义检查。你可以搜索一下Python/symtable.c的代码里面有很多地方会产生错误信息比如“nonlocal declaration not allowed at module level在模块级不允许非本地声明”。
另外Python语言提供了访问符号表的API方便你直接在REPL中来查看编译过程中生成的符号表。你可以参考我的屏幕截图
好了现在符号表已经生成了。基于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的在线文档里有对所有字节码的说明这里我就不展开了感兴趣的话你可以自己去看看。
并且Python和Java的虚拟机一样都是基于栈的虚拟机。所以它们的指令也很相似。比如加法操作的指令是不需要带操作数的因为只需要取出栈顶的两个元素相加把结果再放回栈顶就行了。
进一步,你可以对比一下这两种语言的字节码,来看看它们的异同点,并且试着分析一下原因。
这样对比起来你可以发现它们主要的区别就在于Java的字节码对不同的数据类型会提供不同的指令而Python则不加区分。因为Python对所有的数值都会提供统一的计算方式。
所以你可以看出一门语言的IR是跟这门语言的设计密切相关的。
生成CFG和字节码的代码在Python/compile.c中。调用顺序如下
总的逻辑是以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基本块中又包含一个指令数组每个指令又包含操作码、参数等信息。
图7基本块和指令
为了直观理解我设计了一个简单的示例程序。foo函数里面有一个if语句这样会产生多个基本块。
def foo(a):
if a > 10 :
b = a
else:
b = 10
return b
通过GDB跟踪编译过程我们发现它生成的CFG如下图所示
图8示例程序对应的CFG
在CFG里你要注意两组箭头。
实线箭头是基本块之间的跳转关系用b_next字段来标记。虚线箭头能够基于b_list字段把所有的基本块串起来形成一个链表每一个新生成的基本块指向前一个基本块。只要有一个指针指向最后一个基本块就能访问所有的基本块。
你还可以通过GDB来查看每个基本块中的指令分别是什么这样你就会理解每个基本块到底干了啥。不过我这里先给你留个小伏笔在下一个小节讲汇编的时候一起给你介绍。
到目前为止我们已经生成了CFG和针对每个基本块的指令数组。但我们还没有生成最后的字节码。这个任务是由汇编Assembly阶段负责的。
汇编Assembly
汇编过程是在Python/compiler.c中的assemble()函数中完成的。听名字你会感觉这个阶段做的事情似乎应该比较像汇编语言的汇编器的功能。也确实如此。汇编语言的汇编器能够生成机器码而Python的汇编阶段是生成字节码它们都是生成目标代码。
具体来说,汇编阶段主要会完成以下任务:
把每个基本块的指令对象转化成字节码。
把所有基本块的字节码拼成一个整体。
对于从一个基本块跳转到另一个基本块的jump指令它们有些采用的是相对定位方式比如往前跳几个字的距离。这个时候编译器要计算出正确的偏移值。
生成PyCodeObject对象这个对象里保存着最后生成的字节码和其他辅助信息用于Python的解释器执行。
我们还是通过示例程序来直观地看一下汇编阶段的工作成果。你可以参照下图使用instaviz工具看一下foo函数的编译结果。
在PyCodeObject对象中co_code字段是生成的字节码用16进制显示。你还能看到常量表和变量表这些都是在解释器中运行所需要的信息。
如果把co_code字段的那一串字节码反编译一下你会得到下面的信息
你会看到一共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块的代码。
另一个优化机会就是在字节码的基础上优化这就是窥孔优化其实现是在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对象的过程。
窥孔优化器在字节码的基础上做了一些优化,研究这个程序,会让你对窥孔优化的认识变得具象起来。
按照惯例,我把这一讲的思维导图也整理出来了,供你参考:
一课一思
在语义分析过程中针对函数中的本地变量Python编译器没有像Java编译器那样一边添加符号一边做引用消解。这是为什么请在留言区分享你的观点。
如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
参考资料
Python字节码的说明。

View File

@ -0,0 +1,399 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Python编译器运行时机制
你好,我是宫文学。
在前面两讲中我们已经分析了Python从开始编译到生成字节码的机制。但是我们对Python只是了解了一半还有很多问题需要解答。比如Python字节码是如何运行的呢它是如何管理程序所用到的数据的它的类型体系是如何设计的有什么特点等等。
所以今天这一讲我们就来讨论一下Python的运行时机制。其中的核心是Python对象机制的设计。
我们先来研究一下字节码的运行机制。你会发现它跟Python的对象机制密切相关。
理解字节码的执行过程
我们用GDB跟踪执行一个简单的示例程序它只有一行“a=1”。
这行代码对应的字节码如下。其中前两行指令实现了“a=1”的功能后两行是根据Python的规定在执行完一个模块之后缺省返回一个None值
你需要在_PyEval_EvalFrameDefault()函数这里设置一个断点,在这里实际解释指令并执行。
首先是执行第一行指令LOAD_CONST。
你会看到,解释器做了三件事情:
从常数表里取出0号常数。你知道编译完毕以后会形成PyCodeObject而在这个对象里会记录所有的常量、符号名称、本地变量等信息。常量1就是从它的常量表中取出来的。
把对象引用值加1。对象引用跟垃圾收集机制相关。
把这个常数对象入栈。
从这第一行指令的执行过程,你能得到什么信息呢?
第一个信息常量1在Python内部它是一个对象。你可以在GDB里显示这个对象的信息该对象的类型是PyLong_Type型这是Python的整型在内部的实现。
另外该对象的引用数是126个说明这个常量对象其实是被共享的LOAD_CONST指令会让它的引用数加1。我们用的常数是1这个值在Python内部也是会经常被用到所以引用数会这么高。你可以试着选个不那么常见的常数看看它的引用数是多少都是在哪里被引用的。
进一步我们会发现往栈里放的数据其实是个对象指针而不像Java的栈机那样是放了个整数。
总结上述信息我其实可以告诉你一个结论在Python里程序中的任何符号都是对象包括整数、浮点数这些基础数据或者是自定义的类或者是函数它们都是对象。在栈机里处理的是这些对象的引用。
我们再继续往下分析一条指令也就是STORE_NAME指令来加深一下对Python运行机制的理解。
执行STORE_NAME指令时解释器做了5件事情
根据指令的参数从名称表里取出变量名称。这个名称表也是来自于PyCodeObject。前面我刚说过了Python程序中的一切都是对象那么name也是对象。你可以查看它的类型是PyUnicode_Type也就是Unicode的字符串。
从栈顶弹出上一步存进去的常量对象。
获取保存了所有本地变量的字典这也是来自PyCodeObject。
在字典里设置a的值为该常量。如果你深入跟踪其执行过程你会发现在存入字典的时候name对象和v对象的引用都会加1。这也是可以理解的因为它们一个作为key一个作为value都要被字典所引用。
减少常量对象的引用计数。意思是栈机本身不再引用该常量。
好了通过详细解读这两条指令的执行过程我相信你对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; //整型
它在内存中的布局是这样的:
图1Python3的整数对象的内存布局
所以你会看出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编译器的时候已经讲过了。
2.基于引用计数的垃圾收集机制
每个Python对象会保存一个引用计数。也就是说Python的垃圾收集机制是基于引用计数的。
它的优点是可以实现增量收集,只要引用计数为零就回收,避免出现周期性的暂停;缺点是需要解决循环引用问题,并且要经常修改引用计数(比如在每次赋值和变量超出作用域的时候),开销有点大。
3.唯一ID
每个Python对象都有一个唯一ID它们在生存期内是不变的。用id()函数就可以获得对象的ID。根据Python的文档这个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
因为这个结构非常重要,所以我把一些有代表性的字段整理了一下,你可以重点关注它们:
你会看到这个结构里的很多部分都是一个函数插槽Slot你可以往插槽里保存一些函数指针用来实现各种标准操作比如对象生命周期管理、转成字符串、获取哈希值等。
在上面的表格中你还会看到像“__init__”这样的方法它的两边都是有两个下划线的也就是“double underscore”简称dunder方法也叫做“魔术方法”。在用Python编写自定义的类的时候你可以实现这些魔术方法它们就会被缺省的tp_*函数所调用比如“__init__”会被缺省的tp_init函数调用完成类的初始化工作。
现在我们拿整型对象来举个例子一起来看看它的PyTypeObject是什么样子。
对于整型对象来说它的ob_type会指向一个PyLong_Type对象。这个对象是在longobject.c中初始化的它是PyTypeObject的一个实例。从中你会看到一些信息类型名称是“int”转字符串的函数是long_to_decimal_string此外还有比较函数、方法描述、属性描述、构建和析构函数等。
我们运行type()函数可以获得一个对象的类型名称这个名称就来自PyTypeObject的tp_name。
>>> a = 10
>>> type(a)
<type 'int'>
我们用dir()函数可以从PyTypeObject中查询出一个对象所支持的所有属性和方法。比如下面是查询一个整型对象获得的结果
我们刚才讲了整型它对应的PyTypeObject的实例是PyLong_Type。Python里其实还有其他一些内置的类型它们分别都对应了一个PyTypeObject的实例。你可以参考一下这个表格。
上面列出的这些都是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
它对应的字节码如下:
我们重点来关注BINARY_ADD指令的执行情况如下图所示
可以看到,如果+号两边是字符串,那么编译器就会执行字符串连接操作。否则,就作为数字相加。
我们继续跟踪进入PyNumber_Add函数。在这个函数中Python求出了加法函数指针在PyNumberMethods结构体中的偏移量接着就进入了binary_op1()函数。
在binary_op1函数中Python首先从第一个参数的类型对象中取出了加法函数的指针。你在GDB中打印出输出信息就会发现它是binaryfunc类型的函数名称是long_add。
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指令与函数调用没有任何区别。
可是我们知道示例程序中a的类型对象是myclass但myclass的类型对象是什么呢
换句话说,一个普通的对象的类型,是一个类型对象。那么一个类型对象的类型又是什么呢?
答案是元类metaclass元类是类型的类型。举例来说整型的metaclass是PyType_Type。其实大部分类型的metaclass是PyType_Type。
所以说调用类型来实例化一个对象就是调用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。
当然你也可以给你的类型指定另一个metaclass从而支持不同的对象创建和初始化功能。虽然大部分情况下你不需要这么做但这种可定制的能力就为你编写某些特殊的功能比如元编程提供了可能性。
好了现在你已经知道类型的类型是元类metaclass它能为类型的调用提供支持。你可能进一步会问那么元类的类型又是什么呢是否还有元元类直接调用元类又会发生什么呢
缺省情况下PyType_Type的类型仍然是PyType_Type也就是指向它自身。对元类做调用也一样会启动上面的tp_call()过程。
到目前为止我们谈论Python中的对象还没有谈论那些面向对象的传统话题继承啦、多态啦等等。这些特性在Python中的实现仍然只是在类型对象里设置一些字段即可。你可以在tp_base里设定基类父类来支持继承甚至在tp_bases中设置多个基类来支持多重继承。所有对象缺省的基类是objecttp_base指向的是一个PyBaseObject_Type对象。
>>> int.__base__ #查看int类型的基类
<class 'object'>
到目前为止,我们已经对于对象的类型、元类,以及对象之间的继承关系有了比较全面的了解,为了方便你重点复习和回顾,我把它们画成了一张图。
图2Python对象的类型关系和继承关系
你要注意图中我用两种颜色的箭头区分了两种关系。一种是橙色箭头代表的是类型关系比如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函数。面向对象的特性也是通过在类型对象里建立与基类的链接而实现的。
我照例把本讲的重点知识,整理成了一张思维导图,供你参考和回顾:
一课一思
今天给你的思考题是很有意思的。
我前面讲到当Python做加法运算的时候如果对象类型相同那么只有一个加法函数可选。但如果两边的对象类型是不同的该怎么办呢你可以看看Python是怎么实现的。这其实是编译技术的一个关注点我们在后面课程中还会提及这个问题。
参考资料
Python的内置类型。

View File

@ -0,0 +1,245 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 JavaScript编译器V8的解析和编译过程
你好我是宫文学。从这一讲开始我们就进入另一个非常重要的编译器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的源代码。按照官方文档中的步骤你可以下载源代码并在本地编译。注意你最好把它编译成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++编写的。你可以重点关注这几个目录中的代码,它们是与编译有关的功能,而别的代码主要是运行时功能:
V8的编译器的构成跟Java的编译器很像它们都有从源代码编译到字节码的编译器也都有解释器叫Ignition也都有JIT编译器叫TurboFan。你可以看下V8的编译过程的图例。在这个图中你能注意到两个陌生的节点流处理节点Stream和预解析器PreParser这是V8编译过程中比较有特色的两个处理阶段。
图1V8的编译过程
注意这是比较新的V8版本的架构。在更早的版本里有时会用到两个JIT编译器类似于HotSpot的C1和C2分别强调编译速度和优化效果。在更早的版本里还没有字节码解释器。现在的架构引入了字节码解释器其速度够快所以就取消了其中一级的JIT编译器。
下面我们就进入到V8编译过程中的各个阶段去了解一些编译器的细节。
超级快的解析过程(词法分析和语法分析)
首先我们来了解一下V8解析源代码的过程。我在开头就已经说过V8解析源代码的速度必须要非常快才行。源代码边下载边解析完毕在这个过程中用户几乎感觉不到停顿。那它是如何实现的呢
有两篇文章就非常好地解释了V8解析速度快的原因。
一个是“optimizing the 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
一个页面中包含的代码并不会马上被程序用到。如果在一开头就把它们全部解析成AST并编译成字节码就会产生很多开销占用了太多CPU时间过早地占用内存编译后的代码缓存到硬盘上导致磁盘IO的时间很长等等。
所以所有浏览器中的JavaScript编译器都采用了懒解析技术。在V8里首先由预解析器也就是Preparser粗略地解析一遍程序在正式运行某个函数的时候编译器才会按需解析这个函数。你要注意Preparser只检查语法的正确性而基于上下文的检查则不是这个阶段的任务。你如果感兴趣的话可以深入阅读一下这篇介绍Preparser的文章我在这里就不重复了。
你可以在终端测试一下懒解析和完整解析的区别。针对foo.js示例程序你输入“./d8 ast-print foo.js”命令。
function add(a,b){
return a + b;
}
//add(1,2) //一开始先不调用add函数
得到的输出结果是:
里面有一个没有名字的函数也就是程序的顶层函数并且它记录了一个add函数的声明仅此而已。你可以看到Preparser的解析结果确实够粗略。
而如果你把foo.js中最后一行的注释去掉调用一下add函数再次让d8运行一下foo.js就会输出完整解析后的AST你可以看看二者相差有多大
最后你可以去看看正式的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函数的字节码如下
怎么理解这几行字节码呢?我来给你解释一下:
Ldar a1把参数1从寄存器加载到累加器Ld=loada=accumulator, r=register
Add a0, [0]把参数0加到累加器上。
Return返回返回值在累加器上
不过要想充分理解这几行简单的字节码你还需要真正理解Ignition的设计。因为这些字节码是由Ignition来解释执行的。
Ignition是一个基于寄存器的解释器。它把函数的参数、变量等保存在寄存器里。不过这里的寄存器并不是物理寄存器而是指栈帧中的一个位置。下面是一个示例的栈帧
图2Ignition的栈帧
这个栈帧里包含了执行函数所需要的所有信息:
参数和本地变量。
临时变量它是在计算表达式的时候会用到的。比如计算2+3+4的时候就需要引入一个临时变量。
上下文:用来在函数闭包之间维护状态。
pc调用者的代码地址。
栈帧里的a0、a1、r0、r1这些都是寄存器的名称可以在指令里引用。而在字节码里会用一个操作数的值代替。
整个栈帧的长度是在编译成字节码的时候就计算好了的。这就让Ignition的栈帧能适应不同架构对栈帧对齐的要求。比如AMD64架构的CPU它就要求栈帧是16位对齐的。
Ignition也用到了一些物理寄存器来提高运算的性能
累加器:在做算术运算的时候,一定会用到累加器作为指令的其中一个操作数,所以它就不用在指令里体现了;指令里只要指定另一个操作数(寄存器)就行了。
字节码数组寄存器:指向当前正在解释执行的字节码数组开头的指针。
字节码偏移量寄存器当前正在执行的指令在字节码数组中的偏移量与pc寄存器的作用一样
Ignition是我们见到的第一个寄存器机它跟我们之前见到的Java和Python的栈机有明显的不同。所以你可以先思考一下Ignition会有什么特点呢
我来给你总结一下吧。
它在指令里会引用寄存器作为操作数,寄存器在进入函数时就被分配了存储位置,在函数运行时,栈帧的结构是不变的。而对比起来,栈机的指令从操作数栈里获取操作数,操作数栈随着函数的执行会动态伸缩。
Ignition还引入了累加器这个物理寄存器作为缺省的操作数。这样既降低了指令的长度又能够加快执行速度。
当然Ignition没有像生成机器码那样用一个寄存器分配算法让本地变量、参数等也都尽量采用物理寄存器。这样做的原因一方面是因为寄存器分配算法会增加编译的时间另一方面这样不利于代码在解释器和TurboFan生成的机器代码之间来回切换因为它要在调用约定之间做转换。采用固定格式的栈帧Ignition就能够在从机器代码切换回来的时候很容易地设置正确的解释器栈帧状态。
我把更多的字节码指令列在了下面你可以仔细看一看Ignition都有哪些指令从而加深对Ignition解释运行机制的理解。同时你也可以跟我们已经学过的Java和Python的字节码做个对比。这样呀你对字节码、解释器的了解就更丰富了。
来源Ignition Design Doc
编译成机器码
前面我提到了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可以使用的各种选项及其说明我把上面几个选项的含义解释一下。-
trace-opt-verbose跟踪优化过程并输出详细信息-
trace-turbo跟踪TurboFan的运行过程-
print-code打印生成的代码-
print-opt-code打印优化的代码-
code-comment在汇编代码里输出注释
程序一开头是解释执行的。在循环了24000次以后V8认为这是热点代码于是启动了Turbofan做即时编译。
最后生成的汇编代码有好几十条指令。不过你可以看到,大部分指令是用于初始化栈帧,以及处理逆优化的情况。真正用于计算的指令,是下面几行指令:
对这些汇编代码的解读,以及这些指令的产生和优化过程,我会在下一讲继续给你讲解。
课程小结
今天这讲我们从总体上考察了V8的编译过程我希望你记住几个要点
首先是编译速度。由于JavaScript是在浏览器下载完页面后马上编译并执行它对编译速度有更高的要求。因此V8使用了一边下载一边编译的技术懒解析技术。并且在解析阶段V8也比其他编译器更加关注处理速度你可以从中学到通过查表减少计算量的技术。
其次我们认识了一种新的解释器Ignition它是基于寄存器的解释器或者叫寄存器机。Ignition比起栈机来更有性能优势。
最后我们初步使用了一下V8的即时编译器TurboFan。在下一讲中我们会更细致地探讨TurboFan的特性。
按照惯例,这一讲的思维导图我也给你整理出来了,供你参考:
一课一思
你能否把Ignition的字节码和Java、Python的字节码对比一下。看看它们有哪些共同之处有哪些不同之处
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
参考资料
这两篇文章分析了V8的解析器为什么速度非常快Blazingly fast parsing, part 1: optimizing the scannerBlazingly fast parsing, part 2: lazy parsing
这篇文章描述了Ignition的设计Ignition Design Doc我在GitHub上也放了一个拷贝
这篇文章有助于你了解Ignition的字节码Understanding V8s bytecode
V8项目的官网这里有一些重要的博客文章和文档

View File

@ -0,0 +1,301 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 JavaScript编译器V8的解释器和优化编译器
你好我是宫文学。通过前一讲的学习我们已经了解了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编译器时你就发现了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);
}
但是如果不在解释器里执行直接要求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指令的内置函数来生成了汇编代码。
这个内置函数当然支持所有加法操作的语义,但是它也就无法启动基于推理的优化机制了。这样的代码,跟解释器直接解释执行,性能上没太大的差别,因为它们本质上都是调用一个全功能的内置函数。
而推理式优化的版本则不同,它直接生成了针对整型数字进行处理的汇编代码:
我来给你解释一下这几行指令的意思:
第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》这篇文章就介绍了V8中采用Tagged Pointer技术的细节。
比如说对于一个32位的指针值它的最低位是标记位。当标记位是0的时候前31位是一个短整数简写为Smi而当标记位是1的时候那么前31位是一个地址。这样V8就可以用指针来直接保存一个整数值用于计算从而降低了内存占用并提高了运行效率。
图1用标记指针技术来表示一个整数
好了现在你已经理解了V8的推理式编译的机制。那么还有什么手段能提升代码的性能呢
当然还有基于IR的各种优化算法。
IR和优化算法
在讲Java的JIT编译器时我就提过V8做优化编译时采用的IR也是基于Sea of Nodes的。
你可以回忆一下Sea of Nodes的特点合并了数据流图与控制流图是SSA形式没有把语句划分成基本块。
它的重要优点就是优化算法可以自由调整语句的顺序只要不破坏数据流图中的依赖关系。在Sea of Nodes中没有变量有时也叫做寄存器的概念只有一个个数据节点所以对于死代码删除等优化方法来说它也具备天然的优势。
说了这么多那么要如何查看TurboFan的IR呢一个好消息是V8也像GraalVm一样提供了一个图形化的工具来查看TurboFan的IR。这个工具是turbolizer它位于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
图2在turbolizer中显示add函数的IR
界面中最左边一栏是源代码中间一栏是IR最右边一栏是生成的汇编代码。
上图中的IR只显示了控制节点。你可以在工具栏中找到一个按钮把所有节点都呈现出来。在左侧的Info标签页中还有一些命令的快捷键你最好熟悉一下它们以便于控制IR都显示哪些节点。这对于一个大的IR图来说很重要否则你会看得眼花缭乱
图3完整展开的add函数的IR
在这个图中,不同类型的节点用了不同的颜色来区分:
黄色控制流的节点比如Start、End和Return
淡蓝色:代表一个值的节点;
红色JavaScript层级的操作比如JSEqual、JSToBoolean等
深蓝色代表一种中间层次的操作介于JavaScript层和机器层级中间
绿色:机器级别的语言,代表一些比较低层级的操作。
在turbolizer的界面上还有一个下拉菜单里面有多个优化步骤。你可以挨个点击看看经过不同的优化步骤以后IR的变化是什么样子的。
图4TurboFan对IR的处理步骤
你可以看到在第一步“v8.TFBytecodeGraphBuilder”阶段显示的IR中它显示的节点还是有点儿多。我们先隐藏掉与计算功能无关的节点得到了下面的主干。你要注意其中的绿色节点这里已经进行了类型推测因此它采用了一个整型计算节点SpeculativeSafeIntegerAdd。
这个节点的功能是:当两个参数都是整数的时候,那就符合类型推断,做整数加法操作;如果不符合类型推断,那么就执行逆优化。
图5v8.TFBytecodeGraphBuilder”阶段的IR
你可以再去点击其他的优化步骤,看看图形会有什么变化。
在v8.TFGenericLowering阶段我们得到了如下所示的IR图这个图只保留了计算过程的主干。里面增加了两个绿色节点这两个节点就是把标记指针转换成整数的还增加了一个深蓝色的节点这个节点是在函数返回之前把整数再转换成标记指针。
图6v8.TFGenericLowering阶段的IR
在v8.TFLateGraphTrimming阶段图中的节点增加了更多的细节它更接近于具体CPU架构的汇编代码。比如我们把前面图6中的标记指针转换成32位整数的操作就变成了两个节点
TruncateInt64ToInt32把64位整型截短为32位整型
Word32Sar32位整数的移位运算用于把标记指针转换为整数。
图7v8.TFLateGraphTrimming阶段的IR
这三个阶段就形象地展示出了TurboFan的IR是如何Lower的从比较抽象的、机器无关的节点逐步变成了与具体架构相关的操作。
所以基本上IR的节点可以分为四类顶层代表复杂操作的JavaScript节点、底层代表简单操作的机器节点、处于二者之间做了一定简化的节点以及可以被各个层次共享的节点。
刚才我们对V8做优化编译时所采用的IR的分析只关注了与加法计算有关的主干节点。你还可以用同样的方法来看看其他的节点。这些节点主要是针对异常情况的处理。比如如果发现参数类型不是整型那么就要去执行逆优化。
在做完了所有的优化之后,编译器就会进入指令排序、寄存器分配等步骤,最后生成机器码。
现在你就了解了TurboFan是如何借助Sea of Nodes来做优化和Lower的了。但我们还没有涉及具体的优化算法。那么什么优化算法会帮助V8提升性能呢
前面在研究Java的JIT编译器的时候我们重点关注了内联和逃逸分析的优化算法。那么对于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标准中就对加法的语义有很多的规定如数字相加该怎么做、字符串连接该怎么做等等。
图8ECMAScript标准中加法操作的语义规则
这样的话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编译器的时候已经给你介绍过了所以这一讲并没有详细展开你可以自己去回顾复习一下。
第三V8所采用的内联缓存技术能够在运行期提高对象属性访问的性能。另外你要注意的是在编写代码的时候一定要避免对于相同的对象生成不同的隐藏类。
第四Ignition采用了TurboFan来编译内置函数这种技术非常聪明既省了工作量又简化了系统的结构。实际上在Graal编译器里也有类似的技术它叫做Snippet也是用自身的中后端功能来编译内置函数。所以你会再次发现多个编译器之间所采用的编译技术是可以互相印证的。
这节课的思维导图我同样帮你整理出来了,供你参考和复习:
一课一思
我们已经学了两种动态类型的语言的编译技术Python和JavaScript。那我现在问一个开脑洞的问题如果你要给Python加一个JIT编译器那么你可以从JavaScript这里借鉴哪些技术呢在哪些方面编译器会得到巨大的性能提升
参考资料
V8的指针压缩技术Pointer Compression in V8
介绍V8基于推理的优化机制An Introduction to Speculative Optimization in V8
对Ignition字节码做优化的论文Register equivalence optimization我在GitHub上也放了一份拷贝

View File

@ -0,0 +1,324 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 Julia编译器如何让动态语言性能很高
你好,我是宫文学。
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的性能有多高呢你可以去它的网站上看看与其他编程语言的性能对比
图1Julia和各种语言的性能对比
可以看出它的性能是在C、Rust这一个级别的很多指标甚至比Java还要好比起那些动态语言如Python、R和Octave那更是高了一到两个数量级。
所以Julia的编译器声称它具备了静态类型语言的性能确实是不虚此言的。
你可以从Julia的官网下载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编译器的源代码。
图2Julia的源代码目录
其实Julia的实现会让人有点困扰因为它使用了4种语言C、C++、Lisp和Julia自身。相比而言CPython的实现只用了两种语言C语言和Python。这种情况就对社区的其他技术人员理解这个编译器和参与开发带来了不小的困难。
Julia的作者用C语言实现了一些运行时的核心功能包括垃圾收集器。他们是比较偏爱C语言的。C++主要是用来实现跟LLVM衔接的功能因为LLVM是用C++实现的。
但是为什么又冒出了一个Lisp语言呢而且前端部分的主要功能都是用Lisp实现的。
原来Julia中用到Lisp叫做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就是用Scheme作为教学语言而SICP和Scheme也都是源自MIT。它的词法分析和语法分析的过程主要是在parser.scm文件里实现的我们刚才调用的“julia-parse”函数就是在这个文件中声明的。
Julia的语法分析过程仍然是你非常熟悉的递归下降算法。因为Lisp语言处理符号的能力很强又有很好的元编程功能所以Lisp在实现词法分析和语法分析的任务的时候代码会比其他语言更短。但是不熟悉Lisp语言的人可能会看得一头雾水因为这种括号嵌套括号的语言对于人类的阅读不那么友好不像Java、JavaScript这样的语言一样更像自然语言。
julia-parser.scm输出的成果是比较经典的ASTJulia的文档里叫做“表面语法AST”surface syntax AST。所谓表面语法AST它是跟另一种IR对应的叫做Lowered Form。
“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的编译过程并且可以把它跟你学过的其他几个编译器做一下对比
图3Julia的编译过程
Julia有很好的反射(Reflection)和自省(Introspection)的能力,你可以调用一些函数或者宏来观察各个阶段的成果。
比如,采用@code_lowered宏来看countdown(10)产生的代码你会看到if…else…的结构被转换成了“goto”语句这个IR看上去已经有点像LLVM的IR格式了。
进一步,你还可以用@code_typed宏,来查看它做完类型推断以后的结果,每条语句都标注了类型:
接下来,你可以用@code_llvm和@code_native宏来查看生成的LLVM IR和汇编代码。这次我们用一个更简单的函数foo(),让生成的代码更容易看懂:
julia> function foo(x,y) #一个简单的函数,把两个参数相加
x+y #最后一句的结果就是返回值这里可以省略return
end
通过@code_llvm宏生成的LLVM IR如下图所示
通过@code_native宏输出的汇编代码是这样的
最后生成的汇编代码,可以通过汇编器迅速生成机器码并运行。
通过上面的梳理你应该已经了解了Julia的编译过程脉络通过Lisp的程序把程序变成AST然后再变成更低级一点的IR在这个过程中编译器要进行类型推断等语义层面的处理最后翻译成LLVM的IR并生成可执行的本地代码。
对于静态类型的语言来说我们根据准确的类型信息就可以生成高效的本地代码这也是C语言性能高的原因。比如我们用C语言来写一下foo函数
long foo(long x, long y){
return x+y;
}
Clang的LLVM IR跟Julia生成的基本一样
生成的汇编代码也差不多:
所以对于这样的程序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和汇编代码就会发现它智能地适应了新的数据类型生成了用于处理浮点数的代码使用了不同的指令和寄存器。
你可以用同样的方法,来试一下 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编译器的时候讲到加法操作采用的实现是第一个操作数对象的类型里定义的与加法有关的函数。但为什么它是用第一个对象的方法而不是第二个对象的呢如果第一个对象和第二个对象的类型不同怎么办呢这就是我在那讲中留给你的问题
还有一个很不方便的地方。如果你增加了一种新的数据类型比如矩阵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也不例外。
多种IRJulia在AST之外采用了“goto”格式的IR还用到了LLVM的IR实际上LLVM内部在转换成本地代码之前还有一个中间格式的IR
多版本的目标代码Julia创造性地利用了LLVM的即时编译功能。它可以在运行期通过类型推断确定变量的类型进行即时编译根据不同的参数类型生成多个版本的目标代码让程序员写一个程序就能适应多种数据类型既降低了程序员的工作量同时又保证了程序的高性能。这使得Julia同时拥有了动态类型语言的灵活性和静态类型语言的高性能。
多重分派功能:多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。
你学了这讲有什么体会呢深入探究Julia这样的语言的实现过程真的有助于我们大开脑洞突破思维的限制更好地融合编译原理的各方面的知识从而为你的实际工作带来更加创新的思路。
这一讲的思维导图我也给你整理出来了,供你参考和复习回顾:
一课一思
一个很有意思的问题为什么Julia会为一个函数根据不同的参数类型组合生成多个版本的目标代码而JavaScript的引擎一般只会保存一个版本的目标代码这个问题你可以从多个角度进行思考欢迎在留言区分享你的观点。
感谢你的阅读,如果你觉得有收获,欢迎把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,227 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Julia编译器如何利用LLVM的优化和后端功能
你好,我是宫文学。
上一讲我给你概要地介绍了一下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的编译过程并且程序会停在断点上。我整理了一下调用栈的信息你可以看看即时编译是如何被触发的。
通过跟踪执行和阅读源代码你会发现Julia中最重要的几个源代码
gf.cJulia以方法分派快速而著称。对于类似加法的这种运算它会有上百个方法的实现所以在运行时就必须能迅速定位到准确的方法。分派就是在gf.c里。
interpreter.c它是Julia的解释器。虽然Julia中的函数都是即时编译的但在REPL中的简单的交互靠解释执行就可以了。
codegen.cpp生成LLVM IR的主要逻辑都在这里。
我希望你能自己动手跟踪执行一下这样你就会彻底明白Julia的运行机制。
Julia的IR采用SSA形式
在上一讲中,你已经通过@code_lowered和@code_typed宏查看过了Julia的IR。
Julia的IR也经历了一个发展演化过程它的IR最早不是SSA的而是后来才改成了SSA形式。这一方面是因为SSA真的是有优势它能简化优化算法的编写另一方面也能看出SSA确实是趋势呀我们目前接触到的Graal、V8和LLVM的IR都是SSA格式的。
Julia的IR主要承担了两方面的任务。
第一是类型推断推断出来的类型被保存到IR中以便于生成正确版本的代码。
第二是基于这个IR做一些优化其实主要就是实现了内联优化。内联优化是可以发生在比较早的阶段你在Go的编译器中就会看到类似的现象。
你可以在Julia中写两个短的函数让其中一个来调用另一个看看它所生成的LLVM代码和汇编代码是否会被自动内联。
另外你还可以查看一下传给emit_function函数的Julia IR是什么样子的。在LLDB里你可以用下面的命令来显示src参数的值其中jl_(obj)是Julia为了调试方便提供的一个函数它能够更好地显示Julia对象的信息注意显示是在julia窗口中。src参数里面包含了要编译的Julia代码的信息。
(lldb) expr jl_(src)
为了让你能更容易看懂,我稍微整理了一下输出的信息的格式:
你会发现,这跟用@code_typed(add(2,3))命令打印出来的信息是一致的,只不过宏里显示的信息会更加简洁:
接下来查看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的编译器里我们已经接触过内置函数Built-in的概念了。而在Julia的编译器中内建函数和内置函数其实是不同的概念。
内置函数是标准的Julia函数它可以有多个方法根据不同的类型来分派。比如取最大值、最小值的函数max()、min()这些,都是内置函数。
而内建函数只能针对特定的参数类型没有多分派的能力。Julia会把基础的操作都变成对内建函数的调用。在上面示例的IR中就有一个add_in()函数也就是对整型做加法运算它就是内建函数。内建函数的目的是生成LLVM IR。Julia中有近百个内置函数。在intrinsics.cpp中有为这些内置函数生成LLVM IR的代码。
这就是Julia生成LLVM IR的过程遍历Julia的IR并调用LLVM的IRBuilder类生成合适的IR。在此过程中会遇到很多内建函数并调用内建函数输出LLVM IR的逻辑。
运行LLVM的Pass
我们之所以会使用LLVM很重要的一个原因就是利用它里面的丰富的优化算法。
LLVM的优化过程被标准化成了一个个的Pass并由一个PassManager来管理。你可以查看jitlayers.cpp中的addOptimizationPasses()函数看看Julia都使用了哪些Pass。
LoopRotate官方文档 / SLPVectorizer官方文档 /《编译原理之美》第31讲
上面表格中的Pass都是LLVM中自带的Pass。你要注意运用好这些Pass会产生非常好的优化效果。比如某个开源项目由于对性能的要求比较高所以即使在Windows平台上仍然强烈建议使用Clang来编译而Clang就是基于LLVM的。
除此之外Julia还针对自己语言的特点写了几个个性化的Pass。比如
这些个性化的Pass是针对Julia本身的语言特点而编写的。比如对于垃圾收集每种语言的实现策略都不太一样因此就必须自己实现相应的Pass去插入与垃圾收集有关的代码。再比如Julia是面向科学计算的比较在意数值计算的性能所以自己写了两个Pass来更好地利用CPU的一些特殊指令集。
emit_function函数最后返回的是一个模块Module对象这个模块里只有一个函数。这个模块会被加入到一个JuliaOJIT对象中进行集中管理。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讲中我说过ABI也会影响代码的生成。而LLVM也支持Windows、Linux和macOS的不同的ABI。
另外你已经知道在目标代码生成的过程中一般会需要三大优化算法指令选择、寄存器分配和指令排序算法。LLVM对此同样也给予了很好的支持你直接使用这些算法就行了。
最后LLVM的代码生成功能对CPU厂家也很友好因为这些算法都是目标独立Target-independent的。如果硬件厂家推出了一个新的CPU那它可以用LLVM提供的TableGen工具来描述这款新CPU的架构这样我们就能使用LLVM来为它生成目标代码了。
对垃圾收集的支持
LLVM还支持垃圾收集的特性比如会提供安全点、读屏障、写屏障功能等。这些知识点我会在第32讲“垃圾收集”的时候带你做详细的了解。
对Debug的支持
我们知道代码的跟踪调试对于程序开发是很重要的。如果一门语言是生成机器码的那么要实现跟踪调试我们必须往代码里插入一些调试信息比如目标代码对应的源代码的位置、符号表等。这些调试信息是符合DWARFDebugging 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就提供了一个很好的参考。
本讲的思维导图我也给你整理出来了,供你参考和复习回顾知识点:
一课一思
LLVM强调全生命周期优化的概念。那么我们来思考一个有趣的问题能否让Julia也像Java的JIT功能一样在运行时基于推理来做一些激进的优化如何来实现呢欢迎在留言区发表你的观点。
参考资料
LLVM的官网llvm.org。如果你想像Julia、Rust、Swift等语言一样充分利用LLVM那么应该会经常到这里来查阅相关资料。
LLVM的源代码。像LLVM这样的开源项目不可能通过文档或者书籍来获得所有的信息。最后你还是必须去阅读源代码甚至要根据Clang等其他前端使用LLVM的输出做反向工程才能掌握各种细节。LLVM的核心作者也推荐开发者源代码当作文档。
Working with LLVMJulia的开发者文档中有对如何使用LLVM的介绍。
LLVMs Analysis and Transform Passes对LLVM中的各种Pass的介绍。要想使用好LLVM你就要熟悉这些Pass和它们的使用场景。
在《编译原理之美》的第25讲和第26讲我对LLVM后端及其命令行工具做了介绍并且还手工调用LLVM的API示范了针对不同的语法结构比如if结构应该如何生成LLVM IR最后即时编译并运行。你可以去参考看看。

View File

@ -0,0 +1,253 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 Go语言编译器把它当作教科书吧
你好我是宫文学。今天这一讲我来带你研究一下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语言的源代码本讲采用的是1.14.2版本。并且你最好使用一个IDE便于跟踪调试编译器的执行过程。-
Go的源代码中附带的介绍编译器的文档写得很好、很清晰你可以参考一下。
词法分析和语法分析
Go的编译器的词法分析和语法分析功能的实现是在cmd/compile/internal/syntax目录下。
词法分析器是scanner.go。其实大部分编程语言的词法分析器的算法都已经很标准了我们在Java编译器里就曾经分析过。甚至它们处理标识符和关键字的方式也都一致都是先作为标识符识别出来然后再查表挑出关键字来。Go的词法分析器并没有像V8那样在不遗余力地压榨性能它跟你平常编码的方式是很一致的非常容易阅读。
语法分析器是parser.go。它是一个标准的手写的递归下降算法。在解析二元表达式的时候Go的语法分析器也是采用了运算符优先级算法这个已经是我们第N次见到这个算法了所以你一定要掌握不过每个编译器的实现都不大一样而Go的实现方式相当的简洁你可以去自己看一下或者用调试器来跟踪一下它的执行过程。
图1用IDE工具Goland跟踪调试编译过程
Go的AST的节点是在nodes.go中定义的它异常简洁可以说简洁得让你惊讶。你可以欣赏一下。
Go的语法分析器还有一个很有特色的地方就是对错误的处理。它在处理编译错误时有一个原则就是不要遇到一个错误就停止编译而是要尽可能跳过当前这个出错的地方继续往下编译这样可以一次多报几个语法错误。
parser.go的处理方式是当语法分析器在处理某个产生式的时候如果发现了错误那就记录下这个错误并且往下跳过一些Token直到找到一个Token是属于这个产生式的Follow集合的。这个时候编译器就认为找到了这个产生式的结尾。这样分析器就可以跳过这个语法单元继续处理下面的语法单元。
比如在解析函数声明语句时如果Go的语法分析器没有找到函数名称就报错“expecting name or (”,然后往后找到“{”或者“;”,这样就跳过了函数名称的声明部分,继续去编译后面的函数体部分。
在cmd/compile/internal/syntax目录下还有词法分析器和语法分析器的测试程序你可以去运行测试一下。
最后如果你还想对Go语言的语法分析有更加深入地了解我建议你去阅读一下Go语言的规范它里面对于每个语法单元都有EBNF格式的语法规则定义比如对语句的定义。你通过看代码、看语言规范积累语法规则的第一手经验以后再看到一段程序你的脑子里就能反映出它的语法规则并且能随手画出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中。这里你要注意不要被“typecheck”的名称所误导它其实不仅是做类型检查还做了名称消解Name Resolution和类型推导。
你已经知道名称消解算法的特点是分阶段完成。举个例子在给表达式“a=b”中的变量b做引用消解之前编译器必须先处理完b的定义比如“var b Person”这样才知道符号b指的是一个Person对象。
另外在前面学习Java编译器的时候你已经知道对方法体中的本地变量的消解必须放在最后才能保证变量的使用总是引用到在它前面的变量声明。Go的编译器也是采用了相同的实现思路你可以借此再回顾一下这个知识点加深认识。
在语义分析阶段Go的编译器还做了一些AST变换的工作。其中就有内联优化和逃逸分析这两项工作。在我们之前解析的编译器当中这两项工作都是基于专门做优化的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链图中显示成绿色。你还能看到图中灰色的变量根据定义和使用关系会发现它们没有被使用所以是死代码可以删除。
图2foo示例程序各个处理阶段的IR
针对第一个阶段Start阶段我来给你解释一下每行指令的含义可参考genericOps.go帮助你了解Go语言的IR的设计特点。
你可以参考代码库中介绍SSA的文档里面介绍了Go的SSA的几个主要概念。
下面我来给你解读一下。
首先是值Value。Value是SSA的最主要构造单元它可以定义一次、使用多次。在定义一个Value的时候需要一个标识符ID作为名称、产生该Value的操作码Op、一个类型Type就是代码中<>里面的值),以及一些参数。
操作码有两类。一类是机器无关的其定义在genericOps.go中一类是机器相关的它是面向特定的CPU架构的其定义在XXXOps.go中。比如AMD64Ops.go中是针对AMD64架构CPU的操作码信息。
在做Lower处理时编译器会把机器无关的操作码转换为机器相关的操作码有利于后序生成目标代码。机器无关的优化和机器相关的优化分别作用在采用这两类不同操作码的IR上。
Value的类型信息通常就是Go语言中的类型。但有几个类型是只会在SSA中用到的特殊类型就像上面语句中的 ,即内存( TypeMem)类型以及TypeFlags也就是CPU的标志位类型。
这里我要特别讲一下内存类型。内存类型代表的是全局的内存状态。如果一个操作码带有一个内存类型的参数,那就意味着该操作码依赖该内存状态。如果一个操作码的类型是内存类型,则意味着它会影响内存状态。
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编译器中学到的知识印证一下。当你读写一个Java对象的属性的时候也会涉及内存的读写这些操作对应的IR节点在顺序上也是受到限制的我们把它们叫做固定节点。
此外Value结构中还包含了两个辅助信息字段AuxInt和Aux。AuxInt是一个整型值比如在使用Const64指令中AuxInt保存了常量的值而Aux则可能是个复杂的结构体用来保存每个操作码的个性化的信息。
在IR中你还能看到基本块Block这是第二个重要的数据结构。Go编译器中的基本块有三种简单Plain基本块它只有一个后继基本块退出Exit基本块它的最后一个指令是一个返回指令还有if基本块它有一个控制值并且它会根据该值是true还是false跳转到不同的基本块。
第三个数据结构是函数Func。函数是由多个基本块构成的。它必须有一个入口基本块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讲中曾提到过我们需要对一个函数的所有基本块都扫描一遍甚至多遍才能知道某个变量的活跃性从而决定是否可以删除掉它。那相比起来采用SSA格式可以说简单太多了。
基于这样的IR来做优化就是对IR做很多遍Pass的处理。在cmd/compile/internal/ssa/compile.go的代码里列出了所有这些Pass有将近50个。你能看到每个处理步骤执行的是哪个优化函数你还可以在ssa.html中看到每个Pass之后IR都被做了哪些修改。
图3compiler.go中的Pass
这些处理算法都是在cmd/compile/internal/ssa目录下。比如cse.go里面是消除公共子表达式的算法而nilcheck.go是被用来消除冗余的nil检查代码。
有些算法还带了测试程序如cse_test.gonilcheck_test.go。你可以去阅读一下看看测试程序是如何构造测试数据的并且你还可以通过Debugger来跟踪测试程序的执行过程从而理解相关优化算法是如何实现的这是一个很有效的学习方式。
另外gc还有一些比较简单的优化算法它们是基于一些规则对IR做一些重写rewrite。Go的编译器使用了自己的一种DSL来描述这些重写规则针对机器无关的操作码的重写规则是在generic.rules文件中而针对机器有关的操作码的重写规则是在XXX.rules中比如AMD64.rules。
我们来看几个例子在generic.rules中有这样一个机器无关的优化规则它是把x*1的运算优化为x。
图4把x*1的运算优化为x的规则
在AMD64.rules中有一个机器相关的优化规则这个规则是把MUL指令转换为LEA指令LEA指令比MUL指令消耗的时钟周期更少。
(MUL(Q|L)const [ 3] x) -> (LEA(Q|L)2 x x)
generic.rules中的规则会被rulegen.go解析并生成Go代码rewritegeneric.go。而AMD64.rules中的规则被解析后会生成rewriteAMD64.go。其中Lower的过程也就是把机器无关的操作码转换为机器相关的操作码它也是用这种重写规则实现的。
通过gc这种基于规则做指令转换的方法你应该产生一个感悟也就是在写软件的时候我们经常要设计自己的DSL让自己的软件更具灵活性。比如gc要增加一个新的优化功能只需要增加一条规则就行了。我们还可以再拿Graal编译器印证一下。你还记得Graal在生成LIR的时候要进行指令的选择那些选择规则是用注解来生成的而那些注解规则也是一种DSL。
好了,谈完了优化,我们继续往下看。
生成机器码
最后编译器就可以调用gc/ssa.go中的genssa方法来生成汇编码了。
在ssa.html的最右边一栏就是调用genssa方法以后生成的汇编代码采用的是Go编译器特有的格式其中有些指令如PCDATA和FUNCDATA是用来与垃圾收集器配合的
你可能会问编译器在生成机器码之前不是还要做指令选择、寄存器分配、指令排序吗那我们看看gc是如何完成这几项任务的。
寄存器分配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界面里来跟踪调试每一个功能这样你就能很方便去观察它的算法执行过程。
本讲的思维导图如下:
一课一思
在gc编译器里面内联优化是基于AST去做的。那么它为什么没有基于SSA格式的IR来做呢这两种不同的实现会有什么差异欢迎你在留言区发表你的看法。
参考资料
Introduction to the Go compiler 官方文档介绍了gc的主要结构。
Introduction to the Go compilers SSA backend 官方文档介绍了gc的SSA。
Go compiler internals: adding a new statement to Go - Part 1、Part2。在这两篇博客里作者做了一个实验如果往Go里面增加一条新的语法规则需要做哪些事情。你能贯穿性地了解一个编译器的方法。
Go compiler: SSA optimization rules description language这篇博客详细介绍了gc编译器的SSA优化规则描述语言的细节。
A Primer on Go Assembly和A Quick Guide to Gos Assembler 。gc编译器采用的汇编语言是它自己的一种格式是“伪汇编”。这两篇文章中有Go汇编的细节。

View File

@ -0,0 +1,368 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 MySQL编译器解析一条SQL语句的执行过程
你好我是宫文学。现在就到了我们编译之旅的最后一站了我们一起来探索一下MySQL编译器。
数据库系统能够接受SQL语句并返回数据查询的结果或者对数据库中的数据进行修改可以说几乎每个程序员都使用过它。
而MySQL又是目前使用最广泛的数据库。所以解析一下MySQL编译并执行SQL语句的过程一方面能帮助你加深对数据库领域的编译技术的理解另一方面由于SQL是一种最成功的DSL特定领域语言所以理解了MySQL编译器的内部运作机制也能加深你对所有使用数据操作类DSL的理解比如文档数据库的查询语言。另外解读SQL与它的运行时的关系也有助于你在自己的领域成功地使用DSL技术。
那么数据库系统是如何使用编译技术的呢接下来我就会花两讲的时间带你进入到MySQL的内部做一次全面的探秘。
今天这一讲我先带你了解一下如何跟踪MySQL的运行了解它处理一个SQL语句的过程以及MySQL在词法分析和语法分析方面的实现机制。
好,让我们开始吧!
编译并调试MySQL
按照惯例你要下载MySQL的源代码。我下载的是8.0版本的分支。
源代码里的主要目录及其作用如下我们需要分析的代码基本都在sql目录下它包含了编译器和服务端的核心组件。
图1MySQL的源代码包含的主要目录
MySQL的源代码主要是.cc结尾的也就是说MySQL主要是用C++编写的。另外也有少量几个代码文件是用C语言编写的。
为了跟踪MySQL的执行过程你要用Debug模式编译MySQL具体步骤可以参考这篇开发者文档。
如果你用单线程编译大约需要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你可以根据它的文档来生成示例数据库。
mysql> select first_name, last_name from employees; #从mysql库的user表中查询信息
这个命令被mysqld接收到以后就会触发断点并停止执行。这个时候客户端也会老老实实地停在那里等候从服务端传回数据。即使你在后端跟踪代码的过程会花很长的时间客户端也不会超时一直在安静地等待。给我的感觉就是MySQL对于调试程序还是很友好的。
在GDB中输入bt命令会打印出调用栈这样你就能了解一个SQL语句在MySQL中执行的完整过程。为了方便你理解和复习这里我整理成了一个表格
我也把MySQL执行SQL语句时的一些重要程序入口记录了下来这也需要你重点关注。它反映了执行SQL过程中的一些重要的处理阶段包括语法分析、处理上下文、引用消解、优化和执行。你在这些地方都可以设置断点。
图2MySQL执行SQL语句时的部分重要程序入口
好了现在你就已经做好准备能够分析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是一种特定领域语言DSLDomain 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中你会看到如下所示的规则片段
你可以看一下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来说明该优先级是上下文依赖的。
好了在了解了词法分析器和语法分析器以后我们接着来跟踪一下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语句它所形成的解析树如下
图3示例SQL解析后生成的解析树
而对于“select 2 + 3”这样一个做表达式计算的SQL语句所形成的解析树如下。你会看到它跟普通的高级语言的表达式的AST很相似
图4“select 2 + 3”对应的解析树
图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()等方法获取它的值。
图5解析树的树节点部分
由于SQL是一个个单独的语句所以select、insert、update等语句它们都各自有不同的根节点都是Parse_tree_root的子类。
图6解析树的根节点
好了现在你就已经了解了SQL的解析过程和它所生成的AST了。前面我说过MySQL采用的是LALR算法因此我们可以借助MySQL编译器来加深一下对LR算法家族的理解。
重温LR算法
你在阅读yacc.yy文件的时候在注释里你会发现如何跟踪语法分析器的执行过程的一些信息。
你可以用下面的命令,带上“-debug”参数来启动MySQL服务器
mysqld --debug="d,parser_debug"
然后你可以通过客户端执行一个简单的SQL语句“select 2+3*5”。在终端会输出语法分析的过程。这里我截取了一部分界面通过这些输出信息你能看出LR算法执行过程中的移进、规约过程以及工作区内和预读的信息。
我来给你简单地复现一下这个解析过程。
第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”这样的一些关键字但也有可能为空。
图7移进select后的DFA
第3步因为预读到的Token是一个数字NUM这说明select_options产生式一定生成了一个ε因为NUM是在select_options的Follow集合中。
这就是LALR算法的特点它不仅会依据预读的信息来做判断还要依据Follow集合中的元素。所以编译器做了一个规约也就是让select_options为空。
也就是编译器依据“select_options->ε”做了一次规约并进入了新的状态920。注意状态42和920从DFA的角度来看它们是同一个大状态。而DFA中包含了多个小状态分别代表了不同的规约情况。
图8基于“select_options->ε”规约后的DFA
你还需要注意这个时候老的状态都被压到了栈里所以栈里会有0和42两个状态。栈里的这些状态其实记录了推导的过程让我们知道下一步要怎样继续去做推导。
图9做完前3步之后栈里的情况
第4步移进NUM。这时又进入一个新状态720。
图10移进NUM后的DFA
而旧的状态也会入栈,记录下推导路径:
图11移进NUM后栈的状态
第5~8步依次依据NUM_literal->NUM、literal->NUM_literal、simple_expr->literal、bit_expr->simple_expr这四条产生式做规约。这时候编译器预读的Token是+号,所以你会看到,图中的红点停在+号前。
图12第8步之后的DFA
第9~10步移进+号和NUM。这个时候状态又重新回到了720。这跟第4步进入的状态是一样的。
图13第10步之后的DFA
而栈里的目前有5个状态记录了完整的推导路径。
图14第10步之后栈的状态
到这里其实你就已经了解了LR算法做移进和规约的思路了。不过你还可以继续往下研究。由于栈里保留了完整的推导路径因此MySQL编译器最后会依次规约回来把栈里的元素清空并且形成一棵完整的AST。
课程小结
这一讲我带你初步探索了MySQL编译SQL语句的过程。你需要记住几个关键点
掌握如何用GDB来跟踪MySQL的执行的方法。你要特别注意的是我给你梳理的那些关键的程序入口它是你理解MySQL运行过程的地图。
SQL语言是面向关系数据库的一种DSL它是声明式的并采用了领域特定的模型和术语可以为你设计自己的DSL提供启发。
MySQL的语法分析器是采用bison工具生成的。这至少说明语法分析器生成工具是很有用的连正式的数据库系统都在使用它所以你也可以大胆地使用它来提高你的工作效率。我在最后的参考资料中给出了bison的手册希望你能自己阅读一下做一些简单的练习掌握bison这个工具。
最后你一定要知道LR算法的运行原理知其所以然这也会更加有助于你理解和用好工具。
我依然把本讲的内容给你整理成了一张知识地图,供你参考和复习回顾:
一课一思
我们今天讲到了DSL的概念。你能分享一下你的工作领域中的DSL吗它们是否也是采用声明式的里面用到了哪些特定领域的术语欢迎在留言区分享。
感谢你的阅读。如果有收获,欢迎你把今天的内容分享给更多的朋友。
参考资料
MySQL的内行手册MySQL Internals Manual能提供一些重要的信息。但我发现文档内容经常跟源代码的版本不同步比如介绍源代码的目录结构的信息就过时了你要注意这点。
bison的手册。

View File

@ -0,0 +1,242 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 MySQL编译器编译技术如何帮你提升数据库性能
你好我是宫文学。今天这一讲我们继续来探究MySQL编译器。
通过上一讲的学习你已经了解了MySQL编译器是怎么做词法和语法分析的了。那么在做完语法分析以后MySQL编译器又继续做了哪些处理才能成功地执行这个SQL语句呢
所以今天我就带你来探索一下MySQL的实现机制我会把重点放在SQL的语义分析和优化机制上。当你学完以后你就能真正理解以下这些问题了
高级语言的编译器具有语义分析功能那么MySQL编译器也会做语义分析吗它有没有引用消解问题有没有作用域有没有类型检查
MySQL有没有类似高级语言的那种优化功能呢
让我们开始今天的探究吧。不过在讨论MySQL的编译过程之前我想先带你了解一下MySQL会用到的一些重要的数据结构因为你在解读代码的过程中经常会见到它们。
认识MySQL编译器的一些重要的数据结构
第一组数据结构是下图中的几个重要的类或结构体包括线程、保存编译上下文信息的LEX以及保存编译结果SELECT_LEX_UNIT和SELECT_LEX。
图1MySQL编译器的中的几个重要的类和结构体
首先是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对象
图2Table_indent对象代表一个表
而字段和表达式等表示一个值的对象用Item及其子类来表示。SQL语句中的每个字段、每个计算字段最后都对应一个Item。where条件其实也是用一个Item就能表示。具体包括
字段Item_field
各种常数包括数字、字符和null等Item_basic_constant
能够产生出值的运算Item_result_field包括算术表达式Item_num_op、存储过程Item_func_sp、子查询Item_subselect等。
在语法分析过程中产生的ItemParse_tree_item。它们是一些占位符因为在语法分析阶段不容易一下子创建出真正的Item这些Parse_tree_item需要在上下文分析阶段被替换成真正的Item。
图3Item及其子类
好了上面这些就是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表这个时候编译器就会发现这个表不存在。
你还可以再试一下“select fake_column from departments”这个语句也一样会查出fake_column并不是departments表中的一列。
那么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就可以查找上一级作用域中的表和字段。
图4MySQL用来表示作用域的对象
好了现在你就对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”编译器会把它优化为“select * from foo where x=2 and y=3”把“x”去掉了这是因为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输出的执行计划
从输出的执行计划里你能看出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语句编译成机器码会进一步提升数据库的性能并降低能耗。
我把相应的知识点总结成了思维导图,供你参考:
总结这两讲对MySQL所采用的编译技术介绍你会发现这样几个很有意思的地方
第一,编译技术确实在数据库系统中扮演了很重要的作用。
第二数据库编译SQL语句的过程与高级语言有很大的相似性它们都包含了词法分析、语法分析、语义分析和优化等处理。你对编译技术的了解能够指导你更快地看懂MySQL的运行机制。另外如果你也要设计类似的系统级软件这就是一个很好的借鉴。
一课一思
关系数据库是通过内置的DSL编译器来驱动运行的软件。那么你还知道哪些软件是采用这样的机制来运行的你如果去实现这样的软件能从MySQL的实现思路里借鉴哪些思路欢迎在留言区分享你的观点。
参考资料
如果要加深对MySQL内部机制的了解我推荐两本书一本是OReilly的《Understanding MySQL Internals》第二本是《Expert MySQL》。

View File

@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 课前导读:学习现代语言设计的正确姿势
你好,我是宫文学。
到目前为止你就已经学完了这门课程中前两个模块的所有内容了。在第一个模块“预备知识篇”我带你梳理了编译原理的关键概念、算法等核心知识点帮你建立了一个直观的编译原理基础知识体系在第二个模块“真实编译器解析篇”我带你探究了7个真实世界的编译器让你对编译器所实际采用的各种编译技术都有所涉猎。那么在接下来的第三个模块我会继续带你朝着提高编译原理实战能力的目标前进。这一次我们从计算机语言设计的高度来印证一下编译原理的核心知识点。
对于一门完整的语言来说,编译器只是其中的一部分。它通常还有两个重要的组成部分:一个是运行时,包括内存管理、并发机制、解释器等模块;还有一个是标准库,包含了一些标准的功能,如算术计算、字符串处理、文件读写,等等。
再进一步来看,我们在实现一门语言的时候,首先要做的,就是确定这门语言所要解决的问题是什么,也就是需求问题;其次,针对需要解决的问题,我们要选择合适的技术方案,而这些技术方案正是分别由编译器、运行时和标准库去实现的。
所以,从计算机语言设计的高度来印证编译原理知识,我们也能更容易理解编译器的任务,更容易理解它是如何跟运行时环境去做配合的,这也会让你进一步掌握编译技术。
好了,那接下来就一起来看看,到底用什么样的方式,我们才能真正理解计算机语言的设计思路。
首先,我们来聊一聊实现一门计算机语言的关键因素:需求和设计。
如何实现一门计算机语言?
我们学习编译原理的一个重要的目标就是能够实现一门计算机语言。这种语言可能是你熟悉的某些高级语言也可能是某个领域、为了解决某个具体问题而设计的DSL。就像我们在第二个模块中见到的SQL以及编译器在内部实现时用到的一些DSL如Graal生成LIR时的模式匹配规则、Python编译器中的ASDL解析器还有Go语言编译器中IR的重写规则等。
那么要如何实现一门优秀的语言呢?我们都知道,要实现一个软件,有两个因素是最重要的,一个是需求,一个是设计。计算机语言作为一种软件,具有清晰的需求和良好的设计,当然也是至关重要的。
我先来说说需求问题,也就是计算机语言要解决的问题。
这里你要先明确一件事,如果需求不清晰、目标不明确,那么想要实现这门语言其实是很难成功的。通常来说,我们不能指望任何一种语言是全能的,让它擅长解决所有类型的问题。所以,每一门语言都有其所要解决的针对性问题。
举个例子JavaScript如果单从设计的角度来看有很多细节值得推敲有不少的“坑”比如null、undefined和NaN几个值就很令人困惑你知道“null==undefined”的值是true还是false吗但是它所能解决的问题也非常清晰就是作为浏览器的脚本语言提供Web的交互功能。在这个方面它比同时期诞生的其他竞争技术如ActiveX和Java Applet都更具优势所以它才能胜出。
历史上的计算机语言都是像JavaScript那样在满足了那个时代的某个需求以后而流行起来的。其中根据“硅谷创业之父”保罗·格雷厄姆Paul Graham在《黑客与画家》中的说法这些语言往往是一个流行的系统的脚本。比如说C语言是Unix系统的脚本COBOL是大型机的脚本SQL是数据库系统的脚本JavaScript、Java和C#都是浏览器的脚本Swift和Objective-C是苹果系统的脚本Kotlin是Android的脚本。让一门语言成为某个流行的技术系统的脚本为这个生态提供编程支持就是一种定位很清晰的需求。
好,明确了语言的需求以后,我们再来说说设计问题。
设计是实现计算机语言这种软件所要做的技术选择。你已经看到,我们研究的不同语言,其实现技术都各有特点,分别服务于该语言的需求问题,或者说设计目标。
我还是拿JavaScript来举例子。JavaScript被设计成了一门解释执行的语言这样的话它就能很方便地嵌入到HTML文本中随着HTML下载和执行并且支持不同的客户端操作系统和浏览器平台。而如果是需要静态编译的语言就没有这么方便。
再进一步由于HTML下载完毕后JavaScript就要马上执行从而也对JavaScript的编译速度有了更高的要求所以我们才会看到V8里面的那些有利于快速解析的技术比如通过查表做词法分析、懒解析等。
另外因为JavaScript早期只是在浏览器里做一些比较简单的工作所以它一开始没有设计并发计算的能力。还有由于每个页面运行的JavaScript都是单独的并且在页面退出时就可以收回内存因此JavaScript的垃圾收集功能也不需要太复杂。
作为对比Go语言的设计主要是用来编写服务端程序的那么它的关键特性也是与这个定位相适应。
并发服务端的软件最重要的一项能力就是支持大量并发任务。Go在语言设计上把并发能力作为第一等级的语言要素。
垃圾收集由于垃圾收集会造成整个应用程序停下来所以Go语言特别重视降低由于垃圾收集而产生的停顿。
那么总结起来,我们要想成功地实现一门语言,要把握两个要点:第一,要弄清楚该语言的需求,也就是它要去解决的问题;第二,要确定合适的技术方案,来更好地解决它要面对的问题。
计算机语言的设计会涉及到比较多的内容,为了防止你在学习时抓不到重点,我在第三个模块里,挑了一些重点的内容来做讲解,比如前面提到的垃圾收集的特性等。我会以第二个模块所研究的多门语言和编译器作为素材,一起探讨一下,各门语言都是采用了什么样的技术方案来满足各自的设计目标的,从而让你对计算机语言设计所考虑的因素、编译技术如何跟其他相关技术打配合,形成一个宏观的认识。
“现代语言设计篇”都会讲哪些内容?
这个模块的内容,我根据计算机语言的组成和设计中的关键点,将其分成了三个部分。
第一部分,是对各门语言的编译器的前端、中端和后端技术做一下对比和总结。
这样,通过梳理和总结,我们就可以找出各种编译器之间的异同点。对于其共同的部分,我们可以看作是一些最佳实践,你在自己的项目中可以大胆地采用;而有差异的部分,则往往是某种编译器为了实现该语言的设计目标而采用的技术策略,你可以去体会各门语言是如何做取舍的,这样也能变成你自己的经验储备。
第二部分,主要是对语言的运行时和标准库的实现技术做一个解析。
我们说过一门语言要包括编译器、运行时和标准库。在学习第二个模块的时候你应该已经有了一些体会你能发现编译器的很多特性是跟语言的运行时密切相关的。比如Python有自己独特的对象体系的设计那么Python的字节码就体现了对这些对象的操作字节码中的操作数都是对象的引用。
那么在这一部分,我就分为了几个话题来进行讲解:
第一,是对语言的运行时和标准库的宏观探讨。我们一起来看看不同的语言的运行时和它的编译器之间是如何互相影响的。另外,我还会和你探讨语言的基础功能和标准库的实现策略,这是非常值得探讨的知识点,它让一门语言具备了真正的实用价值。
第二,是垃圾收集机制。本课程分析、涉及的几种语言,它们所采用的垃圾收集机制都各不相同。那么,为什么一门语言会选择这个机制,而另一种语言会选择另一种机制呢?带着这样的问题所做的分析,会让你把垃圾收集方面的原理落到实践中去。
第三是并发模型。对并发的支持对现代语言来说也越来越重要。在后面的课程中我会带你了解线程、协程、Actor三种并发模式理解它们的优缺点同时你也会了解到如何在编译器和运行时中支持这些并发特性。
第三部分是计算机语言设计上的4个高级话题。
第一是元编程技术。元编程技术是一种对语言做扩展的技术相当于能够定制一门语言从而更好地解决特定领域的问题。Java语言的注解功能、Python的对象体系的设计都体现了元编程功能。而Julia语言更是集成了Lisp语言在元编程方面的强大能力。因此我会带你了解一下这些元编程技术的具体实现机制和特点便于你去采纳和做好取舍。
第二是泛型编程技术。泛型或者说参数化类型大大增强了现代语言的类型体系使得很多计算逻辑的表达变得更简洁。最典型的应用就是容器类型比如列表、树什么的采用泛型技术实现的容器类型能够方便地保存各种数据类型。像Java、C++和Julia等语言都支持泛型机制但它们各自实现的技术又有所不同。我会带你了解这些不同实现技术背后的原因以及各自的特点。
第三是面向对象语言的实现机制。面向对象特性是当前很多主流语言都支持的特性。那么要在编译器和运行时上做哪些工作来支持面向对象的特性呢对象在内存里的表示都有哪些不同的方式如何实现继承和多态的特性为什么Java支持基础数据类型和对象类型而有些语言中所有的数据都是对象要在编译技术上做哪些工作来支持纯面向对象特性这些问题我会花一讲的时间来带你分析清楚让你理解面向对象语言的底层机制。
第四是函数式编程语言的实现机制。函数式编程这个范式出现得很早不少人可能不太了解或者不太关注它但最近几年出现了复兴的趋势。像Java等面向对象语言也开始加入对函数式编程机制的支持。在第三个模块中我会带你分析函数式编程的关键特征比如函数作为一等公民、不变性等并会一起探讨函数式编程语言实现上的一些关键技术比如函数类型的内部表示、针对函数式编程特有的优化算法等让你真正理解函数式编程语言的底层机制。
该模块的最后一讲,也是本课程的最后一讲,是对我们所学知识的一个综合检验。这个检验的题目,就是解析方舟编译器。
方舟编译器应该是第一个引起国内IT界广泛关注的编译器。俗话说外行看热闹内行看门道。做一个编译器到底有哪些关键的技术点它们在方舟编译器里是如何体现的我们在学习了编译原理的核心基础知识在考察了多个编译器之后应该能够有一定的能力去考察方舟编译器了。这也是学以致用、紧密结合实际的表现。通过这样的分析你能了解到中国编译技术崛起的趋势甚至还可能会思考如何参与到这个趋势中来。这一讲我希望同学们都能发表自己的看法而我的看法呢只是一家之言你作为参考就好了。
小结
总结一下。咱们课程的名称是《编译原理实战课》,而最体现实战精神的,莫过于去实现一门计算机语言了。而在第三个模块,我就会带你解析实现一门计算机语言所要考虑的那些关键技术,并且通过学习,你也能够根据语言的设计目标来选择合适的技术方案。
从计算机语言设计的高度出发,这个模块会带你对编译原理形成更全面的认知,从而提高你把编译原理用于实战的能力。

View File

@ -0,0 +1,324 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 前端总结:语言设计也有人机工程学
你好,我是宫文学。
正如我在上一讲的“课程导读”中所提到的,在“现代语言设计篇”,我们会开始探讨现代语言设计中的一些典型特性,包括前端、中后端、运行时的特性等,并会研究它们与编译技术的关系。
今天这一讲我先以前面的“真实编译器解析篇”所分析的7种编译器作为基础来总结一下它们的前端技术的特征为你以后的前端工作做好清晰的指引。
在此基础上我们还会进一步讨论语言设计方面的问题。近些年各种新语言都涌现出了一个显著特征那就是越来越考虑对程序员的友好性运用了人机工程的思维。比如说自动类型推导、Null安全性等。那么在这里我们就一起来分析一下要支持这些友好的语法特征在编译技术上都要做一些什么工作。
好,首先,我们就来总结一下各个编译器的前端技术特征。
前端编译技术总结
通过前面课程中对7个编译器的解读分析我们现在已经知道了编译器的前端有一些共性的特征包括手写的词法分析器、自顶向下分析为主的语法分析器和差异化的语义分析功能。
手写的词法分析器
我们分析的这几个编译器,全部都采用了手写的词法分析器。主要原因有几个:
第一,手写的词法分析实现起来比较简单,再加上每种语言的词法规则实际上是大同小异的,所以实现起来也都差不多。
第二手写词法分析器便于做一些优化。典型的优化是把关键字作为标识符的子集来识别而不用为识别每个关键字创建自动机。V8的词法分析器还在性能上做了调优比如判断一个字符是否是合法的标识符字符是采用了查表的方法以空间换性能提高了解析速度。
第三,手写词法分析器便于处理一些特殊的情况。在 MySQL的词法分析器中我们会发现它需要根据当前字符集来确定某个字符串是否是合法的Token。如果采用工具自动生成词法分析器则不容易处理这种情况。
结论:如果你要实现词法分析器,可以参考这些编译器,来实现你自己手写的版本。
自顶向下分析为主的语法分析器
在“解析篇”中,我们还见到了多个语法分析器。
手写 vs 工具生成
在前面解析的编译器当中大部分都是手写的语法分析器只有Python和MySQL这两个是用工具生成的。
一方面手写实现能够在某些地方做一些优化的实现比如在Java语言里我们可以根据需要预读一到多个Token。另外手写实现也有利于编译错误的处理这样可以尽量给用户提供更友好的编译错误信息并且当一个地方发生错误以后也能尽量不影响对后面的语句的解析。手写的语法分析器在这些方面都能提供更好的灵活性。
另一方面Python和MySQL的编译器也证明了用工具生成的语法分析器也是完全可以用于高要求的产品之中的。所以如果你的项目时间和资源有限你要优先考虑用工具生成语法分析器。
自顶向下 vs 自底向上
我们知道,语法分析有两大算法体系。一是自顶向下,二是自底向上。
从我们分析过的7种编译器里可以发现自顶向下的算法体系占了绝对的主流只有MySQL的语法分析器采用的是自底向上的LALR算法。
而在自顶向下的算法中又几乎全是采用了递归下降算法Java、JavaScript和Go三大语言的编译器都是如此。并且对于左递归这个技术点我们用标准的改写方法就可以解决。
不过我们还看到了自顶向下算法和自底向上算法的融合。Java语言和Go语言在处理二元表达式时引入了运算符优先级解析器从而避免了左递归问题并且在处理优先级和结合性的问题上也会更加容易。而运算符优先级解析器实际上采用的是一种LR算法。
差异化的语义分析功能
不同编译器的语义分析功能有其共性,那就是都要建立符号表、做引用消解。对于静态类型的语言来说,还一定要做类型检查。
语义分析最大的特点是上下文相关AST加上这些上下文相关的关系就从树变成了图。由于处理图的算法一般比较复杂这就给引用消解带来了困难因此我们在算法上必须采用一定的启发式规则让算法简化。
比如,我们可以先把类型加入符号表,再去消解用到这些类型的地方:变量声明、方法声明、类继承的声明,等等。你还需要注意的是,在消解本地变量的时候,还必须一边消解,一边把本地变量加入符号表,这样才能避免形成错误的引用关系。
不过在建立符号表并做完引用消解以后上下文相关导致的复杂性就被消除了。所以后续的语义分析算法我们仍然可以通过简单地遍历AST来实现。所以你会看到这些编译器当中大量的算法都是实现了Visitor模式。
另外除了建立符号表、做引用消解和类型检查等语义分析功能不同的编译器还要去处理自己特有的语义。比如说Java编译器花了很多的工作量在处理语法糖上还有对注解的处理上Julia的编译器会去做类型推断Python的编译器会去识别变量的作用域范围等等。
这其中很多的语义处理功能都是为了支持更加友好的语言特性比如Java的语法糖。在现代语言中还增加了很多的特性能够让程序员的编程工作更加容易。接下来我就挑几个共性的特性跟你一起探讨一下它们的实现。
支持友好的语言特性
自动类型推导、Null安全性、通过语法糖提高语法的友好性以及提供一些友好的词法规则等等。这些都是现代语言努力提高其友好性的表现。
自动类型推导
自动类型推导可以减少编程时与类型声明有关的工作量。我们来看看下面这几门语言,都是如何声明变量的。
C++语言是一门不断与时俱进的语言。在C++ 11中采用了auto关键字做类型推导。比如
int a = 10;
auto b = a; //能够自动推导b的类型是int
cout << typeid(b).name() << endl; //输出int
你可能会觉得这看上去似乎也没啥呀把int换成了auto好像并没有省多少事儿。但在下面这个例子中你会发现用于枚举的变量的类型很长std::vector<std::string>::iterator那么你就大可以直接用一个auto来代替省了很多事代码也更加整洁。所以实际上auto关键字也成为了在C++中使用枚举器的标准用法:
std::vector<std::string> vs;
for(std::vector<std::string>::iterator i=vs.begin(); i!=vs.end();i++){
//...
}
//使用auto以后简化为
fora(auto i=vs.begin(); i!=vs.end();i++){
//...
}
我们接着来看看其他的语言,都是如何做类型推导的。
Kotlin中用var声明变量也支持显式类型声明和类型推导两种方式。
var a : Int = 10; //显式声明
var b = 10; //类型推导
Go语言会用“:=” 让编译器去做类型推导:
var i int = 10; //显示声明
i := 10; //类型推导
而Swift语言是这样做的
let a : Int = 10; //常量类型显式声明
let b = 10; //常量类型推导
var c : Int = 10; //变量类型显式声明
var c = 10; //变量类型推导
实际上连Java语言也在Java 10版本加上了类型推导功能比如
Map<String, User> a = new HashMap<String, User>(); //显式声明
var b = new HashMap<String, User>(); //类型推导
你在学习了语义分析中,基于属性计算做类型检查的机制以后,就会发现实现类型推导,其实是很容易的。只需要把等号右边的初始化部分的类型,赋值给左边的变量就行了。
可以看到,在不同的编译器的实现当中,类型推导被如此广泛地接受,所以如果你要设计一门新的语言,你也一定要考虑类似的做法。
我们接着再来探讨下一个有趣的特性它叫做“Null安全性”。
Null安全性
在C++和Java等语言里会用Null引用来表示某个变量没有指向任何对象。这个特性使得语言里充满了Null检查否则运行时就会报错。
给你举个例子。下面这段代码中我们想要使用student.teacher.name这个成员变量因此程序要逐级检查student、teacher和name是否为Null。不检查又不行检查又太啰嗦。你在自己写程序的时候肯定也遇到过这种困扰。
if (student != null
&& student.teacher != null
&& student.teacher.name !=null){
...
}
Null引用其实是托尼·霍尔Tony Hoare在1960年代在设计某一门语言ALGOL W时引入的后来也纷纷被其他语言所借鉴。但Hoare后来却认为这是一个“价值亿万美元的错误”你可以看看他在QCon上的演讲。因为大量的软件错误都是由Null引用引起的而计算机语言的设计者本应该从源头上消除它。
其实我觉得Hoare有点过于自责了。因为在计算机语言发展的早期很多设计决定的后果都是很难预料的当时的技术手段也很有限。而在计算机语言已经进化了这么多年的今天我们还是有办法消除或者减少Null引用的不良影响的。
以Kotlin为例在缺省情况下它不允许你把Null赋给变量因此这些变量就不需要检查是否为Null。
var a : String = "hello";
a = null; //报编译错误
不过有的时候你确实需要用到Null那该怎么办
你需要这样的声明变量,在类型后面带上问号,告诉编译器这个变量可为空:
var a : String? = "hello";
a = null; //OK
但接下来如果你要使用a变量就必须进行Null检查。这样编译器会跟踪你是否做了所有的检查。
val l = b.length; //编译器会报错因为没有做null检查
if (b != null){
println(b.length); //OK因为已经进行了null检查
}
或者你可以进行安全调用Safe Call采用“?.”操作符来访问b.length其返回值是一个Int?类型。这样的话即使b是Null程序也不会出错。
var l : Int? = b?.length;
并且如果你下一步要使用l变量的话就要继续进行Null的检查。编译器会继续保持跟踪让整个过程不会有漏洞。
而如果你对一个本身可能为Null的变量赋值编译器会生成Null检查的代码。如果该变量为Null那么赋值操作就会被取消。
在下面的示例代码中如果student或是teacher或者是name的值为Null赋值操作都不会发生。这大大减少了那种啰嗦的Null检查
student?.teacher?.name=course.getTeacherName();
你可以看到Kotlin通过这样的机制就大大降低了Null引用可能带来的危害也大大减少了Null检查的代码量简直是程序员的福音。
而且不仅是Kotlin语言具有这个特性Dart、Swift、Rust等新语言都提供了Null安全性。
那么Null安全性在编译器里应该怎样实现呢
最简单的你可以给所有的类型添加一个属性Nullable。这样就能区分开Int?和Int类型因为对于后者来说Null不是一个合法的取值。之后你再运用正常的属性计算的方法就可以实现Null安全性了。
接下来,我们再看看现代语言会采用的一些语法糖,让语法更友好。
一些友好的语法糖
1.分号推断
分号推断的作用是在编程的时候让程序员省略掉不必要的分号。在Java语言中我们用分号作为一个语句的结尾。而像Kotlin等语言在一个语句的最后可以加分号也可以不加。但如果两个语句在同一行那么就要加分号了。
2.单例对象
在程序中我们经常使用单例的数据模式。在Java、C++等语言中你需要写一些代码来确保只生成类的一个实例。而在Scala、Kotlin这样的语言中可以直接声明一个单例对象代码非常简洁
object MyObject{
var field1...
var field2...
}
3.纯数据的类
我们在写程序的时候经常需要处理一些纯粹的数据对象比如数据库的记录等。而如果用传统的类可能编写起来会很麻烦。比如使用Java语言的话你需要为这些类编写toString()方法、hashCode()方法、equals()方法还要添加很多的setter和getter方法非常繁琐。
所以在JDK 14版本就增加了一个实验特性它可以支持Record类。比如你要想定义一个Person对象只需要这样一句话就行了
public record Person(String firstName, String lastName, String gender, int age){}
这样一个语句,就相当于下面这一大堆语句:
public final class Person extends Record{
private final String firstName;
private final String lastName;
private final String gender;
private final int age;
public Person(String firstName, String lastName, String gender, int age){
this.firstName = firstName;
this.lastName = lastName;
this.gender = gender;
this.age = age;
}
public String getFirstName(){
return this.firstName;
}
public String getLastName(){
return this.lastName;
}
public String getGender(){
return this.gender;
}
public String getAge(){
return this.age;
}
pulic String toString(){
...
}
public boolean equals(Object o){
...
}
public int hashCode(){
...
}
}
所以你可以看到Record类真的帮我们省了很多的事儿。Kotlin也有类似的data class而Julia和Swift内置支持元组对纯数据对象的支持也比较好。
4.没有原始类型,一切都是对象
像Java、Go、C++、JavaScript等面向对象的语言既要支持基础的数据类型如整型、浮点型又要支持对象类型它们对这两类数据的使用方式是不一致的因此也就增加了我们的编程负担。
而像Scala、Kotlin等语言它们可以把任何数据类型都看作是对象。比如在Kotlin中你可以直接调用一个整型或浮点型数字的方法
不过你要注意的是如果你要把基础数据类型也看作是对象在编译器的实现上要做一些特殊的处理因为如果把这些基础数据当作普通对象一样保存在堆里那显然要占据太多的空间你可以回忆一下Java对象头所需要的空间并且访问性能也更低。
那么要如何解决这些问题呢?这里我先留一个伏笔,我们在“综合实现(一):如何实现面向对象编程?”这一讲再来讨论吧!
除了语法上的一些友好设计之外,一些现代语言还在词法规则方面,提供了一些友好的设计。我们一起来看一下。
一些友好的词法规则
1.嵌套的多行注释
编程语言一般都支持多行注释。比如,你可以把暂时用不到的一段代码给注释起来。这些代码里如果有单行注释也不妨碍。
但是像Kotlin、Swift这些语言又更进了一步它们可以支持在多行注释里嵌套多行注释。这是一个很贴心的功能。这样的话你就可以把连续好几个函数或方法给一起注释掉。因为函数或方法的头部一般都有多行的头注释。支持嵌套注释的话我们就可以把这些头注释一起包含进去。
你可以去看看它们的词法分析器中处理注释的逻辑,了解下它们是如何支持嵌套的多行注释的。
2.标识符支持Unicode
现代的大部分语言都支持用Unicode来声明变量甚至可以声明函数或类。这意味着什么呢你可以用中文来声明变量和函数名称。而对于科学工作者来说你也可以使用π、α、β、θ这些希腊字母会更符合自己的专业习惯。下面是我在Julia中使用Unicode的情况
3.多行字符串字面量
对于字符串字面量来说支持多行的书写方式也会给我们的编程工作带来很多的便利。比如假设你要把一个JSON字符串或者一个XML字符串赋给一个变量用多行的书写方式会更加清晰。如下所示
现在,很多的编程语言都可以支持多行的字符串字面量,比如:
课程小结
今天这一讲,我带你一起总结了一下编译原理的前端技术。在解析了这么多个编译器以后,你现在对于实现前端功能时,到底应该选择什么技术、不同的技术路线有什么优缺点,就都心里有数了。
另外,很多我们可以感知得到的现代语言特性,都是一些前端的功能。比如,更友好的词法特性、更友好的语法特性,等等。你可以借鉴当前语言的一些最佳实践。以你现在的知识积累来说,理解上述语言特性在前端的实现过程,应该不难了。如果你对哪个特性特别感兴趣,也可以按照课程的思路,去直接研究它的编译器。
最后,我把本讲的思维导图也整理了出来,供你参考:
一课一思
你比较推崇哪些友好的前端语言特性?它们是怎么实现的?欢迎在留言区分享你的看法。另外,如果你觉得哪些前端特性的设计是失败的,也可以拿来探讨,我们共同吸取教训。
感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,279 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 中端总结:不遗余力地进行代码优化
你好,我是宫文学。
今天这一讲我继续带你来总结前面解析的7种真实的编译器中中端部分的特征和编译技术。
在课程的第1讲我也给你总结过编译器的中端的主要作用就是实现各种优化。并且在中端实现的优化基本上都是机器无关的。而优化是在IR上进行的。
所以,今天这一讲,我们主要来总结以下这两方面的问题:
第一是对IR的总结。我在第6讲中曾经讲过IR分为HIR、MIR和LIR三个层次可以采用线性结构、图、树等多种数据结构。那么基于我们对实际编译器的研究再一起来总结一下它们的IR的特点。
第二是对优化算法的总结。在第7讲我们把各种优化算法做了一个总体的梳理。现在就是时候来总结一下编译器中的实际实现了。
通过今天的总结你能够对中端的两大主题IR和优化形成更加深入的理解从而更有利于你熟练运用编译技术。
好了我们先来把前面学到的IR的相关知识来系统地梳理和印证一下吧。
对IR的总结
通过对前面几个真实编译器的分析我们会发现IR方面的几个重要特征SSA已经成为主流Sea of Nodes展现出令人瞩目的优势另外一个编译器中的IR既要能表示抽象度比较高的操作也要能表示抽象度比较低的、接近机器码的操作。
SSA成为主流
通过学习前面的课程我们会发现符合SSA格式的IR成为了主流。Java和JavaScript的Sea of Nodes是符合SSA的Golang是符合SSA的Julia自己的IR虽然最早不是SSA格式的但后来也改成了SSA而Julia所采用的LLVM工具其IR也是SSA格式的。
SSA意味着什么呢源代码中的一个变量会变成多个版本每次赋值都形成一个新版本。在SSA中它们都叫做一个值Value对变量的赋值就是对值的定义def。这个值定义出来之后就可以在定义其他值的时候被使用use因此就形成了清晰的“使用-定义”链use-def
这种清晰的use-def链会给优化算法提供很多的便利
如果一个值定义了,但没有被使用,那就可以做死代码删除。
如果对某个值实现了常数折叠那么顺着def-use链我们就可以马上把该值替换成常数从而实现常数传播。
如果两个值的定义是一样的那么这两个值也一定是一样的因此就可以去掉一个从而实现公共子表达式消除而如果不采取SSA实现CSE公共子表达式消除需要做一个数据流分析来确定表达式的变量值并没有发生变化。
针对最后一种情况也就是公共子表达式消除我再给你展开讲解一下让你充分体会SSA和传统IR的区别。
我们知道基于传统的IR要做公共子表达式消除就需要专门做一个“可用表达式”的分析。像下图展示的那样每扫描一遍代码就要往一个集合里增加一个可用的表达式。
为什么叫做可用表达式呢因为变量可能被二次赋值就像图中的变量c那样。在二次赋值以后之前的表达式“c:=a+b”就不可用了。
图1变量c二次赋值后各个位置的可用表达式集合
在后面当替换公共子表达式的时候我们可以把“e:=a+b”替换成“e:=d”这样就可以少做一次计算实现了优化的目标。
而如果采用SSA格式上面这几行语句就可以改写为下图中的样子
图2用SSA格式的IR改写的程序
可以看到原来的变量c被替换成了c1和c2两个变量而c1、d和e右边的表达式都是一样的并且它们的值不会再发生变化。所以我们可以马上消除掉这些公共子表达式从而减少了两次计算这就比采用SSA之前的优化效果更好了。最重要的是整个过程根本不需要做数据流分析。
图3把公共子表达式a+b消除掉
在掌握了SSA格式的特点以后我们还可以注意到Java和JavaScript的两大编译器在做优化时竟然都不约而同地用到了Sea Of Nodes这种数据结构。它看起来非常重要所以我们再接着来总结一下符合SSA格式的Sea of Nodes都有什么特点。
Sea of Nodes的特点总结
其实在解析Graal编译器的时候我就提到过Sea of Nodes的特点是把数据流图和控制流图合二为一从而更容易实现全局优化。因为采用这种IR代码并没有一开始就被限制在一个个的基本块中。直到最后生成LIR的环节才会把图节点Schedule到各个基本块。作为对比采用基于CFG的IR优化算法需要让代码在基本块内部和基本块之间移动处理起来会比较复杂。
在这里我再带你把生成IR的过程推导一遍你能从中体会到生成Sea of Nodes的思路并且还会有一些惊喜的发现。
示例函数或方法是下面这样:
int foo(int b){
a = b;
c = a + b;
c = b;
d = a + b;
e = a + b;
return e;
}
那么为它生成IR图的过程是怎么样的呢
第1步对参数b生成一个节点。
第2步对于a=b这里并没有形成一个新的值所以在后面在引用a和b的时候都指向同一个节点就好。
第3步对于c=a+b生成一个加法节点从而形成一个新的值。
第4步对于c=b实际上还是直接用b这个节点就行了并不需要生成新节点。
第5步和第6步对于d=a+b和e=a+b你会发现它们都没有生成新的值还是跟c1用同一个节点表示就行。
第7步对于return语句这时候生成一个return节点返回上面形成的加法节点即可。
从这个例子中你发现了什么呢原来采用Sea of Nodes作为IR在生成图的过程中顺带就可以完成很多优化了比如可以消除公共子表达式。
所以我希望通过上面的例子你能进一步抓住Sea of Nodes这种数据结构的特点。
但是Sea of Nodes只有优点没有缺点吗也不是的。比如
你在检查生成的IR、阅读生成的代码的时候都会更加困难。因为产生的节点非常多会让你头晕眼花。所以这些编译器都会特别开发一个图形化的工具来帮助我们更容易看清楚IR图的脉络。
对图的访问,代价往往比较大。当然这也可以理解。因为你已经知道,对树的遍历是比较简单的,但对图的遍历算法就要复杂一些。
还有,当涉及效果流的时候,也就是存在内存读写等操作的时候,我们对控制流做修改会比较困难,因为内存访问的顺序不能被打乱,除非通过优化算法把固定节点转换成浮动节点。
总体来说Sea of Nodes的优点和缺点都来自图这种数据结构。一方面图的结构简化了程序的表达另一方面要想对图做某些操作也会更困难一些。
从高到低的多层次IR
对于IR来说我们需要总结的另一个特点就是编译器需要从高到低的多个层次的IR。在编译的过程中高层次的IR会被不断地Lower到低层次的IR直到最后翻译成目标代码。通过这样层层Lower的过程程序的语义就从高级语言一步步变到了汇编语言中间跨越了巨大的鸿沟
高级语言中对一个数组元素的访问,到汇编语言会翻译成对内存地址的计算和内存访问;
高级语言中访问一个对象的成员变量,到汇编语言会以对象的起始地址为基础,计算成员变量相对于起始地址的偏移量,中间要计算对象头的内存开销;
高级语言中对于本地变量的访问,到汇编语言要转变成对寄存器或栈上内存的访问。
在采用Sea of Nodes数据结构的时候编译器会把图中的节点从代表高层次语义的节点逐步替换到代表低层次语义的节点。
以TurboFan为例它的IR就包含了几种不同层次的节点
抽象度最高的是复杂的JavaScript节点
抽象度最低的,是机器节点;
在两者之间的,是简化的节点。
伴随着编译的进程我们有时还要进行IR的转换。比如GraalVM会从HIR转换到LIR而Julia的编译器则从自己的IR转换成LLVM的IR另外在LLVM的处理过程中其IR的内部数据结构也进行了切换。一开始使用的是便于做机器无关的优化的结构之后转换成适合生成机器码的结构。
总结完了IR我们再来看看编译器对IR的处理比如各种分析和优化算法。
对优化算法的总结
编译器基于IR主要做了三种类型的处理。第一种处理就是我们前面说的层层地做Lower。第二种处理就是对IR做分析比如数据流分析。第三种处理就是实现各种优化算法。编译器的优化往往是以分析为基础。比如活跃性分析是死代码消除的基础。
前面我也说过编译器在中端所做的优化基本上都是机器无关的优化。那么在考察了7种编译器以后我们来总结一下这些编译器做优化的特点。
第一,有些基本的优化,是每个编译器都会去实现的。
比如说,我们见过的常数折叠、代数简化、公共子表达式消除等。这些优化还可能发生在多个阶段,比如从比较早期的语义分析阶段,到比较晚期的基于目标代码的窥孔优化,都使用了这些优化算法。
第二,对于解释执行的语言,其编译器能做的优化是有限的。
前面我们见到了代码在JVM的解释器、Python的解释器、V8的解释器中运行的情况现在我们来总结一下它们的运行时的特点。
Python对代码所做的优化非常有限在解释器中执行的性能也很低。最重要的原因是所有的类型检查都是在运行期进行的并且会根据不同的类型选择执行不同的功能。另外Python所有的对象都是在堆里申请内存的没有充分利用栈来做基础数据类型的运算这也导致了它的性能损耗。
JVM解释执行的性能要高一些因为Java编译器已经做了类型检查并针对不同数据类型生成了不同的指令。但它只做了一些简单的优化一些无用的代码并没有被消除掉对Java程序性能影响很大的内联优化和逃逸分析也都没有做。它基于栈的运行机制也没有充分发挥寄存器的硬件能力。
V8的Ignition解释器在利用寄存器方面要比JVM的解释器有优势。不过它的动态类型拖了后腿这跟Python是一样的。
第三,对于动态类型的语言,优化编译的首要任务是做类型推断。
以V8的TurboFan为例它对类型信息做不同的推断的时候优化效果是不同的。如果你一开始运行程序就逼着TurboFan马上做编译那么TurboFan其实并不知道各个变量的类型信息因此只能生成比较保守的代码它仍然是在运行时进行类型检查并执行不同的逻辑。
而一旦通过运行积累了一定的统计数据TurboFan就可以大胆地做出类型的推断从而生成针对某种类型的优化代码。不过它也一定要为自己可能产生的推理错误做好准备在必要的时候执行逆优化功能。
Julia也是动态类型的语言但它采取了另一个编译策略。它会为一个函数不同的参数类型组合编译生成对应的机器码。在运行时根据不同的函数参数分派到不同的函数版本上去执行从而获得高性能。
第四JIT编译器可以充分利用推理性的优化机制这样既节省了编译时间又有可能带来比AOT更好的优化效果。
第五,对于面向对象的语言,内联优化和逃逸分析非常重要。
在分析Graal编译器和V8的TurboFan编译器的过程中我都特别强调了内联优化和逃逸分析的作用。内联优化不仅能减少对若干短方法调用的开销还能导致进一步的优化而逃逸分析能让很多对象在栈上申请内存并实现标量替换、锁消除等优化从而获得极大的性能提升。
第六,对于静态类型的语言,不同的编译器的优化程度也是不同的。
很多工程师经常会争论哪个语言的性能更高。不过在学了编译原理之后,其实可以发现这根本不用争论。你可以设计一些示例程序,测试不同的编译器优化后生成的汇编代码,从而自己得出结论。
现在我用一个示例程序来带你测试一下Graal、Go和Clang三个编译器处理数组加法的效率你可以借此了解一下它们各自的优化力度特别是看看它们有没有自动向量化的支持并进一步了解不同语言的运行机制。
首先来看看Java示例代码在SIMD.java中。其中的add方法是把一个数组的所有值汇总。
private static int add(int a[]){
int sum = 0;
for (int i=0; i<a.length; i++){
sum = sum + a[i];
}
return sum;
}
我们还是用Graal做即时编译并打印出生成的汇编代码。这里我截取了其中的主要部分给你做了分析
分析这段汇编代码,你能得到下面的信息:
Java中的数组其头部在64位环境下占据16个字节其中包含了数组长度的信息。
Java生成的汇编代码在每次循环开始的时候都要检查下标是否越界。这是一个挺大的运算开销。其实我们使用的数组下标i永远不会越界所以本来可以优化得更好。
上述汇编代码并没有使用SIMD指令没有把循环自动向量化。
我们再来看一下Go语言的优化效果示例代码在SIMD.go中。
package main
func add(a []int) int {
sum := 0;
for i:=0; i<len(a); i++{
sum = sum + a[i]
}
return sum;
}
我们生成Go语言特有的伪汇编以后是下面这个样子
我们拿它跟Graal生成的汇编代码做比较会发现其中最重要的差别是Go的编译器消除了下标检查这是一个挺大的进步能够提升不少的性能。不过你也可以测试一下当代码中的“len(a)”替换成随意的一个整数的时候Go的编译器会生成什么代码。它仍然会去做下标检查并在下标越界的时候报错。
不过令人遗憾的是Go语言的编译器仍然没有自动生成向量化的代码。
最后我们来看一下Clang是如何编译同样功能的一个C语言的程序的SIMD.c
int add(int a[], int length){
int sum = 0;
for (int i=0; i<length; i++){
sum = sum + a[i];
}
return sum;
}
编译生成的汇编代码在SIMD.s中。我截取了其中的骨干部分
你已经知道Clang是用LLVM做后端的。在它生成的汇编代码中对循环做了三种优化
自动向量化用movdqu指令一次能把4个整数也就是16个字节、128位数据拷贝到寄存器。用paddd指令可以一次实现4个整数的加法。
循环展开汇编代码里在一次循环里执行了8次SIMD的add指令因为每次相当于做了4个整数的加法因此每个循环相当于做了源代码中32次循环的工作。
指令排序你能看到由于一个循环中有很多个指令所以这就为指令排序提供了机会。另外你还能看到在这段汇编代码中集中做了多次的movdqu操作这可以更好地让指令并行化。
通过这样的对比你会发现LLVM做的优化是最深入的。所以如果你要做计算密集型的软件如果能做到像LLVM这样的优化程度那就比较理想了。
不过做比较深入的优化也不是没有代价的那就是编译时间会更长。而Go语言的编译器在设计之初就把编译速度当成了一个重要的目标因此它没有去实现自动向量化的功能也是可以理解的。
如果你要用Go语言开发软件又需要做密集的计算那么你有两个选择。一是用Go语言提供的内置函数intrincics去实现计算功能这些内置函数是直接用汇编语言实现的。二是Go语言也提供了一个基于LLVM的编译器你可以用这个编译器来获得更好的优化效果。
课程小结
这一讲我带你全面系统地总结了一下“解析篇”中各个实际编译器的IR和优化算法。通过这样的总结你会对如何设计IR、如何做优化有一个更加清晰的认识。
从IR的角度来看你一定要采用SSA格式的IR因为它有显著的优点没有理由不采用。不过如果你打算自己编写各种优化算法也不妨进一步采用Sea of Nodes这样的数据结构并借鉴Graal和V8的一些算法实现。
不过自己编写优化算法的工作量毕竟很大。在这种情况下你可以考虑复用一些后端工具包括LLVM、GraalVM和GCC。
本讲的思维导图我也放在了下面,供你参考:
一课一思
今天我带你测试了Graal、Go和Clang三个编译器在SIMD方面编译结果的差异。那么你能否测试一下这几个编译器在其他方面的优化表现比如循环无关代码外提或者你比较感兴趣的其他优化。欢迎在留言区分享你的测试心得。
如果你还有其他的问题,欢迎在留言区提问,我会逐一解答。最后,感谢你的阅读,如果今天的内容让你有所收获,也欢迎你把它分享给更多的朋友。

View File

@ -0,0 +1,314 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 后端总结:充分发挥硬件的能力
你好,我是宫文学。
后端的工作主要是针对各种不同架构的CPU来生成机器码。在第8讲我已经对编译器在生成代码的过程中所做的主要工作进行了简单的概述你现在应该对编译器的后端工作有了一个大致的了解也知道了后端工作中的关键算法包括指令选择、寄存器分配和指令排序又叫做指令调度
那么今天这一讲,我们就借助在第二个模块中解析过的真实编译器,来总结、梳理一下各种编译器的后端技术,再来迭代提升一下原有的认知,并加深对以下这些问题的理解:
首先在第8讲中我只讲了指令选择的必要性但对于如何实现指令选择等步骤我并没有展开介绍。今天这一讲我就会带你探索一下指令选择的相关算法。
其次关于寄存器分配算法我们探索过的好几个编译器比如Graal、gc编译器等采用的都是线性扫描算法那么这个算法的原理是什么呢我们一起来探究一下。
最后,我们再回到计算机语言设计的主线上来,一起分析一下不同编译器的后端设计,是如何跟该语言的设计目标相匹配的。
OK我们先来了解一下指令选择的算法。
指令选择算法
回顾一下我们主要是在Graal和Go语言的编译器中分析了与指令选择有关的算法。它们都采用了一种模式匹配的DSL只要找到了符合模式的指令组合编译器就生成一条低端的、对应于机器码的指令。
那为什么这种算法是有效的呢?这种算法的原理是什么呢?都有哪些不同的算法实现?接下来,我就给你揭晓一下答案。
我先给你举个例子。针对表达式“a[i]=b”它是对数组a的第i个元素赋值。假设a是一个整数数组那么地址的偏移量就是a+4*i所以这个赋值表达式用C语言可以写成“*(a+4*i)=b”把它表达成AST的话就是下图所示的样子。其中赋值表达式的左子树的计算结果是一个内存地址。
图1a[i]=b的AST
那么,我们要如何给这个表达式生成指令呢?
如果你熟悉x86汇编你就会知道上述语句可以非常简单地表达出来因为x86的指令对数组寻址做了优化参见第8讲的内容
不过这里为了让你更容易理解算法的原理我设计了一个新的指令集。这个指令集中的每条指令都对应了一棵AST的子树我们把它叫做模式树Pattern Tree。在有的算法里它们也被叫做瓦片Tiling。对一个AST生成指令就是用这样的模式树或瓦片来覆盖整个AST的过程。所以这样的算法也叫做基于模式匹配的指令生成算法。
图2指令集中的指令和对应的模式树
你可以看到在图2中对于每棵模式树它的根节点是这个指令产生的结果的存放位置。比如Load_Const指令执行完毕以后常数会被保存到一个寄存器里。这个寄存器又可以作为上一级AST节点的操作数来使用。
图2中的指令包含把常数和内存中的值加载到寄存器、加法运算、乘法运算等。其中有两个指令是特殊设计的目的就是为了让你更容易理解接下来要探究的各种算法。
第一个指令是#4Store_Offset它把值保存到内存的时候可以在目的地址上加一个偏移量。你可以认为这是为某些场景做的一个优化比如你在对象地址上加一个偏移量就能获得成员变量的地址并把数值保存到这个地址上。
第二个指令是#9Lea它相当于x86指令集中的Lea指令能够计算一个地址值特别是能够利用间接寻址模式计算出一个数组元素的地址。它能通过一条指令完成一个乘法计算和一个加法计算。如果你忘记了Lea指令可以重新看看第8讲的内容。
基于上述的指令和模式树,我们就可以尝试来做一下模式匹配,从而选择出合适的指令。那么都可以采用什么样的算法呢?
第一个算法,是一种比较幼稚的算法。我们采取深度优先的后序遍历,也就是按照“左子节点->右子节点->父节点”的顺序遍历,针对每个节点去匹配上面的模式。
第1步采用模式#2把内存中a的值也就是数组的地址加载到寄存器。因为无论加减乘除等任何运算都是可以拿寄存器作为操作数的所以做这个决策是很安全的。
第2步同上采用模式#1把常量4加载到寄存器。
第3步采用模式#2把内存中i的值加载到寄存器。
第4步采用模式#8把两个寄存器的值相乘得到4*i的值。
第5步采用模式#5把两个寄存器的值相加得到a+4*i的值也就是a[i]的地址。
第6步采用模式#2把内存中b的值加载到寄存器。
第7步采用模式#3把寄存器中b的值写入a[i]的地址。
图3用比较幼稚的算法做模式匹配
最后形成的汇编代码是这样的:
Load_Mem a, R1
Load_Const 4, R2
Load_Mem i, R3
Mul_Reg R2, R3
Add_Reg R3, R1
Load_Mem b, R2
Store R2, (R1)
这种方法是自底向上的做树的重写。它的优点是特别简单缺点是性能比较差。它一共生成了7条指令代价是193+1+3+4+1+3+4
在上述步骤中我们能看到很多可以优化的地方。比如4*i这个子表达式我们是用了3条指令来实现的总的Cost是1+3+4=8而如果改成两条指令也就是使用Mul_mem指令就不用先把i加载到寄存器Cost可以是1+6=7。
Load_Const 4, R1
Mul_Mem i, R1
第二种方法是类似Graal编译器所采用的方法自顶向下的做模式匹配。比如当我们处理赋值节点的时候算法会尽量匹配更多的子节点。因为一条指令包含的子节点越多那么通过一条指令完成的操作就越多从而总的Cost就更低。
所以,算法的大致步骤是这样的:
第1步#3和#4两个模式中做选择的话,选中了#4号
第2步沿着AST继续所深度遍历其中+号节点第1步被处理掉了所以现在处理变量a采用了模式#2,把变量加载到寄存器。
第3步处理*节点。这个时候要在#7和#8之间做对比,最后选择了#7,因为它可以包含更多的节点。
第4步处理常量4。因为上级节点在这里需要一个寄存器作为操作数所以我们采用了模式#1,把常量加载到寄存器。
第5步处理变量b。这里也要把它加载到寄存器因此采用了模式#2
图4Maximal Munch算法的匹配结果
到此为止我们用了5条指令就做完了所有的运算生成的汇编代码是
Load_Mem a, R1
Load_Const 4, R2
Mul_Mem R2, i
Load_Mem b, R3
Store_Offset R3, (R1,R2)
这5条指令总的Cost是183+1+6+3+5
上述算法的特点是在每一步都采用了贪婪策略这种算法策略有时候也叫做“Maximal Munch”意思就是每一步都去咬最大的一口。
贪婪策略会生成比幼稚的算法更优化的代码但它不一定是最优的。你看下图中的匹配策略它也是用了5条指令。
图5最优的匹配策略
生成的汇编代码如下:
Lead_Mem a, R1
Load_Mem i, R2
Lea (R1,R2,4), R1
Load_Mem b, R2
Store R2, (R1)
这个新的匹配结果总的Cost是173+3+4+3+4比前一个算法的结果更优化了。那我们用什么算法能得到这样一个结果呢
一个思路是找出用模式匹配来覆盖AST的所有可能的模式并找出其中Cost最低的。你可以采用暴力枚举的方法在每一个节点去匹配所有可能的模式从而找出多组解。但显然这种算法的计算量太大所需的时间会根据AST的大小呈指数级上升导致编译速度无法接受。
所以我们需要找到一个代价更低的算法这就是BURS算法也就是“自底向上重写系统Bottom-Up Rewriting System”。在HotSpot的C2编译器中就采用了BURS算法。这个算法采用了动态规划Dynamic Programming的数学方法来获取最优解同时保持了较低的算法复杂度。
那么要想理解BURS算法你就必须要弄懂动态规划的原理。如果你之前没有学过这个数学方法请不要紧张因为动态规划的原理其实是相当简单的。
我在网上发现了一篇能够简洁地说清楚动态规划的文章。它举了一个例子,用最少张的纸币,来凑出某个金额。
比如说假设你要凑出15元怎么做呢你还是可以继续采用贪婪算法。首先拿出一张10元的纸币也就是小于15的最大金额然后再拿出5元来。这样你用两张纸币就凑出了15这个数值。这个时候贪婪策略仍然是有效的。
但是如果某个奇葩的国家发行的货币不是按照中国货币的面额而是发行1、5、11元三种面额的纸币。那么如果你仍然使用贪婪策略一开始拿出一张11元的纸币你就还需要再拿出4张1元的这样就一共需要5张纸币。
但这显然不是最优解。最优解是只需要三张5元的纸币就可以了这就像我们用贪婪算法去做指令生成得到的可能不是最优解是同样的道理。
那如何采用动态规划的方法来获取最优解呢它的思路是这样的假设我们用f(n)来代表凑出n元钱最少的纸币数那么
当一开始取11元的话Cost = f(4) + 1
当一开始取5元的话Cost = f(10) + 1
当一开始取1元的话Cost = f(14) + 1。
所以我们只需要知道f(4)、f(10)和f(14)哪个值最小就行了。也就是说f(15)=min(f(4), f(10), f(14)) + 1。 而f(4)、f(10)和f(14)三个值也可以用同样的方法递归地求出来最后得到的值分别是4、2、4。所以f(15)=3这就是最优解。
这个算法最棒的一点是整个计算中会遇到的f(14)、f(13)、f(12)、f(11) … f(3)、f(2)这些值,一旦计算过一遍,就可以缓存下来,不必重复计算,从而让算法的复杂性降低。
所以动态规划的特点是通过子问题的最优解得到总的问题的最优解。这种方法也可以用于生成最优的指令组合。比如对于示例程序来说假设f(=)是以赋值运算符为根节点的AST所生成的指令的总的最低Cost那么
当采用#3的时候Cost = 4 + f(+) + f(b)
当采用#4的时候Cost = 5 + f(a) + f(*) + f(b)。
所以你能看出,通过动态规划方法,也能像凑纸币一样,求出树覆盖的最优解。
BURS算法在具体执行的时候需要进行三遍的扫描。
第一遍扫描是自底向上做遍历也就是后序遍历识别出每个节点可以进行的转换。我在图6中给你标了出来。以a节点为例我们可以对它做两个操作第一个操作是保持一个mem节点不动第二个操作是按照模式#1把它转换成一个reg节点
图6识别AST的每个节点可以进行的转换
第二遍扫描是自顶向下的,运用动态规划的方法找出最优解。
第三遍扫描又是自底向上的,用于生成指令。
好了,那么到目前为止,你就已经了解了指令生成的算法思路了。这里我再补充几点说明:
示例中的指令和Cost值是为了便于你理解算法而设计的。在这个示例中最优解和最差解的Cost只差了2也就是大约12%的性能提升。而在实际应用中,优化力度往往会远远大于这个值。
在第6讲探究IR的数据结构时我提到过有向无环图DAG它比起刚才例子中用到的树结构能够消除一些冗余的子树从而减少生成的代码量。LLVM里在做指令选择的时候就是采用了DAG但算法思路是一样的。
示例中到的两个算法贪婪算法和BURS算法它们花费的时间都与节点数呈线性关系所以性能都是很高的。其中BURS算法的线性系数更大一点做指令选择所需的时间也更长一点。
OK那么接下来我们来探究第二个算法寄存器分配算法。
寄存器分配算法
在解析Graal编译器和Go的编译器的时候我都提到过它们的寄存器分配算法是线性扫描算法。我也提到过线性扫描算法的性能比较高。
那么,线性扫描算法的原理是什么呢?总的来说,线性扫描算法理解起来其实相当简单。我用一个例子来带你了解下。
假设我们的程序里有从a到g共7个变量。通过数据流分析中的变量活跃性分析你其实可以知道每个变量的生存期。现在我们已知有4个物理寄存器可用那么我们来看一下要怎么分配这几个物理寄存器。
在第1个时间段a、b、c和d是活跃的那我们刚好把4个物理寄存器分配给这四个变量就行了。
在第2个时间段a的生存期结束而一个新的变量e变得活跃那么我们就把a原来占用的寄存器刚好给到e就可以了。
在第3个时间段我们把c占用的寄存器给到f目前仍然是使用4个寄存器。
在第4个时间段b的生存期结束。这时候只需要用到3个寄存器。
在最后一个时间段只有变量d和g是活跃的占用两个寄存器。
可以看到,在上面这个例子中,所有的变量都可以分配到物理寄存器。而且你也会发现,这个例子中存在多个变量因为生存期是错开的,因此也可以共享同一个寄存器。
但是,如果没有足够的物理寄存器的话,我们要怎么办呢?那就需要把某个变量溢出到内存里了。也就是说,当用到这个变量的时候,才把这个变量加载到寄存器,或者有一些指令可以直接用内存地址作为操作数。
给你举另一个例子,我们来看看物理寄存器不足的情况会是什么样子。在这个例子中,我们有三个物理寄存器。
在第1个时间段物理寄存器是够用的。
在第2个时间段变量d变得活跃现在有4个活跃变量所以必须选择一个溢出到内存。我们选择了a。
在第3个时间段e和f变得活跃现在又需要溢出一个变量才可以。这次选择了c。
在第4个时间段g也变得活跃这次把d溢出了。
以上就是线性扫描算法的思路:线性扫描整个代码,并给活跃变量分配寄存器。如果物理寄存器不足,那么就选择一个变量,溢出到内存中。你看,是不是很简单?
在掌握了线性扫描算法的思路以后,我再给你补充一点信息:
第一,线性扫描算法并不能获得寄存器分配的最优解。所谓最优解,是要让尽量多的操作在寄存器上实现,尽量少地访问内存。因为线性扫描算法并没有去确定一个最优值的目标,所以也就谈不上最优解。
第二,线性扫描算法可以采用一些策略,让一些使用频率低的变量被溢出,而像高频使用的循环中的变量,就保留在寄存器里。
第三,还有一些其他提升策略。比如,当存在多余的物理寄存器以后,还可以把之前已经溢出的变量重新复活到寄存器里。
好了上述就是线性扫描的寄存器分配算法。另外我们再来复习一下在第8讲中我还提到了另一个算法是图染色算法这个算法的优化效果更好但是计算量比较大会影响编译速度。
接下来,让我们再回到计算机语言设计的主线上,一起讨论一下编译器的后端与语言设计的关系。
编译器后端与语言的设计
编译器后端的目的,是要能够针对不同架构的硬件来生成目标代码,并尽量发挥硬件的能力。那么为了更好地支持语言的设计,在编译器后端的设计上,我们需要考虑到三个方面的因素。
平衡编译速度和优化效果
通常我们都希望编译后的代码越优化越好。但是在有些场景下编译速度也很重要。比如像JVM这样需要即时编译的运行时环境编译速度就比较重要。这可能就是Graal的指令选择算法和编译器分配算法都比较简单的原因吧。
Go语言一开始也把编译速度作为一个重要的设计考虑所以它的后端算法也比较简单。我估计是因为Go语言的发起者Robert Griesemer、Rob Pike和Ken Tompson都具有C和C++的背景甚至Ken Tompson还是C语言的联合发明人他们都深受编译速度慢之苦。类似浏览器、操作系统这样比较大的软件即使是用很多台机器做编译还是需要编译很久。这可能也是他们为什么想让Go的编译速度很快的原因。
而Julia的设计目标是用于科学计算的所以其使用场景主要就是计算密集型的。Julia采用了LLVM做后端做了比较高强度的优化即使会因此导致运行时由于JIT而引起短暂停顿。
确定所支持的硬件平台
确定了一门语言主要运行在什么平台上那么首先就要支持该平台上的机器码。由于Go语言主要是用于写服务端程序的而服务端采用的架构是有限的所以Go语言支持的架构也是有限的。
硬件平台也影响算法的选择比如现在很多CPU都支持指令的乱序执行那你在实现编译器的时候就可以省略指令重排序指令调度功能。
设计后端DSL
虽然编译器后端要支持多种硬件但我们其实会希望算法是通用的。所以各个编译器通常会提供一种DSL去描述硬件的特征从而自动生成针对这种硬件的代码。
在Graal中我们看到了与指令选择有关的注解在Go的编译器中我们也看到了对IR进行转换的DSL而LLVM则提供了类似的机制。
课程小结
今天这一讲,我把后端的两个重要的算法拿出来给你单独介绍了一下,并一起讨论了后端技术策略与计算机语言的关系。你需要记住这几个知识点:
关于指令选择从IR生成机器码或LIR通常是AST或DAG中的多个节点对应一条指令所以你要找到一个最佳的组合把整个AST或DAG覆盖住并且要找到一个较优的或最优的解。其中你还要熟悉贪婪算法和动态规划这两种不同的算法策略这两种算法不仅仅会用于指令选择还会用于多种场景。理解了这两种算法之后就会给你的工具库添加两个重要的工具。
关于寄存器分配线性扫描算法比较简单。不过在一些技术点上我们去深入挖掘一下其实会发现还挺有意思的。比如当采用SSA格式的IR的时候寄存器分配算法会有什么不同等等。你可以参考看看文末我给出的资料。
关于编译器后端的设计我们要考虑编译速度和优化程度的平衡要考虑都能支持哪些硬件。因为要支持多种硬件通常要涉及后端的DSL以便让算法尽量中立于具体的硬件架构。
我把本讲的知识点也整理成了思维导图,供你复习和参考:
一课一思
动态规划算法是这节课的一个重要知识点。在学过了这个知识点以后,你能否发现它还可以被用于解决哪些问题?欢迎分享你的经验和看法。
参考资料
对动态规划方法的理解,我建议你读一下这篇文章,通俗易懂。
在《编译原理之美》的第29讲有对寄存器分配算法中的图染色算法的介绍你可以去参考一下。
这两篇关于线性扫描算法的经典论文你可以去看一下论文1论文2。
这篇文章介绍了针对SSA格式的IR的线性扫描算法值得一看。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 运行时从0到语言级的虚拟化
你好,我是宫文学。今天,我会带你去考察现代语言设计中的运行时特性,并讨论一下与标准库有关的话题。
你可能要问了,咱们这门课是要讲编译原理啊,为什么要学运行时呢。其实,对于一门语言来说,除了要提供编译器外,还必须提供运行时功能和标准库:一是,编译器生成的目标代码,需要运行时的帮助才能顺利运行;二是,我们写代码的时候,有一些标准的功能,像是读写文件的功能,自己实现起来太麻烦,或者根本不可能用这门语言本身来实现,这时就需要标准库的支持。
其实,我们也经常会接触到运行时和库,但可能只是停留在使用层面上,并不太会关注它们的原理等。如果真要细究起来、真要对编译原理有更透彻的理解的话,你可能就会有下面这些问题了:
到底什么是运行时?任何语言都有运行时吗?运行时和编译器是什么关系?
什么是标准库?标准库和运行时库又是什么关系?库一般都包含什么功能?
今天,我们就来探讨一下这些与运行时和标准库有关的话题。这样,你能更加充分地理解设计一门语言要完成哪些工作,以及这些工作跟编译技术又有什么关系,也就能对编译原理有更深一层的理解。
首先,我们来了解一下运行时,以及它和编译技术的关系。
什么是运行时Runtime
我们在第5讲说过每种语言都有一个特定的执行模型Execution Model。而这个执行模型就需要运行时系统Runtime System的支持。我们把这种可以支撑程序运行的运行时系统简称为运行时。
那运行时都包含什么功能呢通常我们最关心的是三方面的功能程序运行机制、内存管理机制和并发机制。接下来我就分别以Java、Python以及C、C++、Go语言的运行时机制为例做一下运行时的分析因为它们的使用者比较多并且体现了一些有代表性的运行时特征。
Java的运行时
我们先看看Java语言的运行时系统也就是JVM。
其实JVM不仅为Java提供了运行时环境还为其他所有基于JVM的语言提供了支撑包括Scala、Clojure、Groovy等。我们可以通过JVM的规范来学习一下它的主要特点。
第一JVM规定了一套程序的运行机制。JVM支持基于字节码的解释执行机制还包括了即时编译成机器码并执行的机制。
针对基于字节码的解释执行机制JVM规范定义下面这些内容
定义了一套字节码来运行程序。这些字节码能支持一些基本的运算。超出这些基本运算逻辑的就要自己去实现。比如idiv指令用于做整数的除法当除数为零的时候虚拟缺省的操作是抛出异常。如果你自己的语言是专注于数学计算的想让整数除以零的结果为无穷大那么你需要自己去实现这个逻辑。
规定了一套类型系统包括基础数据类型、数组、引用类型等。所以说任何运行在JVM上的语言不管它设计的类型系统是什么样子编译以后都会变成字节码规定的基础类型。
定义了class文件的结构。class文件规定了把某个类的符号表放在哪里、把字节码放在哪里所以写编译器的时候要遵守这个规范才能生成正确的class文件。JVM在运行时会加载class文件并执行。
提供了一个基于栈的解释器,来解释执行字节码。编译器要根据这个执行模型来生成正确的字节码。
除了解释执行字节码的机制JVM还支持即时编译成机器码并执行的机制。它可以调度多个编译器生成不同优化级别的机器码这就是分层编译机制。在需要的时候还可以做逆优化在不同版本的机器码以及解释执行模式之间做切换。
最后Java程序之间的互相调用需要遵循一定的调用约定或二进制标准包括如何传参数等等。这也是运行机制的一部分。
总体来说JVM代表了一种比较复杂的运行机制既可以解释执行又可以编译成机器码执行。V8的运行时机制也跟JVM也很类似。
第二JVM对内存做了统一的管理。它把内存划分为程序计数器、虚拟机栈、堆、方法区、运行时常量池和本地方法栈等不同的区域。
对于栈来说它的栈桢既可以服务于解释执行又可以用于执行机器码并且还可以在两种模式之间转换。在解释执行的时候栈桢里会有一个操作数栈服务于解释器。我们提到过OSR也就是在运行一个方法的时候把这个方法做即时编译并且把它的栈桢从解释执行的状态切换成运行机器码的状态。而如果遇到逆优化的场景栈桢又会从运行机器码的状态切换成解释执行的状态。
对于堆来说Java提供了垃圾收集器帮助进行内存的自动管理。减少整体的停顿时间是垃圾收集器设计的重要目标。
第三JVM封装了操作系统的线程模型为应用程序提供了并发处理的机制。我会在讲并发机制的时候再展开。
以上就是JVM为运行在其上的任何程序提供的支撑了。在提供这些支撑的同时运行时系统也给程序运行带来了一些限制。
第一JVM实际上提供了一个基础的对象模型JVM上的各种语言必须遵守。所以虽然Clojure是一个函数式编程语言但它在底层却不得不使用JVM规定的对象模型。
第二基于JVM的语言程序要去调用C语言等生成的机器码的库会比较难。不过对于同样基于JVM的语言则很容易实现相互之间的调用因为它们底层都是类和字节码。
第三,在内存管理上,程序不能直接访问内存地址,也不能手动释放内存。
第四在并发方面JVM只提供了线程机制。如果你要使用其他并发模型比如我们会在34讲中讲到的协程模型和35讲中的Actor模型需要语言的实现者绕着弯去做增加一些自己的运行时机制我会在第34讲来具体介绍
好了以上就是我要通过JVM的例子带你学习的Java的运行时以及其编译器的影响了。我们再来看看Python的运行时。
Python的运行时
在解析Python语言的时候已经讲了Python的字节码和解释器以及Python对象模型和程序调用的机制。这里我再从程序运行机制、内存管理机制、并发机制这三个方面给你梳理下。
第一Python也提供了一套字节码以及运行该字节码的解释器。这套字节码也是跟Python的类型体系互相配合的。字节码中操作的那些标识符都是Python的对象引用。
第二在内存管理方面Python也提供了自己的机制包括对栈和堆的管理。
首先我们看看栈。Python运行程序的时候有些时候是运行机器码比如内置函数而有些时候是解释执行字节码。
运行机器码的时候栈帧跟C语言程序的栈帧是没啥区别的。而在解释执行字节码的时候栈帧里会包含一个操作数栈这点跟JVM的栈机是一样的。如果你再进一步去看看操作数栈的实现会发现解释器本身主要就是一个C语言实现的函数而操作数栈就是这个函数里用到的本地变量。因此操作数栈也会像其他本地变量一样被优化成尽量使用物理寄存器从而提高运行效率。这个知识点你要掌握也就是说栈桢中的操作数栈其实是有可能基于物理寄存器的。
然后Python还提供了对堆的管理机制。程序从堆里申请内存的时候不是直接从操作系统申请而是通过Python提供的一个Arena机制使得内存的申请和释放更加高效、灵活。Python还提供了基于引用的垃圾收集机制我会在下一讲为你总结垃圾收集机制
第三是并发机制。Python把操作系统的线程进行了封装让Python程序能支持基于线程的并发。同时它也实现了协程机制我会在34讲详细展开
好了我们再继续看看第三类语言也就是C、C++、Go这样的直接编译成二进制文件执行的语言的运行时。
C、C++、Go的运行时
一个有意思的问题是C语言有没有运行时呢我们对C语言的印象是一旦编译完成以后就是一段完全可以自主运行的二进制代码了你也可以看到输出的完整的汇编代码。除此之外没有其他C语言似乎不需要运行时的支持。
所以C语言最主要的运行时实际上就是操作系统。C语言和现代的各种操作系统可以说是伴生关系就像Java和JVM是伴生关系一样。所以如果我们要深入使用C语言某种意义上就是要深入了解操作系统的运行机制。
在程序执行机制方面C语言编译完毕的程序是完全按照操作系统的运行机制来执行的。
在内存管理方面C语言使用了操作系统提供的线程栈操作系统能够自动帮助程序管理内存。程序也可以从堆里申请内存但必须自己负责释放没有自动内存管理机制。
在并发机制方面当然也是直接用操作系统提供的线程机制。因为操作系统没有提供协程和Actor机制所以C语言也没有提供这种并发机制。
不过有一个程序crt0.o有时被称作是C语言的运行时。它是一段汇编代码crt0.s由链接器自动插入到程序里面主要功能是在调用main函数之前做一些初始化工作比如设置main函数的参数argc和argv、环境变量的地址、调用main函数、设置一些中断向量用于处理程序异常等。所以这个所谓的运行时所做的工作也特别简单。
不同系统的crt0.s会不太一样因为CPU架构和ABI是不同的。下面是一个crt0.s的示例代码
.text
.globl _start
_start: # _start是链接器需要用到的入口
xor %ebp, %ebp # 让ebp置为0标记栈帧的底部
mov (%rsp), %edi # 从栈里获得argc的值
lea 8(%rsp), %rsi # 从栈里获得argv的地址
lea 16(%rsp,%rdi,8), %rdx # 从栈里获得envp的地址
xor %eax, %eax # 按照ABI的要求把eax置为0并与icc兼容
call main # 调用main函数%edi, %rsi, %rdx是传给main函数的三个参数
mov %eax, %edi # 把main函数的返回值提供给_exit作为第一个参数
xor %eax, %eax # 按照ABI的要求把eax置为0并与icc兼容
call _exit # 终止程序
可以说C语言的运行时是一个极端提供了最少的功能。反过来呢这也就是给了程序员最大的自由度。C++语言的跟C是类似的我就不再展开了。总的来说它们都没有Java和Python那种意义上的运行时。
不过Go语言虽然也是编译成二进制的可执行文件但它的运行时要复杂得多。比如它有垃圾收集器再比如Go语言最显著的特点是提供了自己的并发机制也就是goroutine。对goroutine的运行管理也是go的运行时的一部分。
无独有偶在Android平台上你可以把Java程序以AOT的方式编译成可执行文件。但这个可执行文件其实仍然包含了一个运行时比如垃圾收集功能所以与C语言编译形成的可执行文件也是不一样的。
总结起来,运行时系统提供了程序的运行机制、内存管理机制、并行机制等功能。运行时和编译器的关系就是,编译器要跟这些运行时做配合,生成符合运行时要求的目标代码。
接下来,我们再看看语言的另一个重要组成部分,也就是标准库,并看看它跟编译器的关系。
库和标准库
我们知道,任何一门编程语言,要想很好地投入实际应用,必须有良好的库来支撑。这些库的作用就是封装了常用的、标准的功能,让开发者可以直接使用。
根据库的使用场景和与编译器的关系,这些库可以分为标准库、运行时库和内置函数三类。
第一标准库供用户的程序调用。我们在写一段C语言程序的时候总要在源代码一开头的部分include几个库进来比如stdio.h、stdlib.h等等。C++的STL库和标准库让程序员拥有比C语言里面更多的工具比如各种标准的容器类。Java刚面世的时候就在JDK里打包了很多标准库。正是因为这些丰富又好用的库使得Java能够被迅速接受。当然了这些库也成了JDK标准的组成部分。而Python语言声称是“自带电池”的也就是说有很多库的支持可以迅速上手做很多事情。
第二类运行时库它们不是由用户直接调用的而是运行时的组成部分。比如Python实现整数运算的功能很强大支持任意长度整数的加减乘除。这些功能是由一些库函数实现的并由Python的解释器来调用实现Python程序中的加减乘除操作。
第三类是一些叫做Built-in或者Intrincics的内置函数它们是用来辅助生成机器码的。它们往往由汇编代码实现也有的是用编译器的LIR实现的在编译的时候直接内联进去。这些函数有时开发者也可以调用比如在C语言中可以像调用普通函数一样调用CPU厂家提供的与SIMD指令有关的Intrincics。但这些函数会直接生成汇编码不像C语言编写的程序那样需要经过优化和代码生成的过程。
好了我们了解了库的三种分类也就是标准库、运行时库和内置函数。不过我要提醒你的是这些分类有时候是模糊的比如有的语言比如微软的C和C++语言)谈到运行时库的时候,实际上就包括了标准库。
接下来,我们主要看看与标准库相关的几个问题。
标准库的特殊性
与普通程序相比,标准库主要有以下三个方面的不同。
第一,有的库可以用本语言来实现,而有的库必须要用其他语言来实现,因为用本语言实现有困难。这就要求库的编写者要具备更高的技能,能够掌握更加底层的语言。
比如Java有少量库比如网络通讯模块就需要用C语言来编写而Python、PHP、Node.js等语言的大量库都是用C语言编写的。甚至标准库中的某些底层功能会采用汇编语言来写。
第二,标准库的接口不可以经常变化,甚至是要保持一直不变。因此,标准库的设计一定要慎重,这就要求设计者有更高的规划和设计能力。因为几乎每个程序都会用到标准库的功能,库的接口如果变化的话,就会影响到所有已经写好的程序。
第三标准库往往集中体现了一门语言的核心特点。同样的功能面向对象编程语言、函数式编程语言、基于Actor的语言会采用各自的方式来实现。库的编写者要写出教科书级的代码充分发挥这门语言的优势。这样的话编程人员使用这些标准库的过程实际上就是潜移默化地学习这门语言的编程思想的过程。
好了,看来编写一个好的标准库确实是有挑战的事情。但是标准库一般需要包含哪些内容呢?
标准库需要包含什么功能?
第一包含IO功能包括文件IO、网络IO等。
还记得吧我们学习每一门新语言的时候都会在终端上打印出一个“Hello World!”这似乎已经成了一种具有仪式感的行为。可是你注意到没有你在打印输出到终端的时候通常就是调用了一个标准的IO库。因为终端本身就相当于一个文件这实际上是用了文件IO功能。
除了文件IO网络IO也必不可少这样的话手机上的App程序才能够跟服务端的程序通讯。
第二,支持内置的数据类型。
首先是针对整型、浮点型等基础数据类型做运算的功能。比如有的数学库的数学计算功能支持任意长度的整数的运算,并支持准确的小数运算(计算机内置的浮点数计算功能是不精确的)。此外数据类型转换、对字符串操作等,也是必不可少的。
像Java、Python这样的语言提供了一些标准的内置类型比如String等。像Scala这种纯面向对象语言连整型、浮点型等基础数据类型也是通过标准库来提供的。
第三,支持各种容器型的数据结构。
有的语言比如Go会在语法层面提供map等容器型的数据结构并通过运行时库做支持还有些语言比如Java、C++),是在标准库里提供这些数据结构。
此外,标准库还要包含一些其他功能,比如对日期、图形界面等各种不同的功能支持。
课程小结
今天,我们一起学习了一门语言除编译器之外的一些重要组成部分,包括运行时和各种库。编译器拥有运行时和库的知识,并根据这些知识作出正确的编译。当你设计一门语言的时候,应该首先要把它的运行机制设计清楚,然后才能设计出正确的语法、语义,并实现出相应的编译器。
所以,我们这一讲的目标,就是帮你从一个更高的维度来理解编译技术的使用环境,从而更加全面地理解和使用编译技术。
我把今天的知识点也整理成了思维导图,供你参考:
一课一思
挑你熟悉的一门语言,分享一下它的运行时和标准库的设计特征,以及对编译器的影响。
欢迎你在留言区表达自己的见解,也非常欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。

View File

@ -0,0 +1,280 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 运行时(二):垃圾收集与语言的特性有关吗?
你好,我是宫文学。今天,我们继续一起学习垃圾收集的实现机制以及与编译器的关系。
对于一门语言来说垃圾收集机制能够自动管理从堆中申请的内存从而大大降低程序员的负担。在这门课的第二大模块“真实编译器解析篇”中我们学习Java、Python、Go、Julia和JavaScript这几门语言都有垃圾收集机制。那在今天这一讲我们就来学习一下这些语言的垃圾收集机制到底有什么不同跟语言特性的设计又是什么关系以及编译器又是如何配合垃圾收集机制的。
这样如果我们以后要设计一门语言的话,也能清楚如何选择合适的垃圾收集机制,以及如何让编译器来配合选定的垃圾收集机制。
在讨论不同语言的垃圾收集机制之前我们还是需要先了解一下通常我们都会用到哪些垃圾收集算法以及它们都有什么特点。这样我们才能深入探讨应该在什么时候采用什么算法。如果你对各种垃圾收集算法已经很熟悉了也可以从这一讲的“Python与引用计数算法”开始学习如果你还想理解垃圾收集算法的更多细节也可以去看看我的第一季课程《编译原理之美》的第33讲的内容。
垃圾收集算法概述
垃圾收集主要有标记-清除Mark and Sweep、标记-整理Mark and Compact、停止-拷贝Stop and Copy、引用计数、分代收集、增量收集和并发收集等不同的算法在这里我简要地和你介绍一下。
首先,我们先学习一下什么是内存垃圾。内存垃圾,其实就是一些保存在堆里的、已经无法从程序里访问的对象。
我们看一个具体的例子。
在堆中申请一块内存时比如Java中的对象实例我们会用一个变量指向这块内存。但是如果给变量赋予一个新的地址或者当栈桢弹出时该栈桢的变量全部失效这时变量所指向的内存就没用了如图中的灰色块
图1A是内存垃圾
另外如果A对象有一个成员变量指向C对象那么A不可达C也会不可达也就失效了。但D对象除了被A引用还被B引用仍然是可达的。
图2A和C是内存垃圾
那么,所有不可达的内存就是垃圾。所以,垃圾收集的重点就是找到并清除这些垃圾。接下来,我们就看看不同的算法是怎么完成这个任务的。
标记-清除
标记-清除算法是从GC根节点出发顺着对象的引用关系依次标记可达的对象。这里说的GC根节点包括全局变量、常量、栈里的本地变量、寄存器里的本地变量等。从它们出发就可以找到所有有用的对象。那么剩下的对象就是内存垃圾可以清除掉。
标记-整理
采用标记-清除算法,运行时间长了以后,会形成内存碎片。这样在申请内存的时候,可能会失败。
图3内存碎片导致内存申请失败
为了避免内存碎片,你可以采用变化后的算法,也就是标记-整理算法:在做完标记以后,做一下内存的整理,让存活的对象都移动到一边,消除掉内存碎片。
图4内存整理以后可以更有效地利用内存
停止-拷贝
停止和拷贝算法把内存分为新旧空间两部分。你需要保持一个堆指针,指向自由空间开始的位置。申请内存时,把堆指针往右移动就行了。
图5在旧空间中申请内存
当旧空间内存不够了以后,就会触发垃圾收集。在收集时,会把可达的对象拷贝到新空间,然后把新旧空间互换。
图6新旧空间互换
停止-拷贝算法,在分配内存的时候,不需要去查找一块合适的空闲内存;在垃圾收集完毕以后,也不需要做内存整理,因此速度是最快的。但它也有缺点,就是总有一半内存是闲置的。
引用计数
引用计数方法,是在对象里保存该对象被引用的数量。一旦这个引用数为零,那么就可以作为垃圾被收集走。
有时候我们会把引用计数叫做自动引用计数ARC并把它作为跟垃圾收集GC相对立的一个概念。所以如果你读到相关的文章它把ARC和GC做了对比也不要吃惊。
引用计数实现起来简单并且可以边运行边做垃圾收集不需要为了垃圾收集而专门停下程序。可是它也有缺陷就是不能处理循环引用Reference Cycles的情况。在下图中四个对象循环引用但没有GC根指向它们。它们已经是垃圾但计数却都为1。
图7循环引用
另外,由于在程序引用一个对象的前后,都要修改引用计数,并且还有多线程竞争的可能性,所以引用计数法的性能开销比较大。
分代收集
在程序中新创建的对象往往会很快死去比如你在一个方法中使用临时变量指向一些新创建的对象这些对象大多数在退出方法时就没用了。这些数据叫做新生代。而如果一个对象被扫描多次发现它还没有成为垃圾那就会标记它为比较老的时代。这些对象可能Java里的静态数据成员或者调用栈里比较靠近根部的方法所引用的不会很快成为垃圾。
对于新生代对象,可以更频繁地回收。而对于老一代的对象,则回收频率可以低一些。并且,对于不同世代的对象,还可以用不同的回收方法。比如,新生代比较适合复制式收集算法,因为大部分对象会被收集掉,剩下来的不多;而老一代的对象生存周期比较长,拷贝的话代价太大,比较适合标记-清除算法,或者标记-整理算法。
增量收集和并发收集
垃圾收集算法在运行时通常会把程序停下。因为在垃圾收集的过程中如果程序继续运行可能会出错。这种停下整个程序的现象被形象地称作“停下整个世界STW”。
可是让程序停下来,会导致系统卡顿,用户的体验感会很不好。一些对实时性要求比较高的系统,根本不可能忍受这种停顿。
所以,在自动内存管理领域的一个研究的重点,就是如何缩短这种停顿时间。增量收集和并发收集算法,就是在这方面的有益探索:
增量收集可以每次只完成部分收集工作,没必要一次把活干完,从而减少停顿。
并发收集就是在不影响程序执行的情况下,并发地执行垃圾收集工作。
好了,理解了垃圾收集算法的核心原理以后,我们就可以继续去探索各门语言是怎么运用这些算法的了。
首先我们从Python的垃圾收集算法学起。
Python与引用计数算法
Python语言选择的是引用计数的算法。除此之外Swift语言和方舟编译器采用的也是引用计数所以值得我们重视。
Python的内存管理和垃圾收集机制
首先我们来复习一下Python内存管理的特征。在Python里每个数据都是对象而这些对象又都是在堆上申请的。对比一下在C和Java这样的语言里很多计算可以用本地变量实现而本地变量是在栈上申请的。这样你用到一个整数的时候只占用4个字节而不像Python那样有一个对象头的固定开销。栈的优势还包括不会产生内存碎片数据的局部性又好申请和释放的速度又超快。而在堆里申请太多的小对象则会走向完全的反面太多次系统调用性能开销大产生内存碎片数据的局部性也比较差。
所以说Python的内存管理方案就决定了它的内存占用大、性能低。这是Python内存管理的短板。而为了稍微改善一下这个短板Python采用了一套基于区域Region-based的内存管理方法能够让小块的内存管理更高效。简单地说就是Python每次都申请一大块内存这一大块内存叫做Arena。当需要较小的内存的时候直接从Arena里划拨就好了不用一次次地去操作系统申请。当用垃圾回收算法回收内存时也不一定马上归还给操作系统而是归还到Arena里然后被循环使用。这个策略能在一定程度上提高内存申请的效率并且减少内存碎片化。
接下来我们就看看Python是如何做垃圾回收的。回忆一下在第19讲分析Python的运行时机制时其中提到了一些垃圾回收的线索。Python里每个对象都是一个PyObject每个PyObject都有一个ob_refcnt字段用于记录被引用的数量。
在解释器执行字节码的时候会根据不同的指令自动增加或者减少ob_refcnt的值。当一个PyObject对象的ob_refcnt的值为0的时候意味着没有任何一个变量引用它可以立即释放掉回收对象所占用的内存。
现在你已经知道采用引用计数方法需要解决循环引用的问题。那Python是如何实现的呢
Python在gc模块里提供了一个循环检测算法。接下来我们通过一个示例来看看这个算法的原理。在这个例子中有一个变量指向对象A。你能用肉眼看出对象A、B、C不是垃圾而D和E是垃圾。
图8把容器对象加入待扫描列表
在循环检测算法里gc使用了两个列表。一个列表保存所有待扫描的对象另一个列表保存可能的垃圾对象。注意这个算法只检测容器对象比如列表、用户自定义的类的实例等。而像整数对象这样的就不用检测了因为它们不可能持有对其他对象的引用也就不会造成循环引用。
在这个算法里我们首先让一个gc_ref变量等于对象的引用数。接着算法假装去掉对象之间的引用。比如去掉从A到B的引用这使得B对象的gc_ref值变为了0。在遍历完整个列表以后除了A对象以外其他对象的gc_ref都变成了0。
图9扫描列表修改gc_ref的值
gc_ref等于零的对象有的可能是垃圾对象比如D和E但也有些可能不是比如B和C。那要怎么区分呢我们先把这些对象都挪到另一个列表中怀疑它们可能是垃圾。
图10认为gc_ref为0的对象可能是垃圾
这个时候待扫描对象区只剩下了对象A。它的gc_ref是大于零的也就是从gc根是可到达的因此肯定不是垃圾对象。那么顺着这个对象所直接引用和间接引用到的对象也都不是垃圾。而剩下的对象都是从gc根不可到达的也就是真正的内存垃圾。
图11去除其中可达的对象剩下的是真正的垃圾
另外基于循环检测的垃圾回收算法是定期执行的这会跟Java等语言的垃圾收集器一样导致系统的停顿。所以它也会像Java等语言的垃圾收集器一样采用分代收集的策略来减少垃圾收集的工作量以及由于垃圾收集导致的停顿。
好了以上就是Python的垃圾收集算法。我们前面提过除了Python以外Swift和方舟编译器也使用了引用计数算法。另外还有些分代的垃圾收集器在处理老一代的对象时也会采用引用计数的方法这样就可以在引用计数为零的时候收回内存而不需要一遍遍地扫描。
编译器如何配合引用计数算法?
对于Python来说引用计数的增加和减少是由运行时来负责的编译器并不需要做额外的工作。它只需要生成字节码就行了。而对于Python的解释器来说在把一个对象赋值给一个变量的时候要把对象的引用数加1而当该变量超出作用域的时候要把对象的引用数减1。
不过,对于编译成机器码的语言来说,就要由编译器介入了。它要负责生成相应的指令,来做引用数的增减。
不过,这只是高度简化的描述。实际实现时,还要解决很多细致的问题。比如,在多线程的环境下,对引用数的改变,必须要用到锁,防止超过一个线程同时修改引用数。这种频繁地对锁的使用,会导致性能的降低。这时候,我们之前学过的一些优化算法就可以派上用场了。比如,编译器可以做一下逃逸分析,对于没有逃逸或者只是参数逃逸的对象,就可以不使用锁,因为这些对象不可能被多个线程访问。这样就可以提高程序的性能。
除了通过逃逸分析优化对锁的使用,编译器还可以进一步优化。比如,在一段程序中,一个对象指针被调用者通过参数传递给某个函数使用。在函数调用期间,由于调用者是引用着这个对象的,所以这个对象不会成为垃圾。而这个时候,就可以省略掉进入和退出函数时给对象引用数做增减的代码。
还有不少类似上面的情况需要编译器配合垃圾收集机制生成高效的、正确的代码。你在研究Swift和方舟编译器时可以多关注一下它们对引用计数做了哪些优化。
接下来,我们再看看其他语言是怎么做垃圾收集的。
其他语言是怎么做垃圾收集的?
除了Python以外我们在第二个模块研究的其他几门语言包括Java、JavaScriptV8和Julia都没有采用引用计数算法除了在分代算法中针对老一代的对象它们基本都采用了分代收集的策略。针对新生代通常是采用标记-清除或者停止拷贝算法。
它们不采用引用计数的原因,其实我们可以先猜测一下,那就是因为引用计数的缺点。比如增减引用计数所导致的计算量比较多,在多线程的情况下要用到锁,就更是如此;再比如会导致内存碎片化、局部性差等。
而采用像停止-拷贝这样的算法在总的计算开销上会比引用计数的方法低。Java和Go语言主要是用于服务端程序开发的。尽量减少内存收集带来的性能损耗当然是语言的设计者重点考虑的问题。
再进一步看,采用像停止-拷贝这样的算法其实是用空间换时间以更大的内存消耗换来性能的提升。如果你的程序需要100M内存那么虚拟机需要给它准备200M内存因为有一半空间是空着的。这其实也是为什么Android手机比iPhone更加消耗内存的原因之一。
在为iPhone开发程序的时候无论是采用Objective C还是Swift都是采用引用计数的技术。并且程序员还负责采用弱引用等技术来避免循环引用从而进一步消除了在运行时进行循环引用检测的开销。
通过上面的分析我们能发现移动端应用和服务端应用有不同的特点因此也会导致采用不同的垃圾收集算法。那么方舟编译器采用引用计数的方法来编译原来的Android应用是否也是借鉴了iPhone的经验呢我没有去求证过所以不得而知。但我们可以根据自己的知识去做一些合理的猜测。
回过头来我们继续分析一下用Java和Go语言来写服务端程序对垃圾收集的需求。对于服务器端程序来说垃圾收集导致的停顿是一个令程序员们头痛的问题。有时候一次垃圾收集会让整个程序停顿一段非常可观的时间比如上百毫秒甚至达到秒级这对于实时性要求较高或并发量较大的系统来说就会引起很大的问题。也因此一些很关键的系统很长时间内无法采用Java和Go语言编写。
所以Java和Go语言一直在致力于减少由于垃圾收集而产生的停顿。最新的垃圾收集器已经使得垃圾收集导致的停顿降低到了几毫秒内。
在这里,你需要理解的要点,是为什么在垃圾收集时,要停下整个程序?又有什么办法可以减少停顿的时间?
为什么在垃圾收集时,要停下整个程序?
其实,对于引用计数算法来说,是不需要停下整个程序的,每个对象的内存在计数为零的时候就可以收回。
而采用标记-清除算法时你就必须要停下程序首先做标记然后做清除。在做标记的时候你必须从所有的GC根出发去找到所有存活的对象剩下的才是垃圾。所以看上去这是一项完整的工作程序要一直停顿到这项完整的工作做完。
让事情更棘手的是你不仅要停下当前的线程扫描栈里的所有GC根你还要停下其他的线程因为其他线程栈里的对象也可能引用了相同的对象。最后的结果就是你停下了整个世界。
当然也有例外就是如果别的线程正在运行的代码没有可能改变对象之间的引用关系比如仅仅是在做一个耗费时间的数学计算那么是不用停下来的。你可以参考Julia的gc程序中的一段注释来理解什么样的代码必须停下来。
更麻烦的是不仅仅在扫描阶段你需要停下整个世界如果垃圾收集算法需要做内存的整理或拷贝那么这个时候仍然要停下程序。而且程序必须停在一些叫做安全点SafePoint的地方。
在这些地方,修改对象的地址不会破坏程序数据的一致性。比如说,假设代码里有一段逻辑,是访问对象的某个成员变量,而这个成员变量的地址是根据对象的地址加上一个偏移量计算出来的。那么如果你修改了对象的地址,而这段代码仍然去访问原来的地址,那就出错了。而当代码停留在安全点上,就不会有这种不一致。
安全点是编译器插入到代码中一个片段。在查看Graal生成的汇编代码时我们曾经看到过这样的指令片段。
好了,到目前为止,你了解了为什么要停下整个世界,以及要停在哪里才合适。那么我们继续研究,如何能减少停顿时间。
如何能减少停顿时间?
第一招分代收集可以减少垃圾收集的工作量不用每次都去扫描所有的对象因此也会减少停顿时间。像Java、Julia和V8的垃圾收集器都是分代的。
第二招可以尝试增量收集。你可能会问了怎样才能实现增量呀不是说必须扫描所有的GC根才能确认一个对象是垃圾吗
其实是有方法可以实现增量收集的比如三色标记Tri-color Marking法。这种方法的原理是用三种颜色来表示不同的内存对象的处理阶段
白色,表示算法还没有访问的对象。
灰色,表示这个节点已经被访问过,但子节点还没有被访问过。
黑色,表示这个节点已经被访问过,子节点也已经被访问过了。
我们用一个例子来了解一下这个算法的原理。这个例子中有8个对象。你可以看出其中三个对象是内存垃圾。在垃圾收集的时候一开始所有对象都是白色的。
图12所有对象标记为白色
然后扫描所有GC根所引用的对象把这些对象加入到一个工作区并标记为灰色。在例子中我们把A和F放入了灰色区域。
图13正在处理但没有处理完子节点的对象标记为灰色
如果这个对象的所有子节点都被访问过之后就把它标记为黑色。在例子中A和F已经被标记为黑色而B、C、D被标记为灰色。
图14已经处理完子节点的对象标记为黑色用
继续上面的过程B、C、D也被标记为黑色。这个时候灰色区域已经没有对象了。那么剩下的白色对象E、G和H就能确定是垃圾了。
图15处理完所有灰色节点后剩下的白色节点就是垃圾
回收掉E、G和H以后就可以进入下一次循环。重新开始做增量收集。
图16开始下一次垃圾收集
从上面的原理还可以看出这个算法的特点:黑色对象永远不能指向白色对象,顶多指向灰色对象。我们只要始终保证这一条,就可以去做增量式的收集。
具体来说垃圾收集器可以做了一段标记工作后就让程序再运行一段。如果在程序运行期间一个黑色对象被修改了比如往一个黑色对象a里新存储了一个指针b那么把a涂成灰色或者把b涂成灰色就可以了。等所有的灰色节点变为黑色以后就可以做垃圾清理了。
总结起来,三色标记法中,黑色的节点是已经处理完毕的,灰色的节点是正在处理的。如果灰色节点都处理完,剩下的白色节点就是垃圾。而如果在两次处理的间隙,有黑色对象又被改了,那么要重新处理。
那在增量收集的过程中需要编译器做什么配合肯定是需要的编译器需要往生成的目标代码中插入读屏障Read Barrier和写屏障Write Barrier的代码。也就是在程序读写对象的时候要执行一些逻辑保证三色的正确性。
好了,你已经理解了增量标识的原理,知道了它可以减少程序的整体停顿时间。那么,能否再进一步减少停顿时间呢?
这就涉及到第三招:并发收集。我们再仔细看上面的增量式收集算法:既然垃圾收集程序和主程序可以交替执行,那么是否可以一边运行主程序,一边用另一个或多个线程来做垃圾收集呢?
这是可以的。实际上除了少量的时候需要停下整个程序比如一开头处理所有的GC根其他时候是可以并发的这样就进一步减少了总的停顿时间。
课程小结
今天这一讲,我带你了解运行时中的一个重要组成部分:垃圾收集器。采用什么样的垃圾收集算法,是实现一门语言时要着重考虑的点。
垃圾收集算法包含的内容有很多,我们这一讲并没有展开所有的内容,而是聚焦在介绍常用的几种算法(比如引用计数、分代收集、增量收集等)的原理,以及几种典型语言的编译器是如何跟选定的垃圾收集算法配合的。比如,在生成目标代码的时候,生成安全点、写屏障和读屏障的代码,修改引用数的代码,以及能够减少垃圾收集工作的一些优化工作。
我把今天的知识点做成了思维导图,供你参考:
一课一思
我们说垃圾收集是跟语言的设计有关的。那么,你是否可以想一下,怎样设计语言可以减少垃圾收集工作呢?欢迎分享你的观点。

View File

@ -0,0 +1,359 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 并发中的编译技术(一):如何从语言层面支持线程?
你好,我是宫文学。
现代的编程语言,开始越来越多地采用并发计算的模式。这也对语言的设计和编译技术提出了要求,需要能够更方便地利用计算机的多核处理能力。
并发计算需求的增长跟两个趋势有关一是CPU在制程上的挑战越来越大逼近物理极限主频提升也越来越慢计算能力的提升主要靠核数的增加比如现在的手机核数越来越多动不动就8核、12核用于服务器的CPU核数则更多二是现代应用对并发处理的需求越来越高云计算、人工智能、大数据和5G都会吃掉大量的计算量。
因此在现代语言中友好的并发处理能力是一项重要特性也就需要编译技术进行相应的配合。现代计算机语言采用了多种并发技术包括线程、协程、Actor模式等。我会用三讲来带你了解它们从而理解编译技术要如何与这些并发计算模式相配合。
这一讲我们重点探讨线程模式它是现代计算机语言中支持并发的基础模式。它也是讨论协程和Actor等其他话题的基础。
不过在此之前,我们需要先了解一下并发计算的一点底层机制:并行与并发、进程和线程。
并发的底层机制:并行与并发、进程与线程
我们先来学习一下硬件层面对并行计算的支持。
假设你的计算机有两颗CPU每颗CPU有两个内核那么在同一时间至少可以有4个程序同时运行。
后来CPU厂商又发明了超线程Hyper Threading技术让一个内核可以同时执行两个线程增加对CPU内部功能单元的利用率这有点像我们之前讲过的流水线技术。这样一来在操作系统里就可以虚拟出8个内核或者叫做操作系统线程在同一时间可以有8个程序同时运行。这种真正的同时运行我们叫做并行parallelism
图1虚拟内核与CPU真实内核的对应关系
可是仅仅8路并行也不够用呀。如果你去查看一下自己电脑里的进程数会发现运行着几十个进程而线程数就更多了。
所以操作系统会用分时技术让一个程序执行一段时间停下来再让另一个程序运行。由于时间片切得很短对于每一个程序来说感觉上似乎一直在运行。这种“同时”能处理多个任务但实际上并不一定是真正同时执行的就叫做并发Concurrency
实际上哪怕我们的计算机只有一个内核我们也可以实现多个任务的并发执行。这通常是由操作系统的一个调度程序Scheduler来实现的。但是有一点操作系统在调度多个任务的时候是有一定开销的
一开始是以进程为单位来做调度,开销比较大。
在切换进程的时候,要保存当前进程的上下文,加载下一个进程的上下文,也会有一定的开销。由于进程是一个比较大的单位,其上下文的信息也比较多,包括用户级上下文(程序代码、静态数据、用户堆栈等)、寄存器上下文(各种寄存器的值)和系统级上下文(操作系统中与该进程有关的信息,包括进程控制块、内存管理信息、内核栈等)。
相比于进程线程技术就要轻量级一些。在一个进程内部可以有多个线程每个线程都共享进程的资源包括内存资源代码、静态数据、堆、操作系统资源如文件描述符、网络连接等和安全属性用户ID等但拥有自己的栈和寄存器资源。这样一来线程的上下文包含的信息比较少所以切换起来开销就比较小可以把宝贵的CPU时间用于执行用户的任务。
总结起来,线程是操作系统做并发调度的基本单位,并且可以跟同一个进程内的其他线程共享内存等资源。操作系统会让一个线程运行一段时间,然后把它停下来,把它所使用的寄存器保存起来,接着让另一个线程运行,这就是线程调度原理。你要在大脑里记下这个场景,这样对理解后面所探讨的所有并发技术都很有帮助。
图2进程的共享资源和线程私有的资源
我们通常把进程作为资源分配的基本单元而把线程作为并发执行的基本单元。不过有的时候用进程作为并发的单元也是比较好的比如谷歌浏览器每打开一个Tab页就新启动一个进程。这是因为浏览器中多个进程之间不需要有互动。并且由于各个进程所使用的资源是独立的所以一个进程崩溃也不会影响到另一个。
而如果采用线程模型的话由于它比较轻量级消耗的资源比较少所以你可以在一个操作系统上启动几千个线程这样就能执行更多的并发任务。所以在一般的网络编程模型中我们可以针对每个网络连接都启动一条线程来处理该网络连接上的请求。在第二个模块中我们分析过的MySQL就是这样做的。你每次跟MySQL建立连接它就会启动一条线程来响应你的查询请求。
采用线程模型的话程序就可以在不同线程之间共享数据。比如在数据库系统中如果一个客户端提交了一条SQL那么这个SQL的编译结果可以被缓存起来。如果另一个用户恰好也执行了同一个SQL那么就可以不用再编译一遍因为两条线程可以访问共享的内存。
但是共享内存也会带来一些问题。当多个线程访问同样的数据的时候,会出现数据处理的错误。如果使用并发程序会造成错误,那当然不是我们所希望的。所以,我们就要采用一定的技术去消除这些错误。
Java语言内置的并发模型就是线程模型并且在语法层面为线程模型提供了一些原生的支持。所以接下来我们先借助Java语言去了解一下如何用编译技术来配合线程模型。
Java的并发机制
Java从语言层面上对并发编程提供了支持简化了程序的开发。
Java对操作系统的线程进行了封装程序员使用Thread类或者让一个类实现Runnable接口就可以作为一个线程运行。Thread类提供了一些方法能够控制线程的运行并能够在多个线程之间协作。
从语法角度与并发有关的关键字有synchronized和volatile。它们就是用于解决多个线程访问共享内存的难题。
synchronized关键字保证操作的原子性
我们通过一个例子,来看看多个线程访问共享数据的时候,为什么会导致数据错误。
public class TestThread {
public static void main(String[] args) {
Num num = new Num();
for (int i = 0; i < 3; i++) {
new NewThread(num).start();
}
}
}
//线程类NewThread 对数字进行操作
class NewThread extends Thread {
private Num num;
public NewThread(Num num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++)
num.add();
System.out.println("num.num:" + num.value);
}
}
//给数字加1
class Num {
public int value = 0;
public void add() {
value += 1;
}
}
在这个例子中每个线程对Num中的value加1000次。按说总有一个线程最后结束这个时候打印出来的次数是3000次。可实际运行的时候却发现很难对上这个数字通常都要小几百。下面是几次运行的结果
要找到其中的原因最直接的方法是从add函数的字节码入手研究。学习过编译原理之后你要养成直接看字节码、汇编码来研究底层机制的习惯这样往往会对问题的研究更加透彻。add函数的字节码如下
0: aload_0 #加载Num对象
1: dup #复制栈顶对象(Num)
2: getfield #弹出一个Num对象从内存取出value的值加载到栈
5: iconst_1 #加载整数1到栈
6: iadd #执行加法,结果放到栈中
7: putfield #栈帧弹出加法的结果和Num对象写字段值即把value的值写回内存
10: return
看着这一段字节码你是不是会重新回忆起加法的计算过程它实际上是4个步骤
从内存加载value的值到栈
把1加载到栈
从栈里弹出value的值和1并做加法
把新的value的值存回到内存里。
这是一个线程执行的过程。如果是两个以上的线程呢你就会发现有问题了。线程1刚执行getfield取回value的值线程2也做了同样的操作那么它们取到的值是同一个。做完加法以后写回内存的时候写的也是同一个值都是3。
图3多个线程访问共享内存的情况
这样一分析你就能理解数字计算错误的原因了。总结起来出现这种现象是因为对value的加法操作不符合原子性Atomic。原子性的意思是一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断要么就都不执行。如果对value加1是一个原子操作那么线程1一下子就操作完了value的值从2一下子变成3。线程2只能接着对3再加1无法在线程1执行到一半的时候就已经介入。
解决办法就是让这段代码每次只允许一个线程执行不会出现多个线程交叉执行的情况从而保证对value值修改的原子性。这个时候就可以用到synchronized关键字了
public void add() {
synchronized(this){
value += 1;
}
}
这样再运行示例程序就会发现总有一个线程打印出来的值是3000。这证明确实一共对value做了3000次加1的运算。
那synchronized关键字作用的原理是什么呢要回答这个问题我们还是要研究一下add()方法的字节码。
在字节码中你会发现两个特殊的指令monitorenter和monitorexit指令就是它们实现了并发控制。
查看字节码的描述我们可以发现monitorenter的作用是试图获取某个对象引用的监视器monitor的所有权。什么是监视器呢在其他文献中你可能会读到“锁”的概念。监视器和锁其实是一个意思。这个锁是关联到一个Num对象的也就是代码中的this变量。只有获取了这把锁的程序才能执行块中的代码也就是“value += 1”。
具体来说当程序执行到monitorenter的时候会产生下面的情况
如果监视器的进入计数是0线程就会进入监视器并将进入计数修改为1。这个时候该线程就拥有了该监视器。
如果该线程已经拥有了该监视器那么就重新进入并将进入计数加1。
如果其他线程拥有该监视器那么该线程就会被阻塞block直到监视器的进入计数变为0然后再重新试图获取拥有权。
monitorexit指令的机制则比较简单就是把进入计数减1。如果一段程序被当前线程进入了多次那么也会退出同样的次数直到进入计数为0。
总结起来,我们用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性。
到这里了你可能会继续追问如何保证获取锁的操作是原子性的如果某线程看到监视器的进入计数是0这个时候它就进去但在它修改进入计数之前如果另一个线程也进去了怎么办也修改成1怎么办这样两个线程会不会都认为自己获得了锁
这个担心是非常有必要的。实际上,要实现原子操作,仅仅从软件角度做工作是不行的,还必须要有底层硬件的支持。具体是如何支持的呢?我们还是采用一贯的方法,直接看汇编代码。
你可以用第13讲学过的方法获取Num.add()方法对应的汇编代码,看看在汇编层面,监视器是如何实现的。我截取了一段汇编代码,并标注了其中的一些关键步骤,你可以看看。
汇编代码首先会跳到一段代码去获取监视器。如果获取成功那么就跳转回来执行后面对value做加法的运算。
我们再继续看一下获取监视器的汇编代码:
你特别需要注意的是cmpxchg指令它能够通过一条指令完成比较和交换的操作。查看Intel的手册你会发现更详细的解释把rax寄存器的值与cmpxchg的目的操作数的值做对比。如果两个值相等那么就把源操作数的值设置到目的操作数否则就把目的操作数的值设置到rax寄存器。
那cmpxchg指令有什么用呢原来通过这样一条指令计算机就能支持原子操作。
比如监视器的计数器的值一开始是0。我想让它的值加1从而获取这个监视器。首先我根据r11寄存器中保存的地址从内存中读出监视器初始的计数发现它是0接着我就把这个初始值放入rax第三步我把新的值也就是1放入r10寄存器。最后我执行cmpxchg指令
cmpxchg QWORD PTR [r11],r10
这个指令把当前的监视器计数也就是内存地址是r11的值跟rax的值做比较。如果它俩相等仍然是0那就意味着没有别的程序去修改监视器计数。这个时候该指令就会把r10的值设置到监视器计数中也就是修改为1。如果有别的程序已经修改了计数器的值那么就会把计数器现在的值写到rax中。
补充实际执行的时候r10中的值并不是简单的0和1而是获取了Java对象的对象头并设置了其中与锁有关的标志位。
所以通过cmpxchg指令要么获得监视器成功要么失败肯定不会出现两个程序都以为自己获得了监视器的情况。
正因为cmpxchg在硬件级把原来的两个指令比较指令和交换指令Compare and Swap合并成了一个指令才能同时完成两个操作首先看看当前值有没有被改动然后设置正确的值。这也是Java语言中与锁有关的API得以运行的底层原理也是操作系统和数据库系统加锁的原理。
不过在汇编代码中我们看到cmpxchg指令前面还有一个lock的前缀。这是起什么作用的呢
原来呀cmpxchg指令在一个内核中执行的时候可以保证原子性。但是如果两个内核同时执行这条指令也可能再次发生两个内核都去写入从而都认为自己写成功了的情况。lock前缀的作用就是让这条指令在同一时间只能有一个内核去执行。
所以说要从根本上保证原子性真不是一件容易的事情。不过不管怎么说通过CPU的支持我们确实能够实现原子操作了能让一段代码在同一个时间只让一个线程执行从而避免了多线程的竞争现象。
上面说的synchronized关键字是采用了锁的机制保证被保护的代码块在同一时刻只能被一个线程访问从而保证了相关操作的原子性。Java还有另一个与并发有关的关键字就是volatile。
volatile关键字解决变量的可见性问题
那volatile关键字是针对什么问题的呢我先来告诉你答案它解决的是变量的可见性Visibility
你可以先回想一下自己是不是遇到过这个问题在并发计算的时候如果两个线程都需要访问同一个变量其中线程1修改了变量的值那在多个CPU的情况下线程2有的时候就会读不到最新的值。为什么呢
图4不同线程使用不同的高速缓存的情形
因为CPU里都有高速缓存用来提高CPU访问内存数据的速度。当线程1写一个值的时候它不一定会马上被写回到内存这要根据高速缓存的写策略来决定这有点像你写一个文件到磁盘上其实不会即时写进去而是会先保存到缓冲区然后批量写到磁盘这样整体效率是最高的。同样当线程2读取这个值的时候它可能是从高速缓存读取的而没有从内存刷新数据所以读到的可能是个旧数据即使内存中的数据已经更新了。
volatile关键字就是来解决这个问题的。它会告诉编译器有多个线程可能会修改这个变量所以当某个线程写数据的时候要写回到内存而不仅仅是写到高速缓存当读数据的时候要从内存中读而不能从高速缓存读。
在下面的示例程序中两个线程共享了同一个Num对象其中线程2会去修改Num.value的值而线程1会读取Num.value的值。
public class TestVolatile {
public static void main(String[] args) {
new TestVolatile().doTest();
}
public void doTest(){
Num num = new Num();
new MyThread1(num).start();
new MyThread2(num).start();
}
//线程1:读取Num.value的值。如果该值发生了变化那么就打印出来。
class MyThread1 extends Thread {
private Num num;
public MyThread1(Num num) {
this.num = num;
}
@Override
public void run() {
int localValue = num.value;
while (localValue < 10){
if (localValue != num.value){ //发现num.value变了
System.out.println("Value changed to: " + num.value);
localValue = num.value;
}
}
}
}
//线程2修改Num.value的值。
class MyThread2 extends Thread {
private Num num;
public MyThread2(Num num) {
this.num = num;
}
@Override
public void run() {
int localValue = num.value;
while(num.value < 10){
localValue ++;
System.out.println("Change value to: " + localValue);
num.value = localValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Num {
public volatile int value = 0; //用volatile关键字修饰value
}
}
如果value字段的前面没有volatile关键字那么线程1经常不能及时读到value的变化
而如果加了volatile关键字那么每次value的变化都会马上被线程1检测到
通过这样一个简单的例子你能更加直观地理解为什么可见性是一个重要的问题并且能够看到volatile关键字的效果。所以volatile关键字的作用是让程序在访问被修饰的变量的内存时让其他处理器能够见到该变量最新的值。那这是怎么实现的呢
原来这里用到了一种叫做内存屏障Memory Barriers的技术。简单地说编译器要在涉及volatile变量读写的时候执行一些特殊的指令让其他处理器获得该变量最新的值而不是自己的一份拷贝比如在高速缓存中
根据内存访问顺序的不同这些内存屏障可以分为四种分别是LoadLoad屏障、StoreStore屏障、LoadStore屏障和StoreLoad屏障。以LoadLoad屏障为例它的指令序列是
Load1指令
LoadLoad屏障
Load2指令
在这种情况下LoadLoad屏障会确保Load1的数据在Load2和后续Load指令之前被真实地加载。
我们看一个例子。在下面的示例程序中列出了用到Load1指令和Load2指令的场景。这个时候编译器就要在这两条指令之间插入一个LoadLoad屏障
class Foo{
volatile int a;
int b, c;
void foo(){
int i, j;
i = a; // Load1指令,针对volatile变量
j = b; // Load2指令针对普通变量
}
}
关于另几种内存屏障的说明,以及在什么时候需要插入内存屏障指令,你可以看下这篇文章。
另外不同的CPU对于这四类屏障所对应的指令是不同的。下图也是从上面那篇文章里摘出来的
可以看到对于x86芯片其中的LoadStore、LoadLoad和StoreStore屏障都用一个no-op指令等待前一个指令执行完毕即可。这就能确保读到正确的值。唯独对于StoreLoad的情况也就是我们TestVolatile示例程序中一个线程写、另一个线程读的情况需要用到几个特殊的指令之一比如mfence指令、cpuid指令或者在一个指令前面加锁lock前缀
总结起来其实synchronized关键字也好volatile关键字也好都是用来保证线程之间的同步的。只不过synchronized能够保证操作的原子性但付出的性能代价更高而volatile则只同步数据的可见性付出的性能代价会低一点。
在Java语言规范中在多线程情况下与共享变量访问有关的内容被叫做Java的内存模型并单独占了一节。这里面规定了在对内存包括类的字段、数组中的元素做操作的时候哪些顺序是必须得到保证的否则程序就会出错。
这些规定跟编译器的实现有比较大的关系。编译器在做优化的时候会对指令做重排序。在重排序的时候一定要遵守Java内存模型中对执行顺序的规定否则运行结果就会出错。
课程小结
今天这一讲我主要以Java语言为例讲解了多线程的原理以及相关的程序语义。多个线程如果要访问共享的数据通常需要进行同步。synchronized关键字能通过锁的机制保证操作的原子性。而volatile关键字则能通过内存屏障机制在不同的处理器之间同步共享变量的值。
你会发现,在写编译器的时候,只有正确地理解了这些语义和原理,才能生成正确的目标代码,所以这一讲的内容你必须要理解。学会今天这讲,还有一个作用,就是能够帮助你加深对多线程编程的底层机制的理解,更好地编写这方面的程序。
其他语言在实现多线程机制时,所使用的语法可能不同,但底层机制都是相同的。通过今天的讲解,你可以举一反三。
我把今天这讲思维导图也整理出来了,供你参考:
一课一思
在你之前的项目经验中,有没有遇到并发处理不当而导致的问题?你是怎么解决的呢?欢迎分享你的经验。
参考资料
The JSR-133 Cookbook for Compiler Writers介绍了编译器如何为Java语言实现内存屏障。
Java语言规范中对内存模型的相关规定。

View File

@ -0,0 +1,322 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 并发中的编译技术(二):如何从语言层面支持协程?
你好,我是宫文学。
上一讲我们提到了线程模式是当前计算机语言支持并发的主要方式。
不过,在有些情况下,线程模式并不能满足要求。当需要运行大量并发任务的时候,线程消耗的内存、线程上下文切换的开销都太大。这就限制了程序所能支持的并发任务的数量。
在这个背景下一个很“古老”的技术重新焕发了青春这就是协程Coroutine。它能以非常低的代价、友好的编程方式支持大量的并发任务。像Go、Python、Kotlin、C#等语言都提供了对协程的支持
今天这一讲,我们就来探究一下如何在计算机语言中支持协程的奇妙功能,它与编译技术又是怎样衔接的。
首先,我们来认识一下协程。
协程Coroutine的特点与使用场景
我说协程“古老”是因为这个概念是在1958年被马尔文 · 康威Melvin Conway提出来、在20世纪60年代又被高德纳Donald Ervin Knuth总结为两种子过程Subroutine的模式之一。一种是我们常见的函数调用的方式而另一种就是协程。在当时计算机的性能很低完全没有现代的多核计算机。而采用协程就能够在这样低的配置上实现并发计算可见它是多么的轻量级。
有的时候,协程又可能被称作绿色线程、纤程等,所采用的技术也各有不同。但总的来说,它们都有一些共同点。
首先协程占用的资源非常少。你可以在自己的笔记本电脑上随意启动几十万个协程而如果你启动的是几十万个线程那结果就是不可想象的。比如在JVM中缺省会为每个线程分配1MB的内存用于线程栈等。这样的话几千个线程就要消耗掉几个GB的内存而几十万个线程理论上需要消耗几百GB的内存这还没算程序在堆中需要申请的内存。当然由于底层操作系统和Java应用服务器的限制你也无法启动这么多线程。
其次协程是用户自己的程序所控制的并发。也就是说协程模式一般是程序交出运行权之后又被另外的程序唤起继续执行整个过程完全是由用户程序自己控制的。而线程模式就完全不同了它是由操作系统中的调度器Scheduler来控制的。
我们看个Python的例子
def running_avg():
total = 0.0
count = 0
avg = 0
while True:
num = yield avg
total += num
count += 1
avg = total/count
#生成协程,不会有任何输出
ra = running_avg()
#运行到yield
next(ra)
print(ra.send(2))
print(ra.send(3))
print(ra.send(4))
print(ra.send(7))
print(ra.send(9))
print(ra.send(11))
#关掉协程
ra.close
可以看到,使用协程跟我们平常使用函数几乎没啥差别,对编程人员很友好。实际上,它可以认为是跟函数并列的一种子程序形式。和函数的区别是,函数调用时,调用者跟被调用者之间像是一种上下级的关系;而在协程中,调用者跟被调用者更像是互相协作的关系,比如一个是生产者,一个是消费者。这也是“协程”这个名字直观反映出来的含义。
我们用一张图来对比下函数和协程中的调用关系。
图1函数与协程的控制流
细想一下,编程的时候,这种需要子程序之间互相协作的场景有很多,我们一起看两种比较常见的场景。
第一种比较典型的场景就是生产者和消费者模式。如果你用过Unix管道或者消息队列编程的话会非常熟悉这种模式。但那是在多个进程之间的协作。如果用协程的话在一个进程内部就能实现这种协作非常轻量级。
就拿编译器前端来说词法分析器Tokenizer和语法分析器Parser就可以是这样的协作关系。也就是说为了更好的性能没有必要一次把词法分析完毕而是语法分析器消费一个就让词法分析器生产一个。因此这个过程就没有必要做成两个线程了否则就太重量级了。这种场景我们可以叫做生成器Generator场景主程序调用生成器给自己提供数据。
特别适合使用协程的第二种场景是IO密集型的应用。比如做一个网络爬虫同时执行很多下载任务或者做一个服务器同时响应很多客户端的请求这样的任务大部分时间是在等待网络传输。
如果用同步阻塞的方式来做一个下载任务在等待的时候就会把整个线程阻塞掉。而用异步的方式协程在发起请求之后就把控制权交出调度程序接收到数据之后再重新激活协程这样就能高效地完成IO操作同时看上去又是用同步的方式编程不需要像异步编程那样写一大堆难以阅读的回调逻辑。
这样的场景在微服务架构的应用中很常见,我们来简化一个实际应用场景,分析下如何使用协程。
在下面的示例中应用A从客户端接收大量的并发请求而应用A需要访问应用B的服务接口从中获得一些信息然后返回给客户端。
图2应用间通讯的场景
要满足这样的场景,我们最容易想到的就是,编写同步通讯的程序,其实就是同步调用。
假设应用A对于每一个客户端的请求都会起一个线程做处理。而你呢则在这个线程里发起一个针对应用B的请求。在等待网络返回结果的时候当前线程会被阻塞住。
图3采用同步编程实现应用间的通讯
这个架构是最简单的你如果采用Java的Servlet容器来编写程序的话很可能会采用这个结构。但它有一些缺陷
对于每个客户端请求都要起一个线程。如果请求应用B的时延比较长那么在应用A里会积压成千上万的线程从而浪费大量的服务器资源。而且当线程超过一定数量应用服务器就会拒绝后续的请求。
大量的请求毫无节制地涌向应用B使得应用B难以承受负载从而导致响应变慢甚至宕机。
因为同步调用的这种缺点近年来异步编程模型得到了更多的应用典型的就是Node.js。在异步编程模型中网络通讯等IO操作不必阻塞线程而是通过回调来让主程序继续执行后续的逻辑。
图4使用异步编程实现应用间通讯
上图中我们只用到了4个线程对应操作系统的4个真线程可以减少线程切换的开销。在每个线程里维护一个任务队列。首先getDataFromApp2()会被放到任务队列当数据返回以后系统的调度器会把sendBack()函数放进任务队列。
这个例子比较简单只有一层回调你还能读懂它的逻辑。但是采用这种异步编程模式经常会导致多层回调让代码很难阅读。这种现象被叫做“回调地狱Callback Hell”。
这时候,就显示出协程的优势了。协程可以让你用自己熟悉的命令式编程的风格,来编写异步的程序。比如,对于上面的示例程序,用协程可以这样写,看上去跟编写同步调用的代码没啥区别。
requestHandler(){
...;
await getDataFromApp2();
...;
sendBack();
}
当然我要强调一下在协程用于同步和异步编程的时候其调度机制是不同的。跟异步编程配合的时候要把异步IO机制与协程调度机制关联起来。
好了,现在你已经了解了协程的特点和适用场景。那么问题来了,如何让一门语言支持协程呢?要回答这个问题,我们就要先学习一下协程的运行原理。
协程的运行原理
当我们使用函数的时候简单地保持一个调用栈就行了。当fun1调用fun2的时候就往栈里增加一个新的栈帧用于保存fun2的本地变量、参数等信息这个函数执行完毕的时候fun2的栈帧会被弹出恢复栈顶指针sp并跳转到返回地址调用fun2的下一条指令继续执行调用者fun1的代码。
图5调用函数时的控制流和栈桢管理
但如果调用的是协程coroutine1该怎么处理协程的栈帧呢因为协程并没有执行完显然还不能把它简单地丢掉。
这种情况下,程序可以从堆里申请一块内存,保存协程的活动记录,包括本地变量的值、程序计数器的值(当前执行位置)等等。这样,当下次再激活这个协程的时候,可以在栈帧和寄存器中恢复这些信息。
图6调用协程时的控制流和栈桢管理
把活动记录保存到堆里,是不是有些眼熟?其实,这有点像闭包的运行机制。
程序在使用闭包的时候也需要在堆里保存闭包中的自由变量的信息并且在下一次调用的时候从堆里恢复。只不过闭包不需要保存本地变量只保存自由变量就行了也不需要保存程序计数器的值因为再一次调用闭包函数的时候还是从头执行而协程则是接着执行yield之后的语句。
fun1通过resume语句让协程继续运行。这个时候协程会去调用一个普通函数fun2而fun2的栈帧也会加到栈上。
图7在协程里调用普通函数时的栈桢情况
如果fun2执行完毕那么就会返回到协程。而协程也会接着执行下一个语句这个语句是一个专门针对协程的返回语句我们叫它co_return吧以便区别于传统的return。在执行了co_return以后协程就结束了无法再resume。这样的话保存在堆里的活动记录也就可以销毁了。
图8协程结束时对栈桢的处理
通过上面的例子,你应该已经了解了协程的运行原理。那么我们学习编译原理会关心的问题是:实现协程的调度,包括协程信息的保存与恢复、指令的跳转,需要编译器的帮忙吗?还是用一个库就可以实现?
实际上对于C和C++这样的语言来说确实用一个库就可以实现。因为C和C++比较灵活比如可以用setjmp、longjmp等函数跨越函数的边界做指令的跳转。但如果用库实现通常要由程序管理哪些状态信息需要被保存下来。为此你可能要专门设计一个类型来参与实现协程状态信息的维护。
而如果用编译器帮忙那么就可以自动确定需要保存的协程的状态信息并确定需要申请的内存大小。一个协程和函数的区别就仅仅在于是否使用了yield和co_return语句而已减轻了程序员编程的负担。
好了刚才我们讨论了在实现协程的时候要能够正确保存协程的活动记录。在具体实现上有Stackful和Stackless两种机制。采用不同的机制对于协程能支持的特性也很有关系。所以接下来我带你再进一步地分析一下Stackful和Stackless这两种机制。
Stackful和Stackless的协程
到目前为止,看上去协程跟普通函数(子程序)的差别也不大嘛,你看:
都是由一个主程序调用,运行一段时间以后再把控制流交回给主程序;
都使用栈来管理本地变量和参数等信息,只不过协程在没有完全运行完毕时,要用堆来保存活动记录;
在协程里也可以调用其他的函数。
可是在有的情况下我们没有办法直接在coroutine1里确定是否要暂停线程的执行可能需要在下一级的子程序中来确定。比如说coroutine1函数变得太大我们重构后把它的功能分配到了几个子程序中。那么暂停协程的功能也会被分配到子程序中。
图9在辅助函数里暂停协程时的控制流和栈桢情况
这个时候在helper()中暂停协程会让控制流回到fun1函数。而当在fun1中调用resume的时候控制流应该回到helper()函数中yield语句的下一条继续执行。coroutine1()和helper()加在一起起到了跟原来只有一个coroutine1()一样的效果。
这个时候在栈里不仅要加载helper()的活动记录还要加载它的上一级也就是coroutine1()的活动记录这样才能维护正确的调用顺序。当helper()执行完毕的时候控制流会回到coroutine1(),继续执行里面的逻辑。
在这个场景下,不仅要从堆里恢复多个活动记录,还要维护它们之间的正确顺序。上面的示例中,还只有两级调用。如果存在多级的调用,那就更麻烦了。
那么,怎么解决这个技术问题呢?你会发现,其实协程的逐级调用过程,形成了自己的调用栈,这个调用栈需要作为一个整体来使用,不能拆成一个个单独的活动记录。
既然如此那我们就加入一个辅助的运行栈好了。这个栈通常被叫做Side Stack。每个协程都有一个自己专享的协程栈。
图10协程的Side Stack
好了现在是时候给你介绍两个术语了这种需要一个辅助的栈来运行协程的机制叫做Stackful Coroutine而在主栈上运行协程的机制叫做Stackless Coroutine。
对于Stackless的协程来说只能在顶层的函数里把控制权交回给调用者。如果这个协程调用了其他函数或者协程必须等它们返回后才能去执行暂停协程的操作。从这种角度看Stackless的特征更像一个函数。
而对于Stackful的协程来说可以在协程栈的任意一级暂停协程的运行。从这个角度看Stackful的协程像是一个线程不管有多少级的调用随时可以让这个协程暂停交出控制权。
除此之外我们再仔细去想因为设计上的不同Stackless和Stackful的协程其实还会产生其他的差别
Stackless的协程用的是主线程的栈也就是说它基本上会被绑定在创建它的线程上了。而Stackful的协程可以从一个线程脱离附加到另一个线程上。
Stackless的协程的生命周期一般来说受制于它的创建者的生命周期。而Stackful的协程的生命周期可以超过它的创建者的生命周期。
好了以上就是对Stackless和Stackful的协程的概念和区别了。其实对于协程我们可能还会听说一种分类方法就是对称的和非对称的。
到目前为止,我们讲到的协程都是非对称的。有一个主程序,而协程像是子程序。主程序和子程序控制程序执行的原语是不同的,一个用于激活协程,另一个用于暂停协程。而对称的协程,相互之间是平等的关系,它们使用相同的原语在协程之间移交控制权。
那么C++、Python、Java、JavaScript、Julia和Go这些常见语言中哪些是支持协程的是Stackless的 还是Stackful的是对称的还是非对称的需要编译器做什么配合
接下来,我们就一起梳理下。
不同语言的协程实现和差异
C++语言的协程实现
今年发布的C++20标准中增加了协程特性。标准化组织经过长期的讨论采用了微软的Stackless模式。采纳的原因也比较简单就是因为它实现起来简单可靠并且已经在微软有多年的成熟运行的经验了。
在这个方案里采用了co_await、co_yield和co_return三个新的关键字让程序员使用协程并在编译器层面给予了支持。
而我们说过C和C++的协程功能只用库也可以实现。其中腾讯的微信团队就开源了一套协程库叫做libco。这个协程库是支撑微信背后海量并发调用的基础。采用这个协程库单机竟然可以处理千万级的连接
并且libco还做了一点创新。因为libco是Stackful的对每个协程都要分配一块栈空间在libco中给每个协程分配的是128KB。那么1千万协程就需要1.2TB的内存这样服务器的内存就会成为资源瓶颈。所以libco发明了共享栈的机制当一个协程不用栈的时候把里面的活动记录保存到协程私有的内存中把协程栈腾出来给其他协程使用。一般来说一个活动记录的大小要远小于128KB所以总体上节省了内存。
另外libco还跟异步通讯机制紧密整合实现了用同步的编程风格来实现异步的功能使得微信后台的处理能力大大提升。微信后台用协程做升级的案例你可以看看这篇文章。
接下来我们说说Python语言的协程实现。
Python语言的协程实现
我们前面讲协程的运行原理用的示例程序就是用Python写的。这是Python的一种协程的实现支持的是同步处理叫做generator模式。3.4版本之后Python支持一种异步IO的协程模式采用了async/await关键字能够以同步的语法编写异步程序。
总体来说Python是一种解释型的语言而且内部所有成员都是对象所以实现协程的机制是很简单的保存协程的执行状态也很容易。只不过你不可能把Python用于像刚才微信那样高并发的场景因为解释型语言对资源的消耗太高了。尽管如此在把Python当作脚本语言使用的场景中比如编写网络爬虫采用它提供的协程加异步编程的机制还是能够带来很多好处的。
我们再来说说Java和JavaScript语言的协程实现。
Java的协程实现
其实Java原生是不支持协程的但是也有几种方法可以让Java支持协程
方法1给虚拟机打补丁从底层支持协程。
方法2做字节码操纵从而改变Java缺省的控制流执行方式并保存协程的活动记录。
方法3基于JNI。比如C语言可以实现协程然后再用JNI去调用C语言实现的功能。
方法4把线程封装成协程。这种实现技术太过于重量级因为没有体现出协程占据资源少的优点。
现在有一些第三方库实现了协程功能基本上都是基于方法2也就是做字节码操纵。目前还没有哪一个库被广泛接受。如果你不想冒险的话可能还是要等待官方的实现了。
JavaScript中的协程
JavaScript从ES6ECMAScript 6.0引入了generator功能ES7引入了支持异步编程的async/await。由于JavaScript本来就非常重视异步编程所以协程的引入会让异步编程变得更友好。
Julia和Go语言的协程实现
Julia语言的协程机制跟以上几种语言都不同。它提供的是对称的协程机制。多个协程可以通过channel通讯当从channel里取不出信息时或者channel已满不能再发信息时自然就停下来了。
当我谈到channel的时候熟悉Go语言的同学马上就会想到Goroutine。Goroutine是Go语言的协程机制也是用channel实现协程间的协作的。
我把对Go语言协程机制的介绍放在最后是因为Goroutine实在是很强大。我觉得所有对并发编程有兴趣的同学都要看一看Goroutine的实现机制都会得到很大的启发。
我的感受是Goroutine简直是实现轻量级并发功能的集大成者几乎考虑到了你能想到的所有因素。介绍Goroutine的文章有很多我就不去重复已有的内容了你可以看看“How Stacks are Handled in Go”这篇文章。现在我就顺着本讲的知识点对Goroutine的部分特点做一点介绍。
首先我们来看一下Goroutine是Stackful还是Stackless答案是Stackful的。就像我们前面已经总结过的Stackful协程的特点主要是两点协程的生命周期可以超过其创建者以及协程可以从一个线程转移到另一个线程。后者在Goroutine里特别有用。当一个协程调用了一个系统功能导致线程阻塞的时候那么排在这条线程上的其他Goroutine岂不是也要被迫等待为了避免这种尴尬Goroutine的调度程序会把被阻塞的线程上的其他Goroutine迁移到其他线程上。
我们讲libco的时候还讲过Stackful的缺点是要预先分配比较多的内存用作协程的栈空间比如libco要为每个协程分配128K的栈。而Go语言只需要为每个Goroutine分配2KB的栈。你可能会问了万一空间不够了怎么办不会导致内存访问错误吗
不会的。Go语言的函数在运行的时候会有一小块序曲代码用来检查栈空间够不够用。如果不够用就马上申请新的内存。需要注意的是像这样的机制必须有编译器的配合才行编译器可以为每个函数生成这样的序曲代码。如果你用库来实现协程就无法实现这样的功能。
通过这个例子,你也可以体会到把某个特性做成语言原生的,以及用库去实现的差别。
我想说的Go语言协程机制的第二个特点就是channel机制。channel提供了Goroutine之间互相通讯从而能够协调行为的机制。Go语言的运行时保证了在同一个时刻只有一个Goroutine能够读写channel这就避免了我们前一讲提到的用锁来保证多个线程访问共享数据的难题。当然channel在底层也采用了锁的机制毕竟现在不需要程序员去使用这么复杂且容易出错的机制了。
Go语言协程机制的第三个特点是关于协程的调度时机。今天这一讲我们其实看到了两种调度时机对于generator类型的协程基本上是同步调度的协程暂停以后控制立即就回到主程序第二个调度机制是跟异步IO机制配合。
而我关心的是能否实现像线程那样的抢占式preemptive的调度。操作系统的线程调度器在进行调度的时候可以不管当前线程运行到了什么位置直接中断它的运行并把相关的寄存器的值保存下来然后去运行另一个线程。这种抢占式的调度的一个最大的好处是不会让某个程序霸占CPU资源不放而是公平地分配给各个程序。而协程也存在着类似的问题。如果一个协程长时间运行那么排在这条线程上的其他协程就被剥夺了运行的机会。
Goroutine在解决这个问题上也做了一些努力。比如在下面的示例程序中foo函数中的循环会一直运行。这时候编译器就可以在bar()函数的序曲中插入一些代码检查当前协程是否运行时间太久从而主动让出控制权。不过如果bar()函数被内联了,处理方式就要有所变化。但总的来说,由于有编译器的参与,这种类似抢占的逻辑是可以实现的。
func foo(){
while true{
bar(); //可以在bar函数的序曲中做检查。
}
}
在Goroutine实现了各种丰富的调度机制以后它已经变得不完全由用户的程序来主导协程的调度了而是能够更加智能、更加优化地实现协程的调度由操作系统的线程调度器、Go语言的调度器和用户程序三者配合实现。这也是Go语言的一个重要优点。
那么我们从C、C++、Python、Java、JavaScript、Julia和Go语言中就能总结出协程实现上的特点了
除了Julia和Go其他语言采用的都是非对称的协程机制。Go语言是采用协程最彻底的。在采用了协程以后已经不需要用过去的线程。
像C++、Go这样编译成机器码执行的语言对协程栈的良好管理能够大大降低内存占用增加支持的协程的数量。
协程与异步IO结合是一个趋势。
课程小结
今天这一讲,我们学习了协程的定义、使用场景、实现原理和不同语言的具体实现机制。我们特别从编译技术的角度,关注了协程对栈的使用机制,看看它与传统的程序有什么不同。
在这个过程中,一方面,你会通过今天的课程对协程产生深入的认识;另一方面,你会更加深刻地认识到编译技术是如何跟语言特性的设计和运行时紧密配合的。
协程可以用库实现,也可以借助编译技术成为一门语言的原生特性。采用编译技术,能帮助我们自动计算活动记录的大小,实现自己独特的栈管理机制,实现抢占式调度等功能。
本讲的思维导图我也放在了下面,供你参考:
一课一思
上一讲我们讨论的是线程模式。我们知道当并发访问量非常大的时候线程模式消耗的资源会太多。那么你会如何解决这个问题是否会采用协程如果你使用的是Java语言其原生并不支持协程你会怎么办欢迎发表你的观点。
参考资料
How Stacks are Handled in Go这篇文章介绍了Goroutine使用栈的机制你可以看看它是如何很节约地使用内存的。
Coroutines in Java这篇文章探讨了在Java中实现协程的各种技术考虑。

View File

@ -0,0 +1,213 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 并发中的编译技术Erlang语言厉害在哪里
你好,我是宫文学。
在前面两讲,我们讨论了各门语言支持的并发计算的模型。线程比进程更加轻量级,上下文切换成本更低;协程则比线程更加轻量级,在一台计算机中可以轻易启动几十万、上百万个并发任务。
但不论是线程模型、还是协程模型,当涉及到多个线程访问共享数据的时候,都会出现竞争问题,从而需要用到锁。锁会让其他需要访问该数据的线程等待,从而导致系统整体处理能力的降低。
并且编程人员还要特别注意避免出现死锁。比如线程A持有了锁x并且想要获得锁y而线程B持有了锁y想要获得锁x结果这两个线程就会互相等待谁也进行不下去。像数据库这样的系统检测和消除死锁是一项重要的功能以防止互相等待的线程越来越多对数据库操作不响应并最终崩溃掉。
既然使用锁这么麻烦那在并发计算中能否不使用锁呢这就出现了Actor模型。那么什么是Actor模型为什么它可以不用锁就实现并发这个并发模型有什么特点需要编译技术做什么配合
今天这一讲我们就从这几个问题出发一起学习并理解Actor模型。借此我们也可以把用编译技术支持不同的并发模型的机制理解得更深刻。
首先我们看一下什么是Actor模型。
什么是Actor模型
在线程和协程模型中,之所以用到锁,是因为两个线程共享了内存,而它们会去修改同一个变量的值。那,如果避免共享内存,是不是就可以消除这个问题了呢?
没错这就是Actor模型的特点。Actor模型是1973年由Carl Hewitt提出的。在Actor模型中并发的程序之间是不共享内存的。它们通过互相发消息来实现协作很多个一起协作的Actor就构成了一个支持并发计算的系统。
我们看一个有三个Actor的例子。
图1三个Actor的例子
你会注意到每个Actor都有一个邮箱用来接收其他Actor发来的消息每个Actor也都可以给其他Actor发送消息。这就是Actor之间交互的方式。Actor A给Actor B发完消息后就返回并不会等着Actor B处理完毕所以它们之间的交互是异步的。如果Actor B要把结果返回给A也是通过发送消息的方式。
这就是Actor大致的工作原理了。因为Actor之间只是互发消息没有共享的变量当然也就不需要用到锁了。
但是,你可能会问:如果不共享内存,能解决传统上需要对资源做竞争性访问的需求吗?比如,卖电影票、卖火车票、秒杀或者转账的场景。我们以卖电影票为例讲解一下。
在用传统的线程或者协程来实现卖电影票功能的时候,对票的状态进行修改,需要用锁的机制实现同步互斥,以保证同一个时间段只有一个线程可以去修改票的状态、把它分配给某个用户,从而避免多个线程同时访问而出现一张票卖给多个人的情况。这种情况下,多个程序是串行执行的,所以系统的性能就很差。
如果用Actor模式会怎样呢
你可以把电影院的前半个场地和后半个场地的票分别由Actor B和 C负责销售Actor A在接收到定前半场座位的请求的时候就发送给Actor B后半场的就发送给Actor CActor B和C依次处理这些请求如果Actor B或C接收到的两个信息都想要某个座位那么针对第二个请求会返回订票失败的消息。
图2Actor用于订票场景
你发现没有在这个场景中Actor B和C仍然是顺序处理各个请求。但因为是两个Actor并发地处理请求所以系统整体的性能会提升到原来的两倍。
甚至你可以让每排座位、每个座位都由一个Actor负责使得系统的性能更高。因为在系统中创建一个Actor的成本是很低的。Actor跟协程类似很轻量级一台服务器里创建几十万、上百万个Actor也没有问题。如果每个Actor负责一个座位那一台服务器也能负责几十万、上百万个座位的销售也是可以接受的。
当然,实际的场景要比这个复杂,比如一次购买多张相邻的票等,但原理是一样的。用这种架构,可以大大提高并发能力,处理海量订票、秒杀等场景不在话下。
其实我个人比较喜欢Actor这种模式因为它跟现实世界里的分工协作很相似。比如餐厅里不同岗位的员工他们通过互相发信息来实现协作从而并发地服务很多就餐的顾客。
分析到这里我再把Actor模式跟你非常熟悉的一个概念面向对象编程Object Oriented ProgrammingOOP关联起来。你可能会问Actor和面向对象怎么还有关联
是的。面向对象语言之父阿伦 · 凯伊Alan KaySmalltalk的发明人在谈到面向对象时是这样说的对象应该像生物的细胞或者是网络上的计算机它们只能通过消息互相通讯。对我来说OOP仅仅意味着消息传递、本地保留和保护以及隐藏状态过程并且尽量推迟万物之间的绑定关系。
I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning it took a while to see how to do messaging in a programming language efficiently enough to be useful)-
…-
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.
总结起来Alan对面向对象的理解强调消息传递、封装和动态绑定没有谈多态、继承等。对照这个理解你会发现Actor模式比现有的流行的面向对象编程语言更加接近面向对象的实现。
无论如何通过把Actor和你熟悉的面向对象做关联我相信能够拉近你跟Actor之间的距离甚至会引发你以新的视角来审视目前流行的面向对象范式。
好了到现在你可以说是对Actor模型比较熟悉了也可以这么理解Actor有点像面向对象程序里的对象里面可以封装一些数据和算法但你不能调用它的方法只能给它发消息它会异步地、并发地处理这些消息。
但是你可能会提出一个疑问Actor模式不用锁的机制就能实现并发程序之间的协作这一点很好那么它有没有什么缺点呢
我们知道任何设计方案都是一种取舍。一个方案有某方面的优势可能就会有其他方面的劣势。采用Actor模式会有两方面的问题。
第一由于Actor之间不共享任何数据因此不仅增加了数据复制的时间还增加了内存占用量。但这也不完全是缺点一方面你可以通过在编写程序时尽量降低消息对象的大小从而减少数据复制导致的开销另一方面消息传递的方式对于本机的Actor和集群中的Actor是一样的这就使得编写分布式的云端应用更简单从而在云计算时代可以获得更好的应用。
第二基于消息的并发机制基本上是采用异步的编程模式这就和通常程序的编程风格有很大的不同。你发出一个消息并不会马上得到结果而要等待另一个Actor发送消息回来。这对于习惯于编写同步代码的同学可能是一个挑战。
好了我们已经讨论了Actor机制的特点。接下来我们再看看什么语言和框架实现了Actor模式。
支持Actor模型的语言和框架
支持Actor的最有名的语言是Erlang。Erlang是爱立信公司发明的它的正式版本是在1987年发布其核心设计者是乔 · 阿姆斯特朗Joe Armstrong最早是用于开发电信领域的软件系统。
在Erlang中每个Actor叫作一个进程Process。但这个“进程”其实不是操作系统意义上的进程而是Erlang运行时的并发调度单位。
Erlang有两个显著的优点首先对并发的支持非常好所以它也被叫做面向并发的编程语言COP。第二用Erlang可以编写高可靠性的软件可以达到9个9。这两个优点都与Actor模式有关
Erlang的软件由很多Actor构成
这些Actor可以分布在多台机器上相互之间的通讯跟在同一台机器上没有区别
某个Actor甚至机器出现故障都不影响整体系统可以在其他机器上重新启动该Actor
Actor的代码可以在运行时更新。
所以由Actor构成的系统真的像一个生命体每个Actor像一个细胞。细胞可以有新陈代谢而生命体却一直存在。可以说用Erlang编写的基于Actor模式的软件非常好地体现了复杂系统的精髓。到这里你是不是就能解释“Erlang语言厉害在哪里”这个问题了。
鉴于Actor为Erlang带来的并发能力和高可靠性有一些比较流行的开源系统就是用Erlang编写的。比如消息队列系统RabbitMQ、分布式的文档数据库系统CouchDB都很好地体现了Erlang的并发能力和健壮性。
除了Erlang以外Scala语言也提供了对Actor的支持它是通过Akka库实现的运行在JVM上。我还关注了微软的一个Orleans项目它在.NET平台上支持Actor模式并进一步做了一些有趣的创新。
那接下来我们继续探讨一下这些语言和框架是如何实现Actor机制的以及需要编译器做什么配合。
Actor模型的实现
在上一讲研究过协程的实现机制以后我们现在再分析Actor的实现机制时其实就应该会把握要点了。比如说我们会去看它的调度机制和内存管理机制等。鉴于Erlang算是支持Actor的最有名、使用最多的语言接下来我会以Erlang的实现机制带你学习Actor机制是如何实现的。
首先我们知道肯定要有个调度器把海量的Actor在多个线程上调度。
并发调度机制
那我们需要细究一下对于Actor该如何做调度呢什么时候把一个Actor停下让另一个Actor运行呢
协程也好Actor也好都是在应用级做调度而不是像线程那样在应用完全不知道的情况下就被操作系统调度了。对于协程我们是通过一些像yield这样的特殊语句触发调度机制。那Actor在什么时候调度比较好呢
前面我们也讲过了Actor的运行规律是每次从邮箱取一条消息并进行处理。那么我们自然会想到一个可选的调度时机就是让Actor每处理完一条消息就暂停一下让别的Actor有机会运行。当然如果处理一条消息所花费的时间太短比如有的消息是可以被忽略的那么处理多条消息累积到一定时间再去调度也行。
了解了调度时机我们再挑战第二个比较难的话题如果处理一条消息就要花费很长时间怎么办呢能否实现抢占式的调度呢就像Goroutine那样
当然可以,但这个时候就肯定需要编译器和运行时的配合了。
Erlang的运行机制是基于一个寄存器机解释执行。这使得调度器可以在合适的时机去停下某个Actor的运行调度其他Actor过来运行。
Erlang做抢占式调度的机制是对Reduction做计数Reduction可以看作是占时不长的一小块工作量。如果某个Actor运行了比较多的Reduction那就可以对它做调度从而提供了软实时的能力具体可以参考这篇文章
在比较新的版本中Erlang也加入了编译成本地代码的特性那么在生成的本地代码中也需要编译器加入对Reduction计数的代码这就有点像Goroutine了。
这也是Erlang和Scala/Akka的区别。Akka没有得到编译器和JVM在底层的支持也就没办法实现抢占式的调度。这有可能让某些特别耗时的Actor影响了其他Actor使得系统的响应时间不稳定。
最后一个涉及调度的话题是I/O与调度的关系。这个关系如果处理得不好那么对系统整体的性能影响会很大。
通常我们编写I/O功能时会采用同步编程模式来获取数据。这个时候操作系统会阻塞当前的线程直到成功获取了数据以后才可以继续执行。
getSomeData(); //操作系统会阻塞住线程,直到获得了数据。
do something else //继续执行
采用这种模式开发一个服务端程序会导致大量线程被阻塞住等待I/O的结果。由于每个线程都需要不少的内存并且线程切换的成本也比较高因此就导致一台服务器能够服务的客户端数量大大降低。如果这时候你在运行时查看服务程序的状态就会发现大量线程在等待CPU利用率也不高而新的客户端又连接不上来造成服务器资源的浪费。
并且如果采用协程等应用级的并发机制一个线程被阻塞以后排在这个线程上的其他协程也只能等待从而导致服务响应时间变得不可靠有时快有时慢。我们在前一讲了解过Goroutine的调度器。它在遇到这种情况的时候就会把这条线程上的其他Goroutine挪到没被阻塞的线程上从而尽快得到运行机会。
由于阻塞式I/O的缺点现在很多语言也提供了非阻塞I/O的机制。在这种机制下程序在做I/O请求的时候并不能马上获得数据。当操作系统准备好数据以后应用程序可以通过轮询或被回调的方式获取数据。Node.js就是采用这种I/O模式的典型代表。
上一讲提到的C++协程库libco也把非阻塞的网络通讯机制和协程机制做了一个很好的整合大大增加了系统的整体性能。
而Erlang在很早以前就解决了这个问题。在Erlang的最底层所有的I/O都是用事件驱动的方式来实现的。系统收到了一块数据就调用应用来处理整个过程都是非阻塞的。
说完了并发调度机制,我们再来看看运行时的另一个重要特征,内存管理机制。
内存管理机制
内存管理机制要考虑栈、堆都怎么设计,以及垃圾收集机制等内容。
图3Erlang的内存模型
首先说栈。每个Actor也需要有自己的栈空间在执行Actor里面的逻辑的时候用于保存本地变量。这跟上一节讲过的Stateful的协程很像。
再来看看堆。Erlang的堆与其他语言有很大的区别它的每个Actor都有自己的堆空间而不是像其他编程模型那样不同的线程共享堆空间。这也很容易理解因为Actor模型的特点就是并发的程序之间没有共享的内存所以当然也就不需要共享的堆了。
再进一步由于每个Actor都有自己的堆因此会给垃圾收集带来很大的便利
因为整个程序划分成了很多个Actor每个Actor都有自己的堆所以每个Actor的垃圾都比较少不用一次回收整个应用的垃圾所以回收速度会很快。
由于没有共享内存所以垃圾收集器不需要停下整个应用而只需要停下被收集的Actor。这就避免了“停下整个世界STW”问题而这个问题是Java、Go等语言面临的重大技术挑战。
如果一个Actor的生命周期结束那么它占用的内存会被马上释放掉。这意味着对于有些生命周期比较短的Actor来说可能压根儿都不需要做垃圾收集。
好了基于Erlang我们学习了Actor的运行时机制的两个重要特征一是并发调度机制二是内存管理机制。那么与此相配合需要编译器做什么工作呢
编译器的配合工作
我们说过Erlang首先是解释执行的是用一个寄存器机来运行字节码。那么编译器的任务就是生成正确的字节码。
之前我们已经分别研究过Graal、Python和V8 Ignition的字节码了。我们知道字节码的设计很大程度上体现了语言的设计特点体现了与运行时的交互过程。Erlang的字节码设计当然也是如此。
比如针对消息的发送和接收它专门提供了send指令和receive指令这体现了Erlang的并发特征。再比如Erlang还提供了与内存管理有关的指令比如分配一个新的栈桢等体现了Erlang在内存管理上的特点。
不过我们知道仅仅以字节码的方式解释执行不能满足计算密集型的需求。所以Erlang也正在努力提供编译成机器码运行的特性这也需要编译器的支持。那你可以想象出生成的机器码一定也会跟运行时配合来实现Erlang特有的并发机制和内存管理机制。
课程小结
今天这一讲我们介绍了另一种并发模型Actor模型。Actor模型的特点是避免在并发的程序之间共享任何信息从而程序就不需要使用锁机制来保证数据的一致性。但是采用Actor机制也会因为数据拷贝导致更大的开销并且你需要习惯异步的编程风格。
Erlang是实现Actor机制的典型代表。它被称为面向并发的编程语言并且能够提供很高的可靠性。这都源于它善用了Actor的特点由Actor构成的系统更像一个生命体一般的复杂系统。
在实现Actor模型的时候你要在运行时里实现独特的调度机制和内存管理机制这些也需要编译器的支持。
本讲的思维导图我也放在了下面,供你参考:
好了今天这一讲加上第33和34讲我们用了三讲介绍了不同计算机语言是如何实现并发机制的。不难看出并发机制确实是计算机语言设计中的一个重点。不同的并发机制会非常深刻地影响计算机语言的运行时的实现以及所采用的编译技术。
一课一思
你是否也曾经采用过消息传递的机制,来实现多个系统或者模块之间的调度?你从中获得了什么经验呢?欢迎你和我分享。
参考资料
Carl Hewitt关于Actor的论文
微软Orleans项目介绍
介绍Erlang虚拟机原理的在线电子书
介绍Erlang字节码的文章

View File

@ -0,0 +1,239 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 高级特性(一):揭秘元编程的实现机制
你好,我是宫文学。
作为一名技术人员我想你肯定知道什么是编程那你有没有听说过“元编程Meta-Programming”这个概念呢
元编程是计算机语言提供的一项重要能力。这么说吧如果你要编写一些比较厉害的程序像是Java世界里的Spring、Hibernate这样的库以及C++的STL库等这样级别的程序也就是那些通用性很强、功能强大的库元编程功能通常会给予你巨大的帮助。
我还可以从另一个角度来评价元编程功能。那就是善用计算机语言的元编程功能,某种意义上能让你修改这门语言,让它更满足你的个性化需求,为你量身打造!
是不是觉得元编程还挺有意思的?今天这一讲,我就带你来理解元编程的原理,并一起探讨如何用编译技术来支持元编程功能的实现。
首先,我们需要透彻地了解一下什么是元编程。
什么是元编程Meta-Programming
元编程是一种把程序当做数据来处理的技术。因此,采用元编程技术,你可以把一个程序变换成另一个程序。
图1元编程处理的对象是程序
那你可能要问了,既然把程序作为处理对象的技术就是元编程技术,那么编译器不就是把程序作为处理对象的吗?经过处理,编译器会把源代码转换成目标代码。类似的还有对源代码的静态分析工具、代码生成工具等,都算是采用了元编程技术。
不过,我们在计算机语言里说的元编程技术,通常是指用这门语言本身提供的功能,就能处理它自己的程序。
比如说在C语言中你可以用宏功能。经过C语言的预处理器处理以后那些宏就被转换成了另外的代码。下面的MUL宏用起来像一个函数但其实它只是做了一些字符串的替换工作。它可以说是最原始的元编程功能了。你在阅读像Python和Julia的编译器时就会发现有不少地方采用了宏的功能能让代码更简洁、可读性更好。
#define MUL(a,b) (a*b)
MUL(2,3) //预处理后变成(2*3)
再拿Java语言举个例子。Java语言对元编程提供了多重支持其中之一是注解功能。我们在解析Java编译器的时候已经发现Java编译器会把所编译的程序表示成一个对象模型。而注解程序可以通过这个对象模型访问被注解的程序并进行一些处理比如生成新的程序。所以这也是把程序作为数据来处理。
除了注解以外Java还提供了反射机制。通过反射机制Java程序可以在运行时获取某个类有哪些方法、哪些属性等信息并可以动态地运行该程序。你看这同样是把程序作为数据来处理。
像Python和JavaScript这样的脚本语言其元编程能力就更强了。比如说你用程序可以很容易地查询出某个对象都有哪些属性和方法甚至可以给它们添加新的属性和方法。换句话说你可以很容易地把程序作为数据进行各种变换从而轻松地实现一些灵活的功能。这种灵活性是很多程序员特别喜欢Python和JavaScript这样的语言的原因。
图2各种不同的元编程技术起作用的时机
好了,到现在为止,你已经了解了元编程的基本特征:把程序当做数据去处理。接下来,我再带你更深入地了解一下元编程,并把不同的元编程技术做做分类。
理解Meta的含义、层次以及作用
首先我们来注意一下Meta这个词缀的意思。维基百科中的解释是Meta来自希腊文意思是“在……之后after”和“超越……beyond”。加上这个词缀后Meta-Somthing所形成的新概念就会比原来的Somthing的概念的抽象度上升一层。
举例来说Physics是物理学的意思表示看得见摸得着的物理现象。而Metaphysics就代表超越了物理现象的学问也就是形而上学。Data是数据而Metadata是元数据是指对数据特性的描述比如它是什么数据类型、取值范围是什么等等。
还有一门语言我们叫做Language而语法规则Grammar是对一门语言的特点的描述所以语法规则可以看做是Metalanguage。
其次在理解了Meta的概念以后我再进一步告诉你Meta是可以分层次的。你可以对Meta再超越一层、抽象一层就是Meta-Meta。理解Meta的层次对于你深入理解元编程的概念非常重要。
拿你很熟悉的关系数据库来举个例子吧看看不同的Meta层次都是什么意思。
首先是M0层也就是关系数据库中的数据。比如一条人员数据编号是“001”姓名是“宫文学”等。一个数据库的使用者从数据库中查出了这条数据我们说这个人是工作在M0层的。
比M0抽象一层的是M1层也就是Metadata它描述了数据库中表的结构。比如它定义了一张人员表并且规定里面有编号、姓名等字段以及每个字段的数据类型等信息。这样看来元数据实际上是描述了一个数据模型所以它也被叫做Model。一个工程师设计了这个数据库表的结构我们说这个工程师是工作在M1层的。基于该工程师设计的数据库表你可以保存很多M0层的人员数据张三、李四、王五等等。
比M1再抽象一层的是M2层。因为M1层可以叫做Model所以M2层可以叫做Metamodel也就是元模型。在这个例子中Metamodel描述的是关系数据模型它是由一张张的表Table构成的而每张表都是由字段构成的每个字段都可以有数据类型、是否可空等信息。发明关系数据模型以及基于这个模型设计出关系数据库的大师是工作在M2层的。基于关系模型你可以设计出很多M1层的数据库表人员表、订单表、商品表等等。
那么有没有比Metamodel更抽象的层次呢有的。这就是M3层叫做Meta-Metamodel。这一层要解决的问题是如何去描述关系数据模型和其他的元模型在UML标准中有一个MOFMeta Object Facility的规范可以用来描述关系数据库、数据仓库等元模型。它用类、关联、数据类型和包这些基本要素来描述一个元模型。
通过关系数据库这个例子现在你应该理解了不同的Meta层次是什么概念。那我们再把这个概念应用到计算机语言领域也是一样的。
假设你使用一门面向对象的语言写了一个程序。这个程序运行时在内存里创建了一个Person对象。那这个对象属于M0层。
而为了创建这个Person对象你需要用程序设计一个Person类。从这个意义上来看我们平常写的程序属于M1层也就是相当于建立了一个模型来描述现实世界。你编写的订票程序就是对真实世界中的购票行为建立了一个模型而你编写的游戏当然也是建立了一个逼真的游戏模型。
那么你要如何才能设计一个Person类以及一个完整的程序呢这就需要用到计算机语言。计算机语言对应着M2层。它提供了类、成员变量、方法、数据类型、本地变量等元素用于设计你的程序。我们对一门计算机语言的词法规则、语法规则和语义规则等方面的描述就属于M2层也就是一门计算机语言的元模型。而编译器就是工作在M2层的程序它会根据元模型也就是词法规则、语法规则等来做程序的翻译工作。
我们在描述词法规则、语法规则的时候曾经用到产生式、EBNF这些工具。这些工具是属于M3层的。你可以用我们前面说过的一个词也就是Metalanguage来称呼这一层次。
这里我用了一个表格来给你展示下关系数据模型与Java程序中不同的Meta层次。
元编程技术的分类
理解了Meta层次的概念以后我们再来总结一下元编程技术都有哪些分类。
第一,元编程可以通过生成语义层对象来生成程序。
当我们操纵M1层的程序时我们通常需要透过M2层的对象来完成比如读取类和方法的定义信息。类和方法就是M2层的对象。Java的注解功能和反射机制就是通过读取和操纵M2层的对象来完成的。
在学习编译原理的过程中,你知道了类、方法这些都是语义层次的概念,编译器保证了编译后的程序在语义上的正确性,所以你可以大胆地使用这些信息,不容易出错。如果你要在运行时动态地调用方法,运行时也会提供一定的检查机制,减少出错的可能性。
第二元编程可以通过生成AST来生成程序。
你同样知道一个程序也可以用AST来表达。所以我们能不能让程序直接读取、修改和生成AST呢这样对AST的操纵就等价于对程序的操纵。
答案是可以的。所有Lisp家族的语言都采用了这种元数据技术Julia就是其中之一。Lisp语言可以用S表达式来表示程序。S表达式是那种括号嵌套括号的数据结构其实就是一棵AST。你可以用宏来生成S表达式也就是生成AST。
不过让程序直接操作比较底层的数据结构其代价是可能生成的AST不符合语义规则。毕竟AST只表达了语法规则。所以用这种方式做元编程需要小心一些不要生成错误的程序。同时这种元编程技术对程序员来说学习的成本也更高因为他们要在比较低的概念层次上工作。
第三,元编程可以通过文本字符串来生成程序。
当然你还可以把程序表达成更加低端的格式就是一些文本字符串而已。我们前面说过C语言的宏其实就是做字符串的替换。而一些脚本语言通常也能接受一个文本字符串作为程序来运行比如JavaScript的eval()函数就可以接受一个字符串作为参数然后把字符串作为程序来运行。所以在JavaScript里的一项非常灵活的功能就是用程序生成一些字符串然后用eval()函数来运行。当然你也能预料到用越原始的模型来表示程序出错的可能性就越大。所以有经验的程序员都会很谨慎地使用类似eval()这样的功能。但无论如何,这也确实是一种元编程技术。
第四,元编程可以通过字节码操纵技术来生成字节码。
那么除了通过生成语义层对象、AST和文本来生成程序以外对于Java这种能够运行字节码的语言来说你还可以通过字节码操纵技术来生成字节码。这种技术一般不是由语言本身提供的能力而是由第三方工具来实现的典型的就是Spring。
好,到这里,我们就探讨完了通过元编程技术由程序生成程序的各种方式。下面我们再通过另一个维度来讨论一下元编程技术。这个维度是元编程技术起作用的时机,我们可以据此分为静态元编程和动态元编程。
静态元编程技术只在编译期起作用。比如C++的模板技术和把Java注解技术用在编译期的情况在下面会具体介绍这两种技术。一旦编译完毕以后元程序跟普通程序一样都会变成机器码。
动态元编程技术会在运行期起作用。这方面的例子是Java的反射机制。你可以在运行期加载一个类来查看它的名称、都有哪些方法然后打印出来。而为了实现这种功能Java程序必须在class文件里保存这个类的Model比如符号表并通过M2层的接口来查询类的信息。Java程序能在运行期进行类型判断也是基于同样的原理。
好,通过上面的介绍,我想你对元编程的概念应该有比较清晰的理解了。那接下来,我们就来看看不同语言具体实现元编程的方式,并且一起探讨下在这个过程中应该如何运用编译技术。
不同语言的元编程技术
我们讨论的语言包括几大类首先是Java接着是Python和JavaScript这样的脚本语言然后是Julia这样的Lisp语言最后是C++的模板技术等一些很值得探讨的元编程技术。
Java的元编程技术
在分析Java的编译器的时候我们已经解析了它是如何处理注解的注解就是一种元编程技术。在我们举的例子中注解是在编译期就被处理掉了。
@Retention(RetentionPolicy.SOURCE) //注解用于编译期处理
@Target(ElementType.TYPE) //注解是针对类型的
public @interface HelloWorld {
}
当时我们写了一个简单的注解处理程序这个程序能够获取被注解的代码的元数据M1层的信息比如类名称、方法名称等。这些元数据是由编译器提供的。然后注解处理程序会基于这些元数据生成一个新的Java源代码紧接着该源代码就会被编译器发现并编译掉。
通过这个分析,你会发现注解处理过程自始至终都借助了编译器提供的能力:先是通过编译器查询被注解的程序的元数据,然后生成的新程序也会被编译器编译掉。所以你能得出一个结论:所谓元编程,某种意义上就是由程序来调用编译器提供的能力。
刚刚我们探究的是在编译期使用元编程技术。那么在运行期Java提供了反射机制来动态地获取程序的元数据并操纵程序的执行。
举个例子。假设你写了一个简单的ORMObject-Relational Mapping程序能够把Java对象自动保存到数据库中。那么你就可以通过反射机制来获取这个对象都有哪些属性然后读取这些属性的值并生成一个正确的SQL语句来完成对象的保存动作。比如对于一个Person对象ORM程序通过反射机制会得知它有name和country两个字段再从对象里读取name和字段的值就会生成类似”Insert into Person (name, age), values(“Richard”, “China”)“这样的SQL语句。
从这个例子中你能看出元编程的强大只需要写一个通用的程序就能用于各种不同的类。这些类在你写ORM程序的时候根本不需要提前知道因为ORM程序工作在M2层。给你任何一个类你都能获得它的方法和属性信息。
不过这种反射机制也是有短板的,就是性能比较低。基于反射机制编写的程序的效率,比同样功能的静态编译的程序要低好几倍。所以,如何提升运行期元编程功能的性能,是编译技术研究的一个重点。
OK接下来我们看看Python、JavaScript等脚本语言的元编程技术。
Python、JavaScript等脚本语言的元编程技术
对于像Python、JavaScript和Ruby这样的脚本语言它们实现起元编程技术来就更加简单。
最简单的元编程方式,我们前面也提到过,就是动态生成程序的文本字符串,然后动态编译并执行。这种方式虽然简单粗暴,容易出错,有安全隐患,但在某些特殊场景下还确实很有用。
不过如有可能,我们当然愿意使用更优雅的元编程方式。这几种脚本语言都有几个特点,使得操纵和修改已有程序的步骤会变得特别简单:
第一个特点,就是用程序可以很方便地获取对象的元数据,比如某个对象有什么属性、什么方法,等等。
第二个特点,就是可以很容易地为对象添加属性和方法,从而修改对象。
这些脚本语言做元编程究竟有多么容易呢我给你举个Python语言的例子。
我们在解析Python编译器的时候曾提到过metaclass元类。metaclass能够替代缺省的Type对象控制一个类创建对象的过程。通过你自己的metaclass你可以很容易地为所创建的对象的方法添加修饰比如输出调试信息这样的AOP功能。
所以很多喜欢Python、JavaScript和Ruby语言的工程师很大一部分原因都是因为这些语言非常容易实现元编程因此能够实现出很多强大的库。
不过在灵活的背后脚本语言的元编程技术通常要付出性能的代价。比如采用元编程技术程序经常会用Decorator模式对原有的函数或方法做修饰这样会增加函数调用的层次以及其他一些额外的开销从而降低程序的性能。
接下来我们说说Julia等类Lisp语言的元编程技术。
Julia等类Lisp语言的元编程技术
前面我们已经说过像Julia等类似Lisp的语言它本来就是把程序看做数据的。它的程序结构本来就是一个嵌套的树状结构其实跟AST没啥区别。因此只要在语言里提供一种方式能够生成这些树状结构的数据就可以很好地实现元编程功能了。
比如下面的一段示例程序是用Common Lisp编写的。你能看出程序的结构完全是一层层的括号嵌套的结构每个括号中的第一个单词都是一个函数名称后面跟着的是函数参数。这个例子采用了Lisp的宏功能把pred替换成合适的函数名称。当替换成>时,实现的是求最大值功能;而替换成<实现的是求最小值功能
(defmacro maxmin(list pred) ;定义一个宏
`(let ((rtn (first ,list))) ;`后面是作为数据的程序
(do ((i 1 (1+ i)))
((>= i (length ,list)) rtn)
(when (,pred (nth i ,list) rtn);pred可以被替换成一个具体的函数名
(setf rtn (nth i ,list))))))
(defun mymax2 (list) ;定义一个函数,取一个列表的最大值
(maxmin list >))
(defun mymin2 (list) ;定义一个函数,取一个列表的最小值。
(maxmin list <)
这种能够直接操纵AST的能力让Lisp特别灵活。比如在Lisp语言里根本没有原生的面向对象编程模型但你完全可以用它的元编程功能自己构造一套带有类、属性、方法、继承、多态的编程模型这就相当于构建了一个新的M2层的元模型。通常一个语言的元模型也就是编程时所能使用的结构比如是否支持类呀什么的在设计语言的时候就已经固定了。但Lisp的元编程功能竟然能让你自己去定义这些语言特性这就是一些小众的程序员特别热爱Lisp的原因。
C++的元编程技术
提到元编程就不能不提一下C++的模板元编程Template Metaprogramming技术它大大增强了C++的功能。
模板元编程技术属于静态元编程技术也就是让编译器尽量在编译期做一些计算。这在很多场景中都有用。一个场景就是提供泛型的支持。比如List 是整型这样的值类型的列表而List 是Student这种自定义类型的列表你不需要为不同的类型分别开发List这样的容器类在下一讲我还会对泛型做更多的讲解
但模板元编程技术不仅仅可以支持泛型,也就是模板的参数可以不仅仅是类型,还可以是普通的参数。模板引擎可以在编译期利用这些参数做一些计算工作。我们来看看下面这个例子。这个例子定义了一个数据结构,它可以根据你传入的模板参数获得阶乘值。
如果这个参数是一个编译期的常数,那么模板引擎会直接把这个阶乘值计算出来,而不是等到运行期才做这个计算。这样能降低程序在运行时的计算量,同时又保持编程的灵活性。
template<int n>
struct Fact {
enum { RET = n * Fact<n-1>::RET }; //用一个枚举值代表阶乘的计算结果
};
template<> //当参数为1时阶乘值是1
struct Fact<1> {
enum { RET = 1 };
};
int b = Fact<5>::RET; //在编译期就计算出阶乘值为120
看到这里利用你学过的编译原理你能不能猜测出C++模板的实现机制呢?
我们也看到过在编译器里做计算的情况比如说常数折叠会在编译期计算出表达式的常数值不用在运行期再去计算了。而在C++的模板引擎里把这种编译器的计算能力大大地丰富了。不过你仍然可以猜测出它的实现机制它仍然是基于AST来做计算生成新的AST。在这个过程中像Fact这种情况甚至会被计算出最终的值。C++模板引擎支持的计算如此复杂,以至于可以执行递归运算。
课程小结
今天这一讲,我们围绕元编程这个话题做了比较深入的剖析。
元编程,对于我们大多数程序员来说,是一个听上去比较高深的概念。但是,在学过编译原理以后,你会更容易理解元编程技术,因为编译器就是做元编程的软件。而各门语言中的元编程特性,本质上就是对编译器的能力的释放和增强。编译器要获得程序的结构信息,并对它们进行修改、转换,元编程做的是同样的事情。
我们学好编译原理以后在元编程方面其实拥有巨大的优势。一方面我们可以更加了解某门语言的元编程机制是如何工作的另一方面即使某些语言没有提供原生的元编程功能或者是元编程功能不够强大我们也仍然可以自己做一些工具来实现元编程功能这就是类似Spring这样的工具所做的事情。
本讲中关于Meta的层次的概念是我特别向你推荐的一个思维模型。采用这个模型你就知道不同的工作是发生在哪一个抽象层级上。因而你也就能明白为什么学习编译原理中用到的那些形式语言会觉得更加抽象。因为计算机语言的抽象层级就挺高的了而用于描述计算机语言的词法和语法规则的语言当然抽象层级更高。
我把这讲的思维导图也放在了这里,供你复习和参考。
一课一思
我在本讲举了ORM的例子。如果用你熟悉的语言来实现ORM功能也就是自动根据对象的类型信息来生成合适的SQL语句你会怎么做
欢迎分享你的观点,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。

View File

@ -0,0 +1,344 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 高级特性(二):揭秘泛型编程的实现机制
你好,我是宫文学。
对泛型的支持是现代语言中的一个重要特性。它能有效地降低程序员编程的工作量避免重复造轮子写很多雷同的代码。像C++、Java、Scala、Kotlin、Swift和Julia这些语言都支持泛型。至于Go语言它的开发团队也对泛型技术方案讨论了很久并可能会在2021年的版本中正式支持泛型。可见泛型真的是成为各种强类型语言的必备特性了。
那么,泛型有哪些特点?在设计和实现上有哪些不同的方案?编译器应该进行什么样的配合呢?今天这一讲,我就带你一起探讨泛型的实现原理,借此加深你对编译原理相关知识点的认知,让你能够在自己的编程中更好地使用泛型技术。
首先,我们来了解一下什么是泛型。
什么是泛型?
在日常编程中我们经常会遇到一些代码逻辑它们除了类型不同其他逻辑是完全一样的。你可以看一下这段示例代码里面有两个类其中一个类是保存Integer的列表另一个类是保存Student对象的列表。
public class IntegerList{
List data = new ArrayList();
public void add(Integer elem){
data.add(elem);
}
public Integer get(int index){
return (Integer) data.get(index);
}
}
public class StudentList{
List data = new ArrayList();
public void add(Student elem){
data.add(elem);
}
public Student get(int index){
return (Student) data.get(index);
}
}
我们都知道,程序员是很不喜欢重复的代码的。像上面这样的代码,如果要为每种类型都重新写一遍,简直会把人逼疯!
泛型的典型用途是针对集合类型能够更简单地保存各种类型的数据比如List、Map这些。在Java语言里如果用通用的集合类来保存特定类型的对象就要做很多强制转换工作。而且我们还要小心地做类型检查。比如
List strList = new ArrayList(); //字符串列表
strList.add("Richard");
String name = (String)strList.get(i); //类型转换
for (Object obj in strList){
String str = (String)obj; //类型转换
...
}
strList.add(Integer.valueOf(1)); //类型错误
而Java里的泛型功能就能完全消除这些麻烦工作让程序更整洁并且也可以减少出错机会。
List<String> strList = new ArrayList<String>(); //字符串列表
strList.add("Richard");
String name = strList.get(i); //类型转换
for (String str in strList){ //无需类型转换
...
}
strList.add(Integer.valueOf(1)); //编译器报错
像示例程序里用到的List<String>是在常规的类型后面加了一个参数使得这个列表变成了专门存储字符串的列表。如果你再查看一下List和ArrayList的源代码会发现它们比普通的接口和类的声明多了一个类型参数<E>,而这个参数可以用在接口和方法的内部所有需要类型的地方:变量的声明、方法的参数和返回值、类所实现的接口,等等。
public interface List<E> extends Collection<E>{
E get(int index);
boolean add(E e);
...
}
所以说,泛型就是把类型作为参数,出现在类/接口/结构体、方法/函数和变量的声明中。由于类型是作为参数出现的,因此泛型也被称作参数化类型。
参数化类型还可以用于更复杂的情况。比如你可以使用1个以上的类型参数像Map就可以使用两个类型参数一个是key的类型K一个是value的类型V
public interface Map<K,V> {
...
}
另外你还可以对类型参数添加约束条件。比如你可以要求类型参数必须是某个类型的子类这是指定了上界Upper Bound你还可以要求类型参数必须是某个类型的一个父类这是指定了下界Lower Bound。实际上从语言设计的角度来看你可以对参数施加很多可能的约束条件比如必须是几个类型之一等等。
基于泛型的程序由于传入的参数不同程序会实现不同的功能。这也被叫做一种多态现象叫做参数化多态Parametric Polymorphism。它跟面向对象中的多态一样都能让我们编写更加通用的程序。
好了,现在我们已经了解了泛型的含义了。那么,它们是如何在语言中实现的呢?需要用到什么编译技术?
泛型的实现
接下来我们一起来看一看几种有代表性的语言实现泛型的技术包括Java、C#、C++等。
类型擦除技术
在Java里泛型是通过类型擦除Type Erasure技术来实现的。前面在分析Java编译器时你就能发现其实类型参数只存在于编译过程中用于做类型检查和类型推断。在此之后这些类型信息就可以被擦除。ArrayList和ArrayList<String>对应的字节码是一样的,在运行时没有任何区别。
所以我们可以说在Java语言里泛型其实是一种语法糖有助于减少程序员的编程负担并能提供额外的类型检查功能。
除了Java以外其他基于JVM的语言比如Scala和Kotlin其泛型机制基本上都是类型擦除技术。
类型擦除技术的优点是实现起来特别简单。运用我们学过的属性计算、类型检查和推断等相关技术基本就够用了。
不过类型擦除技术也有一定的局限性。
问题之一是它只能适用于引用类型也就是对象而不适用于值类型也就是Java中的基础数据类型Primitive Type。比如你不能声明一个List<int>来保存单纯的整型数据你在列表里只能保存对象化的Integer。而我们学习过Java对象的内存模型知道一个Integer对象所占的内存是一个int型基础数据的好几倍因为对象头要有十几个字节的固定开销。再加上由此引起的对象创建和垃圾收集的性能开销导致用Java的集合对象来保存大量的整型、浮点型等基础数据是非常不划算的。我们在这种情况下还是要退回到使用数组才行。
问题之二就是因为类型信息在编译期被擦除了所以程序无法在运行时使用这些类型信息。比如在下面的示例代码中如果你想要根据传入的类型T创建一个新实例就会导致编译错误。
public static <T> void append(ArrayList<T> a) {
T b= new T(); // 编译错误
a.add(b);
}
同样由于在运行期没有类型信息所以如果要用反射机制来调用程序的时候我们也没有办法像在编译期那样进行类型检查。所以你完全可以往一个旨在保存String的列表里添加一个Interger对象。而缺少类型检查可能会导致程序在执行过程中出错。
另外还有一些由于类型擦除而引起的问题。比如在使用参数化类型的情况下方法的重载Overload会失败。再比如下面的示例代码中两个foo方法看似参数不同。但如果进行了类型擦除以后它们就没什么区别所以是不能共存的。
public void foo(List<Integer> p) { ... }
public void foo(List<Double> p) { ... }
你要注意不仅仅是Java语言的泛型技术有这样的缺点其他基于JVM实现的语言也有类似的缺点比如没有办法在运行时使用参数化类型的信息。这其实是由于JVM的限制导致的。为了理解这个问题我们可以看一下基于.NET平台的语言 比如C#所采用的泛型技术。C#使用的不是类型擦除技术而是一种叫做具体化reification的技术。
具体化技术Reification
说起来C#语言的设计者,安德斯 · 海尔斯伯格Anders Hejlsberg是一位令人尊敬的传奇人物。像我这一代的程序员差不多都使用过他在DOS操作系统上设计的Pascal编译器。后来他在此基础上设计出了Delphi也是深受很多人喜爱的一款开发工具。
出于对语言设计者的敬佩虽然我自己从没用C#写过程序但我从来没有低估过C#的技术。在泛型方面C#的技术方案成功地避免了Java泛型的那些缺点
C#语言编译也会形成IR,然后在.NET平台上运行。在C#语言中对应于Java字节码的IR被叫做IL是中间语言Intermediate Language的缩写。
我们知道了在Java的泛型实现中编译完毕以后类型信息就会被擦除。而在C#生成的IL中则保留了类型参数的类型信息。所以List<Student>和List<Teacher>是两个完全不同的类型。也因为IL保存了类型信息因此我们可以在运行时使用这些类型信息比如根据类型参数创建对象而且如果通过反射机制来运行C#程序的话,也会进行类型检查。
还有很重要的一点就是C#的泛型能够支持值类型比如基础的整型、浮点型数据再比如针对List<int>和List<long>C#的泛型能够真的生成一份完全不同的可运行的代码。它也不需要把值类型转换成对象,从而导致额外的内存开销和性能开销。
把参数化类型变成实际的类型的过程是在运行时通过JIT技术实现的。这就是具体化Reification的含义。把一个参数化的类型变成一个运行时真实存在的类型它可以跟非参数化的类型起到完全相同的作用。
不过,为了支持泛型,其实.NET扩展了C#生成的IL以便在IL里能够记录参数化类型信息。而JVM则没有改变它的字节码从而完全是靠编译器来处理泛型。
好了现在我们已经见识到了两种不同的泛型实现机制。还有一种泛型实现机制也是经常被拿来比较的这就是C++的泛型机制,它的泛型机制依托的是模板元编程技术。
基于元编程技术来支持泛型
在上一讲我们介绍过C++的模板元编程技术。模板元编程很强大,程序里的很多要素都可以模板化,那么类型其实也可以被模板化。
你已经知道元编程技术是把程序本身作为处理对象的。采用C++的模板元编程技术,我们实际上是为每一种类型参数都生成了新的程序,编译后生成的目标代码也是不同的。
所以C++的模板技术也能做到Java的类型擦除技术所做不到的事情比如提供对基础数据类型的支持。在C++的标准模板库STL提供了很多容器类型。它们能像保存对象一样保存像整型、浮点型这样的基础数据类型。
不过使用模板技术来实现泛型也有一些缺点。因为本质上,模板技术有点像宏,它是把程序中某些部分进行替换,来生成新的程序。在这个过程中,它并不会检查针对参数类型执行的某些操作是否是合法的。编译器只会针对生成后的程序做检查,并报错。这个时候,错误信息往往是比较模糊的,不太容易定位。这也是模板元编程技术固有的短板。
究其原因是模板技术不是单单为了泛型的目的而实现的。不过如果了解了泛型机制的原理你会发现其实可以通过增强C++编译器来提升它的类型检查能力。甚至对类型参数指定上界和下界等约束条件也是可以的。不过这要看C++标准委员会的决定了。
总的来说C++的泛型技术像Java的一样都是在运行期之前就完成了所有的工作而不像.NET那样在运行期针对某个参数化的类型产生具体的本地代码。
好了,了解了泛型的几种实现策略以后,接下来,我们接着讨论一个更深入的话题:把类型参数化以后,对于计算机语言的类型系统有什么挑战?这个问题很重要,因为在语义分析阶段,我们已经知道如何做普通类型的分析和处理。而要处理参数化的类型,我们还必须更加清楚支持参数化以后,类型体系会有什么变化。
泛型对类型系统的增强
在现代语言中通常会建立一个层次化的类型系统其中一些类型是另一些类型的子类型。什么是子类型呢就是在任何一个用到父类型的地方都可以用其子类型进行替换。比如Cat是Animal的子类型在任何用到Animal的地方都可以用Cat来代替。
不过,当类型可以带有参数之后,类型之间的关系就变得复杂了。比如说:
Collection<Cat>和List<Cat>是什么关系呢?
List<Animal>和List<Cat>之间又是什么关系呢?
对于第一种情况其实它们的类型参数是一样的都是Cat。而List本来是Collection的子类型那么List<Cat>也是Collection<Cat>的子类型我们永远可以用List<Cat>来替换Collection<Cat>。这种情况比较简单。
但是对于第二种情况List<Cat>是否是List<Animal>的子类型呢这个问题就比较难了。不同语言的实现是不一样的。在Java、Julia等语言中List<Cat>和List<Animal>之间没有任何的关系。
在由多个类型复合而形成的类型中比如泛型复合类型之间的关系随其中的成员类型的关系而变化的方式分为不变Invariance、协变Covariance和逆变Contravariance三种情况。理解清楚这三种变化对于我们理解引入泛型后的类型体系非常重要这也是编译器进行正确的类型计算的基础。
首先说说不变。在Java语言中List<Animal>和List<Cat>之间并没有什么关系在下面的示例代码中如果我们把List<Cat>赋值给List<Animal>编译器会报错。因此我们说List<T>基于T是不变的。
List<Cat> catList = new ArrayList<>();
List<Animal> animalList = catList; //报错,不是子类型
那么协变是什么呢就是复合类型的变化方向跟成员类型是相同的。我给你举两个在Java语言中关于协变的例子。
第一个例子。假设Animal有个reproduce()方法也就是繁殖。而Cat覆盖Override了这个方法但这个方法的返回值是Cat而不是Animal。因为猫肯定繁殖出的是小猫而不是其他动物。这样当我们调用Cat.reproduce()方法的时候就不用对其返回值做强制转换。这个时候我们说reproduce()方法的返回值与它所在类的类型,是协变的,也就是一起变化。
class Animal{
public abstract Animal reproduce();
}
class Cat extends Animal{
@Override
public Cat reproduce() { //方法的返回值可以是Animal的子类型
...
}
}
第二个例子。在Java语言中数组是协变的。也就是Cat[]其实是Animal[]的子类型,在下面的示例代码中,一个猫的数组可以赋值给一个动物数组。
Cat[] cats = {new Cat(), new Cat()}; //创建Cat数组
Animal[] animals = cats; //赋值给Animal数组
animals[0] = new Dog(); //修改第一个元素的值
Cat aCat = cats[0]; //运行时错误
但你接下来会看到Animal数组中的值可以被修改为Dog这会导致Cat数组中的元素类型错误。至于为什么Java语言要把数组设计为协变的以及由此导致的一些问题我们暂且不管它。我们要问的是List<T>这样的泛型可以变成协变关系吗?
答案是可以的。我前面也提过我们可以在类型参数中指定上界。List<Cat>是List<? Extends Animal>的子类型List<? Extends Animal>的意思是任何以Animal为祖先的子类。我们可以把一个List<Cat>赋值给List<? Extends Animal>。你可以看一下示例代码:
List<Cat> catList = new ArrayList<>();
List<? extends Animal> animalList = catList; //子类型
catList.add(new Cat());
Animal animal = animalList.get(0);
实际上不仅仅List<Cat>是List<? extends Animal>的子类型连List<Animal>也是List<? extends Animal>的子类型。你可以自己测试一下。
我们再来说说逆变。逆变的意思是虽然Cat是Animal的子类型但包含了Cat的复合类型竟然是包含了Animal的复合类型的父类型它们颠倒过来了
这有点违反直觉。在真实世界里有这样的例子吗?当然有。
比如假设有两个函数getWeight<Cat>()函数是返回Cat的重量getWeight<Animal>()函数是返回Animal的重量。你知道从函数式编程的观点每个函数也都是有类型的。那么这两个函数谁是谁的子类型呢
实际上求Animal重量的函数其实是求Cat重量的函数的子类型。怎么说呢
来假设一下。如果你想用一个getTotalWeight()函数求一群Cat的总重量你会怎么办呢你可以把求Cat重量的函数作为参数传进去这肯定没问题。但是你也可以把求Animal重量的函数传进去。因为既然它能返回普通动物的重量那么也一定能返回猫的重量。
//伪代码求Cat的总重量
getTotalWeight(List<Cat> cats, function fun)
而根据类型理论如果类型B能顶替类型A的位置那么B就是A的子类型。
所以getWeigh<Animal>()反倒是getWeight<Cat>()的子类型,这种情况就叫做逆变。
总的来说,加入泛型以后,计算机语言的类型体系变得更加复杂了。我们在编写编译器的时候,一定要弄清楚这些变化关系,这样才能执行正确的类型计算。
那么在了解了加入泛型以后对类型体系的影响后我们接着借助Julia语言来进一步验证一下如何进行正确的类型计算。
Julia中的泛型和类型计算
Julia设计了一个精巧的类型体系。这个类型体系有着共同的根也就是Any。在这个类型层次中橙色的类型是叶子节点它们是具体的类型也就是可以创建具体的实例。而中间层次的节点蓝色都是抽象的主要是用于类型的计算。
你在第22讲中已经了解到了Julia做函数编译的特点。在编写函数的时候你可以根本不用指定参数的类型编译器会根据传入的参数的实际类型来编译成相应版本的机器码。另外你也可以为函数编写多个版本的方法每个版本的参数采用不同的类型。编译器会根据实际参数的类型动态分派到不同的版本。而这个动态分派机制就需要用到类型的计算。
比如说有一个函数foo()定义了三个版本的方法其参数分别是没有指定类型也就是Any、Real类型和Float64类型。如果参数是Float64类型那它当然会被分派到第三个方法。如果是Float32类型那么就会被分派到第二个方法。如果是一个字符串类型呢则会被分派到第一个方法。
julia> function foo(x) #方法1
...
end
julia> function foo(x::Real) #方法2
...
end
julia> function foo(x::Float64) #方法3
...
end
再进一步Julia还支持在定义结构体和函数的时候使用泛型。比如下面的一个Point结构中坐标x和y的类型是参数化的。
julia> struct Point{T}
x::T
y::T
end
julia> Point{Float64}
Point{Float64}
julia> Point{Float64} <: Point #在Julia里如果一个类型更具体<:为真
true
julia> Point{Float64} <: Point{Real} #Invariant
false
julia> p1 = Point(1.0,2.3) #创建一个Point实例
Point{Float64}(1.0, 2.3) #自动推断出类型
如果我们再为foo()函数添加几个方法其参数类型分别是Point类型、Point{Real}类型和Point{Float64}类型,那动态分派的算法也必须能够做正确的分派。所以,在这里,我们就必须能够识别出带有参数的类型之间的关系。
julia> function foo(x::Point) #方法4
...
end
julia> function foo(x::Point{Real}) #方法5
...
end
julia> function foo(x::Point{Float64}) #方法6
...
end
通过以上的示例代码你可以看到Point{Float64} <: Point也就是Point{Float64}是Point的子类型这个关系是有意义的
Julia的逻辑是Point{Float64} 比Point更具体能够在程序里替代Point。而Point{Float64} 和Point{Real}之间是没有什么关系的虽然Float64是Real的子类型。这说明Point{T}基于T是不变的Invariant这跟Java语言的泛型处理是一样的。
所以在Julia编译的时候如果我们给foo()传递一个Point{Float64}参数那么应该被分派到方法6。而如果传递一个Point{Float32}参数呢分派算法不会选择方法5因为Point{Float32}不是Point{Real}的子类型。因此分配算法会选择方法4因为Point{Float32}是Point的子类型。
那么如何让Point{T}基于T协变呢这样我们就可以针对Real类型写一些通用的算法让采用Float32、Float16等类型的Point都按照这个算法去编译了。
答案就是需要指定上界。我们可以把Point{Real}改为Point{<:Real}它是Point{Float32}Point{Float16}等的父类型
总结起来Julia的泛型和类型计算是很有特点的。泛型提供的参数化多态Parametric Polymorphism跟Julia原来的方法多态Method Polymorphism很好地融合在了一起让我们能够最大程度地去编写通用的程序。而被泛型增强后的类型体系也对动态分派算法提出了更高的要求。
课程小结
这一讲,我们学习了泛型这个现代语言中非常重要的特性的实现机制。在实现泛型机制的时候,我们首先必须弄清楚引入泛型以后,对类型体系的影响。你要掌握不变、协变和逆变这三个基本概念和它们的应用场景,从而能够正确地用于类型计算的过程中。
在泛型的具体实现机制上,有类型擦除、具体化和模板元编程等不同的方法。好的实现机制应该有能力同时兼顾值类型和复合类型,同时又便于调试。
按照惯例,我也把本讲的内容总结成了思维导图,供你参考:
一课一思
今天,我想给你留两道思考题,你可以根据你熟悉的语言,选择其一。
如果你对Java语言比较熟悉那么针对Java的泛型不支持基础数据类型的问题你能否想出一种技术方案来弥补这个短板呢你思考一下。我在下一讲会借助面向对象的话题给出一个技术方案。
而如果你对Go语言有所了解那么你对Go语言的泛型技术方案会提出什么建议能否避免已有语言在实现泛型上的短板呢你也可以参考我在文末给出的Go语言泛型方案的草案来印证你的想法。
欢迎在留言区分享你的观点,也非常欢迎你把今天的内容分享给更多的朋友。
参考资料
Go语言泛型方案的草案。
Julia的泛型。
C#泛型的文档你可以看看它在运行期是如何支持泛型的这跟Java有很大的不同。