mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-21 09:23:44 +08:00
mod
This commit is contained in:
238
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/33 | 垃圾收集:能否不停下整个世界?.md
Normal file
238
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/33 | 垃圾收集:能否不停下整个世界?.md
Normal file
@@ -0,0 +1,238 @@
|
||||
<audio id="audio" title="33 | 垃圾收集:能否不停下整个世界?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/e7/972476dd2674a6355f9eccd801f7b5e7.mp3"></audio>
|
||||
|
||||
对于内存的管理,我们已经了解了栈和栈桢,在编译器和操作系统的配合下,栈里的内存可以实现自动管理。
|
||||
|
||||
不过,如果你熟悉C和C++,那么肯定熟悉在堆中申请内存,也知道要小心维护所申请的内存,否则很容易引起内存泄漏或奇怪的Bug。
|
||||
|
||||
其实,现代计算机语言大多数都带有自动内存管理功能,**也就是垃圾收集(GC)。**程序可以使用堆中的内存,但我们没必要手工去释放。垃圾收集器可以知道哪些内存是垃圾,然后归还给操作系统。
|
||||
|
||||
那么这里会有几个问题,也是本节课关注的重点:
|
||||
|
||||
- 自动内存管理有哪些不同的策略?这些策略各自有什么优缺点?
|
||||
- 为什么垃圾收集会造成系统停顿?工程师们又为什么特别在意这一点?
|
||||
|
||||
相信学完这节课之后,你对垃圾收集的机制理解得会更加深刻,从而在使用Java、Go等带有垃圾收集功能的语言时,可以更好地提升回收效率,减少停顿,提高程序的运行效率。
|
||||
|
||||
当然,想要达到这个目的,你首先需要了解什么是内存垃圾,如何发现哪些内存是没用的?
|
||||
|
||||
## 什么是内存垃圾
|
||||
|
||||
内存垃圾是一些保存在堆里的对象,但从程序里已经无法访问。
|
||||
|
||||
在堆中申请一块内存时(比如Java中的对象实例),我们会用一个变量指向这块内存。这个变量可能是:全局变量、常量、栈里的变量、寄存器里的变量。**我们把这些变量叫做GC根节点。**它指向的对象中,可能还包含指向其他对象的指针。
|
||||
|
||||
但是,如果给变量赋予一个新的地址,或者当栈桢弹出,该栈桢的变量全部失效,这时,变量所指向的内存就无用了(如图中的灰色块)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/96/9494805978d8f443d0a2bcd26c9c0296.jpg" alt="">
|
||||
|
||||
另外,如果A对象有一个成员变量指向C对象,那么如果A不可达,C也会不可达,也就失效了。但D对象除了被A引用,还被B引用,仍然是可达的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/64/4c105fc2086aa28833b964e0fec8ab64.jpg" alt="">
|
||||
|
||||
所以,所有可达的内存就不是垃圾,而计算可达性,重点在于知道哪些是根节点。在一个活动记录(栈桢)里,有些位置放的是指向堆中内存的指针,有的位置不是,比如,可能存放的是返回地址,或者是一个整数值。如果我们能够知道活动记录的布局,就可以找出所有的指针,然后就能计算寻找垃圾内存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/2b/b627043f76490ff9e5beaa1241b0e52b.jpg" alt="">
|
||||
|
||||
现在,你应该知道了内存垃圾的特点了,接下来,只要用算法找出哪些内存是不可达的,就能进行垃圾收集了。
|
||||
|
||||
## 标记和清除(Mark and Sweep)
|
||||
|
||||
标记和清除算法是最为经典的垃圾收集算法,它分为**标记阶段和清除阶段。**
|
||||
|
||||
**在标记阶段中,**GC跟踪所有可达的对象并做标记。每个对象上有一个标记位,一开始置为0,如果发现这个对象是可达的,就置为1。这个过程其实就是图的遍历算法,我们把这个算法细化一下,写成伪代码如下:
|
||||
|
||||
```
|
||||
把所有的根节点加入todo列表
|
||||
只要todo列表不为空,就循环处理:
|
||||
从todo列表里移走一个变量v
|
||||
如果v的标记为0,那么
|
||||
把v的标记置为1
|
||||
假设v1...vn是v中包含的指针
|
||||
那么把v1...vn加入todo列表(去除重复成员)
|
||||
|
||||
```
|
||||
|
||||
下面的示例图中,x和y是GC根节点,标记完毕以后,A、C和D是可达的,B、E和F是可收集的(我用不同的颜色做了标注)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/80/8a710595f6488fa83b19926df166a880.jpg" alt="">
|
||||
|
||||
**在清除阶段中,**GC遍历所有从堆里申请的对象,把标记为0的对象收回,把标记为1的内存重新置为0,等待下次垃圾收集再做标记。
|
||||
|
||||
这个算法虽然看上去简单清晰,**但存在一个潜在的问题。**
|
||||
|
||||
在标记阶段,也就是遍历图的时候,必须要有一个列表作为辅助的数据结构,来保存所有待检查的对象。但这个列表要多大,只有运行时才清楚,所以没有办法提前预留出一块内存,用于清除算法。而一旦开始垃圾收集,那说明系统的内存已经比较紧张了,所以剩下的内存是否够这个辅助的数据结构用,是不确定的。
|
||||
|
||||
可能你会说:那我可以改成递归算法,递归地查找下级节点并做标记。这是不行的,因为每次递归调用都会增加一个栈桢,来保存递归调用的参数等信息,内存消耗有可能更大。
|
||||
|
||||
不过,方法总比问题多,针对算法的内存占用问题,你可以用**指针逆转(pointer reversal)**来解决。**这个技术的思想是:**把算法所需要的辅助数据,记录在内存对象自身的存储空间。**具体做法是:**顺着指针方向从A到达B时,我们把从A到B的指针逆转过来,改成从B到A。把B以及B的子节点标记完以后,再顺着这个指针找到回去的路,回到A,然后再把指针逆转回来。
|
||||
|
||||
整个标记过程的直观示意图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/bf/31c23e4c04c7eeb5bd11b9e4b0202cbf.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/fd/d7/fd553de3de1dcee92fba8672f289b3d7.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/9a/e8/9acdf18b874f1fb3dab262c47bf3b1e8.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/fe/bf/fe824ae8346ac347b7a1fb77218b8cbf.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/9f/8c/9fd1b7668d1368d94676a26fdf5de28c.jpg" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/0f/cc2b26d6bc1d4aaa601d21fc2e5cad0f.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/46/9c8e0bbc06eef2f1cbb53ef6987b0546.jpg" alt="">
|
||||
|
||||
**关于这个技术,你需要注意其中一个技术细节:**内存对象中,可能没有空间来存一个指针信息。比如下图中,B对象原来就有一个变量,用来保存指向C的指针。现在用这个变量的位置保存逆转指针,来指向A就行了。但到C的时候,发现C没有空间来存逆转到B的指针。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/55/0983e445ee4c732dde5fcfb5f6e3bf55.jpg" alt="">
|
||||
|
||||
**这时,借助寄存器就可以了。**在设置从B到A的指针之前,要把B和C的地址,临时保存在寄存器里,避免地址的丢失。进入C以后,如果C没有存指针的空间,就证明C是个叶子节点,这时,用寄存器里保存的地址返回给B就行了。
|
||||
|
||||
**采用标记和清除算法,**你会记住所有收集了的内存(通常是存在一个双向列表里),在下次申请内存的时候,可以从中寻找大小合适的内存块。**不过,这会导致一个问题:**随着我们多次申请和释放内存,内存会变得碎片化。所以,在申请内存的时候,要寻找合适的内存块,算法会有点儿复杂。而且就算你努力去寻找,当申请稍微大一点儿的内存时,也会失败。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/1d/c9d3a8cd707d47494986caf1cbd6ae1d.jpg" alt="">
|
||||
|
||||
为了避免内存碎片,你可以采用变化后的算法,**标记-整理算法:**在做完标记以后,做一下内存的整理,让存活的对象都移动到一边,消除掉内存碎片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/99/5b8a00382aa078eb3861fb47111c8299.jpg" alt="">
|
||||
|
||||
除此之外,停止和拷贝算法,也能够避免内存碎片化。
|
||||
|
||||
## 停止和拷贝(Stop and Copy)
|
||||
|
||||
采用这个算法后,内存被分成两块:
|
||||
|
||||
- 一块是旧空间,用于分配内存。
|
||||
- 一块是新空间,用于垃圾收集。
|
||||
|
||||
停止和拷贝算法也可以叫做**复制式收集(Coping Collection)。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/bd/5040c8ae50faeb063e11092fa8b425bd.jpg" alt="">
|
||||
|
||||
你需要保持一个堆指针,指向自由空间开始的位置。申请内存时,把堆指针往右移动就行了,比标记-清除算法申请内存更简单。
|
||||
|
||||
**这里需要注意,**旧空间里有一些对象可能已经不可达了(图中的灰色块),但你不用管。当旧空间变满时,就把所有可达的对象,拷贝到新空间,并且把新旧空间互换。这时,新空间里所有对象整齐排列,没有内存碎片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/2b/75f21f379ecdc8e486b888932050422b.jpg" alt="">
|
||||
|
||||
**停止-拷贝算法被认为是最快的垃圾收集算法,有两点原因:**
|
||||
|
||||
- 分配内存比较简单,只需要移动堆指针就可以了。
|
||||
- 垃圾收集的代价也比较低,因为它只拷贝可达的对象。当垃圾对象所占比例较高的时候,这种算法的优势就更大。
|
||||
|
||||
**不过,停止-拷贝算法还有缺陷:**
|
||||
|
||||
- 有些语言不允许修改指针地址。
|
||||
|
||||
在拷贝内存之后,你需要修改所有指向这块内存的指针。像C、C++这样的语言,因为内存地址是对编程者可见的,所以没法采用停止和拷贝算法。
|
||||
|
||||
- 始终有一半内存是闲置的,所以内存利用率不高。
|
||||
- 最后,它一次垃圾收集的工作量比较大,会导致系统停顿时间比较长,对于一些关键系统来说,这种较长时间的停顿是不可接受的。但这两个算法都是基础的算法,它们可以被组合进更复杂的算法中,比如分代和增量的算法中,就能避免这个问题。
|
||||
|
||||
## 引用计数(Reference Counting)
|
||||
|
||||
引用计数支持增量的垃圾收集,可以避免较长时间的停顿。
|
||||
|
||||
**它的原理是:**在每个对象中,保存引用本对象的指针数量,每次做赋值操作时,都要修改这个引用计数。如果x和y分别指向A和B,当执行“x=y”这样的赋值语句时,要把A的引用计数减少,把B的引用计数增加。如果某个对象的引用计数变成了0,那就可以把它收集掉。
|
||||
|
||||
所以,引用计数算法非常容易实现,只需要在赋值时修改引用计数就可以了。
|
||||
|
||||
**不过,引用计数方法也有缺陷:**
|
||||
|
||||
**首先,是不能收集循环引用的结构。**比如图中的A、B、C和D的引用计数都是1,但它们只是互相引用,没有其他变量指向它们。而循环引用在面向对象编程里很常见,比如一棵树的结构中,父节点保存了子节点的引用,子节点也保存了父节点的引用,这会让整棵树都没有办法被收集。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/94/9908e44af49bca04b1dbef3667f15f94.jpg" alt="">
|
||||
|
||||
如果你有C++工作经验,应该思考过,怎么自动管理内存。**有一个思路是:**实现智能指针,对指针的引用做计数。这种思路也有循环引用的问题,所以要用其他算法辅助,来解决这个问题。
|
||||
|
||||
**其次,在每次赋值时,都要修改引用计数,开销大。**何况修改引用计数涉及写内存的操作,而写内存是比较慢的,会导致性能的降低。
|
||||
|
||||
其实,这三个算法都是比较单一的算法,实际上,它们可以作为更复杂、更实用算法的组成部分,**比如分代收集算法。**
|
||||
|
||||
## 分代收集(Generational Collection)
|
||||
|
||||
分代收集算法在商业级的产品里很普及,比如Java和Go语言的GC。
|
||||
|
||||
**它的核心思想是:**在程序中,往往新创建的对象会很快死去,比如,你在一个方法中,使用临时变量指向一些新创建的对象,这些对象大多数在退出方法时,就没用了。**根据这个原理,**垃圾收集器将注意力集中在比较“年轻”的数据上,因为它们成为垃圾的概率比较高。
|
||||
|
||||
我们把堆划分成若干“代”(Generation):G0是最新代,G1就要老一些。不过GC根节点的计算有一个小小的区别:在收集G0时,根节点除了全局变量、栈和寄存器中的变量外,还要包含老一代的对象中指向G0的指针(下图中橙色的线,都是指向G0中对象的)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/30/8a10c9a1fa22a21c3e1160595f92e330.png" alt="">
|
||||
|
||||
**所以,一个重要的问题是:**记住G1、G2…中的根节点。但如果每次都去搜一遍,相当于遍历所有世代,效率很低。所以,要采用效率高一点儿的算法,比如记忆表法。
|
||||
|
||||
**这个算法是指:**如果A对象的x属性被设置成了B对象,那么就要把A对象加入一个向量里(记忆表),记住这个对象曾经被更新过。在垃圾收集时,要扫描这张表,寻找指向G0的老对象。
|
||||
|
||||
因为这个算法要记的对象太多,记忆表会变得很大,不太划算。不过我们可以把内存划为2的k次方大小的一个个卡片,如果卡片上的对象被赋值,那么就把这张卡片标记一下,这叫做卡片标记法。
|
||||
|
||||
如果你熟悉操作系统,会马上发现,这种卡片和操作系统内存管理时的分页比较相似。所以你可以由操作系统帮忙记录哪页被写入数据了,这种方法叫做页标记法。
|
||||
|
||||
**解决了根节点的问题之后,我们就可以对G0进行收集了。**在G0被收集了多次以后,对G1、G2也可以进行收集。这里你需要注意,G0比较适合复制式收集算法,因为大部分对象会被收集掉,剩下来的不多;而老年代的对象生存周期比较长,拷贝的话代价太大,比较适合标记-清除算法,或者标记-整理算法。
|
||||
|
||||
Java的GC就采用了分代收集,现在,你再去看介绍Java垃圾收集的资料,会容易多了。
|
||||
|
||||
在带你了解了一些常见的垃圾收集算法之后,我想和你讨论一下:能否不停下整个世界?这个标题里的痛点问题。
|
||||
|
||||
## 增量收集和并发收集(Incremental Collection, Concurrent Collection)
|
||||
|
||||
垃圾收集算法在运行时,通常会把程序停下。因为在垃圾收集的过程中,如果程序继续运行,程序可能会出错。这种停下整个程序的现象,被形象地称作“停下整个世界(STW)”。
|
||||
|
||||
可是让程序停下来,会导致系统卡顿,用户的体验感会很不好。一些对实时性要求比较高的系统,根本不可能忍受这种停顿。
|
||||
|
||||
所以,在自动内存管理领域的一个研究的重点,就是如何缩短这种停顿时间。以Go语言为例,它的停顿时间从早期的几十毫秒,已经降低到了几毫秒。甚至有一些激进的算法,力图实现不用停顿。增量收集和并发收集算法,就是在这方面的有益探索。
|
||||
|
||||
增量收集可以每次只完成部分收集工作,没必要一次把活干完,从而减少停顿。
|
||||
|
||||
并发收集就是在不影响程序执行的情况下,并发地执行垃圾收集工作。<br>
|
||||
为了讨论增量和并发收集算法,**我们定义两个角色:**一个是收集器(Collector),负责垃圾收集;一个是变异器(Mutator),其实就是程序本身,它会造成可达对象的改变。
|
||||
|
||||
然后,用三色标记(tricolor marking)的方法,来表示算法中,不同的内存对象的处理阶段:
|
||||
|
||||
- 白色表示,算法还没有访问的对象。
|
||||
- 灰色表示,这个节点已经被访问过,但子节点还没有被访问过。
|
||||
- 黑色节点表示,这个节点已经访问过,子节点也已经被访问过了。
|
||||
|
||||
用三色标记法来分析的话,**你会发现前面的算法有两个特点:**
|
||||
|
||||
1.不会有黑色对象指向白色对象,因为黑色对象都已经被扫描完毕了。<br>
|
||||
2.每一个灰色对象都处于收集器的待处理工作区中,比如在标记-清除算法的todo列表中。
|
||||
|
||||
再进一步分析后,我们发现,只要保证这两个特点一直成立,那么收集器和变异器就可以一起工作,互不干扰,从而实现增量收集或并发收集。因为算法可以不断扫描灰色对象,加入到黑色区域。这样整个算法就可以增量式地运行下去。
|
||||
|
||||
**现在我们的重点,就变成了保证上面两个特点一直成立。**比如,如果变异器要在一个黑色对象a里存储一个指针b,把a涂成灰色,或者把b涂成灰色,都会保持上面两条的成立。或者当变异器要读取一个白色指针a的时候,就把它涂成灰色,这样的话也不会违背上面两条。
|
||||
|
||||
不同的算法会采取不同的策略,但无论采取哪种算法,收集器和变异器都是通过下面三种机制来协作:
|
||||
|
||||
<li>
|
||||
读屏障(read barrier 或 load barrier)。在load指令(从内存到寄存器)之后立即执行的一小段代码,用于维护垃圾收集所需的数据。包括把内存对象涂成正确的颜色,并保证所有灰色对象都在算法的工作区里。
|
||||
</li>
|
||||
<li>
|
||||
写屏障(write barrier 或 store barrier)。在store指令(从寄存器到内存)之前执行的一小段代码,也要为垃圾收集做点儿工作。
|
||||
</li>
|
||||
<li>
|
||||
安全点(safepoint)。安全点是代码中的一些点,在这些点上,指针的值是可以安全地修改的。有时,你修改指针的值是有问题的,比如正在做一个大的数组的拷贝,拷到一半,你把数组的地址改了,这就有问题。所以安全点一般都在方法调用、循环跳转、异常跳转等地方。
|
||||
</li>
|
||||
|
||||
**概要地总结一下:**要想实现增量或并发的垃圾收集,就要保证与垃圾收集有关数据的正确性,所以,需要读屏障、写屏障两个机制。另外,还要保证垃圾收集不会导致程序出错,所以需要安全点机制。
|
||||
|
||||
要实现这三个机制,需要编译器的帮助。
|
||||
|
||||
## LLVM对垃圾收集的支持
|
||||
|
||||
总的来说,垃圾收集器是一门语言,运行期的一部分,不是编译器的职责。所以,LLVM并没有为我们提供垃圾收集器。但是,要想让垃圾收集器发挥功能,必须要编译器配合,LLVM能够支持:
|
||||
|
||||
- 在代码中创建安全点,只有在这些点上才可以执行GC。
|
||||
- 计算栈图(Stack Map)。在安全点上,栈桢中的指针会被识别出来,作为GC根节点被GC所使用。
|
||||
- 提供写屏障和读屏障的支持,用于支持增量和并发收集。
|
||||
|
||||
LLVM能为当前所有常见的GC算法提供支持,包括我们本讲提到的所有算法,**你写GC的时候,一定要跟LLVM配合,才能让GC顺利发挥作用。**
|
||||
|
||||
## 课程小结
|
||||
|
||||
垃圾收集是高级语言的重要特征,我们针对垃圾收集,探讨了它的原理和常见的算法,我希望你记住以下几点:
|
||||
|
||||
- 内存垃圾是从根节点不能到达的对象。
|
||||
- 标记-清除算法中,你要记住不占额外的内存来做标记的技巧,也就是指针逆转。
|
||||
- 停止-拷贝算法比较适合活对象比例比较低的情况,因为只需要拷贝少量对象。
|
||||
- 引用计数的方法比较简单,但不能处理循环引用的情况,所以可以考虑跟其他算法配合。
|
||||
- 分代收集算法非常有效,关键在于计算老一代中的根节点。
|
||||
- 增量收集和并发收集是当前的前沿,因为它能解决垃圾收集中最大的痛点,时延问题
|
||||
- LLVM给垃圾收集提供安全点、栈图、读写屏障方面的支持,GC要跟编译器配合才能很好的工作。
|
||||
|
||||
总之,垃圾收集是一项很前沿的技术,如果你有兴趣在这方面做些工作,有一些开源的GC可以参考。不过,就算不从事GC的编写,仅仅了解原理,也会有助于你更好地使用自己的语言,比如把Java和Go语言做好调优。
|
||||
|
||||
## 一课一思
|
||||
|
||||
垃圾收集机制曾经给你造成了什么困惑吗?你是怎么解决的?学完本讲后,能否从原理的角度分析一下?欢迎在留言区分享你的观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
180
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/34 | 运行时优化:即时编译的原理和作用.md
Normal file
180
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/34 | 运行时优化:即时编译的原理和作用.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="34 | 运行时优化:即时编译的原理和作用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/55/22/556251ec4c8e5461def3bd0d84e0c922.mp3"></audio>
|
||||
|
||||
前面所讲的编译过程,都存在一个明确的编译期,编译成可执行文件后,再执行,这种编译方式**叫做提前编译(AOT)。** 与之对应的,另一个编译方式**是即时编译(JIT),**也就是,在需要运行某段代码的时候,再去编译。其实,Java、JavaScript等语言,都是通过即时编译来提高性能的。
|
||||
|
||||
**那么问题来了:**
|
||||
|
||||
- 什么时候用AOT,什么时候用JIT呢?
|
||||
- 在讲运行期原理时,我提到程序编译后,会生成二进制的可执行文件,加载到内存以后,目标代码会放到代码区,然后开始执行。那么即时编译时,对应的过程是什么?目标代码会存放到哪里呢?
|
||||
- 在什么情况下,我们可以利用即时编译技术,获得运行时的优化效果,又该如何实现呢?
|
||||
|
||||
本节课,我会带你掌握,即时编译技术的特点,和它的实现机理,并通过一个实际的案例,探讨如何充分利用即时编译技术,让系统获得更好的优化。这样一来,你对即时编译技术的理解会更透彻,也会更清楚怎样利用即时编译技术,来优化自己的软件。
|
||||
|
||||
首先,来了解一下,即时编译的特点和原理。
|
||||
|
||||
## 了解即时编译的特点及原理
|
||||
|
||||
根据计算机程序运行的机制,我们把,不需要编译成机器码的执行方式,**叫做解释执行。**解释执行,通常都会基于虚拟机来实现,比如,基于栈的虚拟机,和基于寄存器的虚拟机(在[32讲](https://time.geekbang.org/column/article/161944)中,我带你了解过)。
|
||||
|
||||
与解释执行对应的,是编译成目标代码,直接在CPU上运行。而根据编译时机的不同,又分为AOT和JIT。**那么,JIT的特点,和使用场景是什么呢?**
|
||||
|
||||
一般来说,一个稍微大点儿的程序,静态编译一次,花费的时间很长,而这个等待时间是很煎熬的。如果采用JIT机制,你的程序就可以像,解释执行的程序一样,马上运行,得到反馈结果。
|
||||
|
||||
其次,JIT能保证代码的可移植性。在某些场景下,我们没法提前知道,程序运行的目标机器,所以,也就没有办法提前编译。Java语言,先编译成字节码,到了具体运行的平台上,再即时编译成,目标代码来运行,这种机制,使Java程序具有很好的可移植性。
|
||||
|
||||
再比如,很多程序会用到GPU的功能,加速图像的渲染,但针对不同的GPU,需要编译成不同的目标代码,这里也会用到即时编译功能。
|
||||
|
||||
最后,JIT是编译成机器码的,在这个过程中,可以进行深度的优化,因此程序的性能要比解释执行高很多。
|
||||
|
||||
这样看来,JIT非常有优势。
|
||||
|
||||
而从实际应用来看,原来一些解释执行的语言,后来也采用JIT技术,优化其运行机制,在保留即时运行、可移植的优点的同时,又提升了运行效率,**JavaScript就是其中的典型代表。**基于谷歌推出的V8开源项目,JavaScript的性能获得了极大的提升,使基于Web的前端应用的体验,越来越好,这其中就有JIT的功劳。
|
||||
|
||||
而且据我了解,R语言也加入了JIT功能,从而提升了性能;Python的数据计算模块numba也采用了JIT。
|
||||
|
||||
在了解JIT的特点,和使用场景之后,你有必要对JIT和AOT在技术上的不同之处有所了解,以便掌握JIT的技术原理。
|
||||
|
||||
静态编译的时候,首先要把程序,编译成二进制目标文件,再链接形成可执行文件,然后加载到内存中运行。JIT也需要编译成二进制的目标代码,但是目标代码的加载和链接过程,就不太一样了。
|
||||
|
||||
**首先说说目标代码的加载。**
|
||||
|
||||
在静态编译的情况下,应用程序会被操作系统加载,目标代码被放到了代码区。从安全角度出发,操作系统给每个内存区域,设置了不同的权限,代码区具备可执行权限,所以我们才能运行程序。
|
||||
|
||||
在JIT的情况下,我们需要为这种动态生成的目标代码,申请内存,并给内存设置可执行权限。我写个实际的C语言程序,让你直观地理解一下这个过程。
|
||||
|
||||
我们在一个数组里,存一段小小的机器码,只有9个字节。这段代码的功能,相当于一个C语言函数的功能,也就是把输入的参数加上2,并返回。
|
||||
|
||||
```
|
||||
/*
|
||||
* 机器码,对应下面函数的功能:
|
||||
* int foo(int a){
|
||||
* return a + 2;
|
||||
* }
|
||||
*/
|
||||
uint8_t machine_code[] = {
|
||||
0x55, 0x48, 0x89, 0xe5,
|
||||
0x8d, 0x47, 0x02, 0x5d, 0xc3
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
**你可能问了:**你怎么知道这个函数,对应的机器码是这9个字节呢?
|
||||
|
||||
这不难,你把foo.c编译成目标文件,然后查看里面的机器码就行了。
|
||||
|
||||
```
|
||||
clang -c -O2 foo.c -o foo.o
|
||||
或者
|
||||
gcc -c -O2 foo.c -o foo.o
|
||||
|
||||
objdump -d foo.o
|
||||
|
||||
```
|
||||
|
||||
objdump命令,能够反编译一个目标文件,并把机器码,和对应的汇编码都显示出来:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/3b/ac1eee0040fed86b591d5f6962904a3b.png" alt="">
|
||||
|
||||
另外,用“hexdump foo.o”命令显示这个二进制文件的内容,也能找到这9个字节(图中橙色框中的内容)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/d4/64bab30e9513fcb24d0ee7b184049ad4.png" alt="">
|
||||
|
||||
**这里多说一句,**如果你喜欢深入钻研的话,那么我建议你研究一下,如何从汇编代码生成机器码(实际上就是研究汇编器的原理)。比如,第一行汇编指令“pushq %rbp”,为什么对应的机器码,只有一个字节?如果指令一个字节,操作数一个字节,应该两个字节才对啊?
|
||||
|
||||
其实,你阅读Intel的手册之后,就会知道这个机器码为什么这么设计。因为它要尽量节省空间,所以实际上,很多指令和操作码,会被压缩进,一个字节中去表示。在32讲中,研究字节码的设计时,你应该发现了这个规律。这些设计思路都是相通的,如果你要设计自己的字节码,也可以借鉴这些思想。
|
||||
|
||||
说回我们的话题,既然已经有了机器码,那我们接着再做下面几步:
|
||||
|
||||
- 用mmap申请9个字节的一块内存。用这个函数(不是malloc函数)的好处是,可以指定内存的权限。我们先让它的权限是可读可写的。
|
||||
- 然后用memcp函数,把刚才那9个字节的数组,拷贝到,所申请的内存块中。
|
||||
- 用mprotect函数,把内存的权限,修改为可读和可执行。
|
||||
- 再接着,用一个int(*)(int)型的函数指针,指向这块内存的起始地址,也就是说,该函数有一个int型参数,返回值也是int。
|
||||
- 最后,通过这个函数指针,调用这段机器码,比如fun(1)。你打印它的值,看看是否符合预期。
|
||||
|
||||
完整的代码在[jit.cpp](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/33-jit/jit.cpp)里。
|
||||
|
||||
借这个例子,你可能会知道,通过内存溢出来攻击计算机是怎么回事了。因为只要一块内存是可执行的,你又通过程序写了一些代码进去,就可以攻击系统。是不是有点儿黑客的感觉?所以在jit.cpp里,我们其实很小心地,把内存地址的写权限去掉了。
|
||||
|
||||
**如果你愿意深究,**我建议你,再看一眼objdump打印的汇编码。你会发现,其中开头为0、1和7的三行是没有用的。根据你之前学过的汇编知识,你应该知道,这三行实际是保存栈指针、设置新的栈指针的。但这个例子中,都是用寄存器来操作的,没用到栈,所以这三行代码对应的机器码可以省掉。
|
||||
|
||||
最后,只用4个字节的机器码也能完成同样的功能:
|
||||
|
||||
```
|
||||
//省略了三行汇编代码的机器码:
|
||||
uint8_t machine_code1[] = {
|
||||
0x8d, 0x47, 0x02, 0xc3
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
现在,你应该清楚了,动态生成的代码,是如何加载到内存,然后执行了吧?
|
||||
|
||||
不过,刚刚这个函数比较简单,只做了一点儿算术计算。通常情况下,你的程序会比较复杂,往往在一个函数里,要调用另一个函数。比如,需要在foo函数里,调用bar函数。这个bar函数可能是你自己写的,也可能是一个库函数。执行的时候,需要能从foo函数,跳转到bar函数的地址,执行完毕以后再跳转回来。**那么,你要如何确定bar函数的地址呢?**
|
||||
|
||||
**这就涉及目标代码的链接问题了。**
|
||||
|
||||
原来,编译器生成的二进制对象,都是可重定位的。在静态编译的时候,链接程序最重要的工作,就是重定位(Relocatable),各个符号的地址,包括全局变量、常量的地址和函数的地址,这样,你就可以访问这些变量,或者跳转到函数的入口。
|
||||
|
||||
JIT没有静态链接的过程,但是,也可以运用同样的思路,解决地址解析的问题。你编写的程序里的所有全局变量,和函数,都会被放到一个符号表里,在符号表里记录下它们的地址。这样,引用它们的函数就知道正确的地址了。
|
||||
|
||||
**更进一步,**你写的函数,不仅可以引用你自己编写的,程序中的对象,还可以访问共享库中的对象。比如,很多程序,都会共享libc库中的标准功能,这个库的源代码超过了几百万行,像打印输出这样的基础功能,都可以用这个库来实现。
|
||||
|
||||
**这时候,你可以用到动态链接技术。**动态链接技术运用得很普遍,它是在应用程序加载的时候,来做地址的重定位。
|
||||
|
||||
动态链接,通常会采用“位置无关代码(PIC)”的技术,使动态库,映射进每个应用程序的空间时,其地址看上去都不同。这样一来,可以让动态库被很多应用共享,从而节省内存空间,而且可以提升安全性。因为固定的地址,有利于恶意的程序,去攻击共享库中的代码,从而危及整个系统。
|
||||
|
||||
到目前为止,你已经了解了实现JIT的两个关键技术:
|
||||
|
||||
- 让代码动态加载和执行。
|
||||
- 访问自己写的程序和共享库中的对象。
|
||||
|
||||
它们是JIT的核心。至于代码优化和目标代码生成,与静态编译是一样的。了解这些内容之后,你应该更加理解Java、JavaScript等语言,即时编译运行的过程了吧?
|
||||
|
||||
当然,LLVM对即时编译提供了很好的支持,**它大致的机制是这样的:**
|
||||
|
||||
我们编写的任何模块(Module),都以内存IR的形式存在,LLVM会把模块中的符号,都统一保存到符号表中。当程序要调用模块的方法时,这个模块就会被即时编译,形成可重定位的目标对象,并被调用执行。动态链接库中的方法(如printf)也能够被重定位并调用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/fe/e8743ebbc90d04e5c65be16864d878fe.png" alt="">
|
||||
|
||||
在第一次编译时,你可以让LLVM,仅运行少量的优化算法,这样编译速度比较快,马上就可以运行。而对于被多次调用的函数,你可以让LLVM执行更多的优化算法,生成更优化版本的目标代码。而运行时所收集的某些信息,可以作为某些优化算法的输入,像Java和JavaScript都带有这种多次生成目标代码的功能。
|
||||
|
||||
带你了解JIT的原理之后,接下来,我再通过一个案例,让你对JIT的作用有更加直观的认识。
|
||||
|
||||
## 用JIT提升系统性能
|
||||
|
||||
著名的开源数据库软件,PostgreSQL,你可能听说过。它的历史比MySQL久,功能也比MySQL多一些。在最近的版本中,它添加了基于LLVM的,即时编译功能,性能大大提高。
|
||||
|
||||
看一下下面的SQL语句:
|
||||
|
||||
```
|
||||
select count(*) from table_name where (x + y) > 100
|
||||
|
||||
```
|
||||
|
||||
**这个语句的意思是:**针对某个表,统计一下字段x和y的和大于100的记录有多少条。这个SQL在运行时,需要遍历所有的行,并对每一行,计算“(x + y) > 100”这个表达式的值。如果有1000万行,这个表达式就要被执行1000万次。
|
||||
|
||||
PostgreSQL的团队发现,直接基于AST或者某种IR,解释执行这个表达式的话,所用的时间,占到了处理一行记录所需时间的56%。而基于LLVM实现JIT以后,所用的时间只占到6%,性能整整提高了一倍。
|
||||
|
||||
在这里,我联系[31讲](https://time.geekbang.org/column/article/160990)内存计算的内容,**带你拓展一下。**上面的需求,是典型的基于列进行汇总计算的需求。如果对代码进行向量化处理,再保证数据的局部性,针对这个需求,性能还可以提升很多倍。
|
||||
|
||||
**再说回来。**除了针对表达式的计算进行优化,PostgreSQL的团队还利用LLVM的JIT功能,实现了其他的优化。比如,编译SQL执行计划的时间,缩短了5.5倍;创建b树索引的时间,降低了5%~19%。
|
||||
|
||||
那么32讲中,我提到,将一个规则引擎,编译成字节码,这样在处理大量数据时,可以提高性能。这是因为,JVM也会针对字节码做即时编译。道理是一样的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
对现代编程语言来说,编译期和运行期的界限,越来越模糊了,解释型语言和编译型语言的区别,也越来越模糊了。即时编译技术可以生成,最满足你需求的目标代码。那么通过今天的内容,我强调这样几点:
|
||||
|
||||
1.为了实现JIT功能,你可以动态申请内存,加载目标代码到内存,并赋予内存可执行的权限。在这个过程中,你要注意安全因素。比如,向内存写完代码以后,要取消写权限。
|
||||
|
||||
2.可重定位的目标代码,加上动态链接技术,让JIT产生的代码可以互相调用,以及调用共享库的功能。
|
||||
|
||||
3.JIT技术可以让数据库这类基础软件,获得性能上的提升,如果你有志参与研发这类软件,掌握JIT技术会给你加分很多。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你参与开发的软件,特别是支持DSL的软件,是否可以用JIT技术提升性能?欢迎在留言区分享你的观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多朋友。
|
||||
|
||||
|
169
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/35 | 案例总结与热点问题答疑:后端部分真的比前端部分难吗?.md
Normal file
169
极客时间专栏/编译原理之美/实现一门编译型语言 · 扩展篇/35 | 案例总结与热点问题答疑:后端部分真的比前端部分难吗?.md
Normal file
@@ -0,0 +1,169 @@
|
||||
<audio id="audio" title="35 | 案例总结与热点问题答疑:后端部分真的比前端部分难吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/32/b1f76136f42068c391cffa8441e2da32.mp3"></audio>
|
||||
|
||||
本节课,我会继续剖析一些,你们提出的,有代表性的问题(以后端问题为主),主要包括以下几个方面:
|
||||
|
||||
- 后端技术部分真的比前端技术部分难吗?
|
||||
- 怎样更好地理解栈和栈桢(有几个同学提出的问题很好,有必要在这里探究一下)?这样,你对栈桢的理解会更加扎实。
|
||||
- 有关数据流分析框架。数据流分析是后端技术的几个重点之一,需要再细化一下。
|
||||
- 关于Java的两个知识点:泛型和反射。我会从编译技术的角度讲一讲。
|
||||
|
||||
接下来,进入第一个问题:后端技术真的难吗?正确的学习路径是什么?
|
||||
|
||||
## 后端技术真的难吗?该怎么学?
|
||||
|
||||
有同学觉得,一进到后端,难度马上加大了,你是不是也有这样的感觉?我承认,前端部分和后端部分确实不太相同。
|
||||
|
||||
**前端部分**偏纯逻辑,你只要把算法琢磨透就行了。**而后端部分,**开始用到计算机组成原理的知识,要考虑CPU、寄存器、内存和指令集,甚至还要深入到CPU内部,去看它的流水线结构,以便理解指令排序。当然,我们还要说清楚与操作系统的关系,操作系统是如何加载代码并运行的,如何帮你管理内存等等。另外,还涉及ABI和调用约定,NP完全的算法等等。**看上去复杂了很多。**
|
||||
|
||||
虽然比较复杂,但我认为,这并不意味着后端更难,只意味着知识点更多。可这些知识,往往你熟悉了就不难了。
|
||||
|
||||
比如,**@风**同学见到了汇编代码说:总算遇到了自己熟悉的内容了,不用天天看Java代码了。
|
||||
|
||||
我觉得,从算法的角度出发,后端部分的算法,至少没比前端的语法分析算法难。而且有些知识点,别的课程里应该讲过,如果你从以下三个方面多多积累,会更容易掌握后端内容:
|
||||
|
||||
- 计算机组成原理:CPU的运行原理、汇编指令等等。
|
||||
- 数据结构和算法,特别是与树和图有关的算法:如果你之前了解过,与图有关的算法,了解旅行商问题,那么会发现,指令选择等算法似曾相识。自然会理解,我提到某些算法是NP完全的,是什么意思。
|
||||
- 操作系统:大部分情况下,程序是在操作系统中运行的,所以,要搞清楚我们编译的程序是如何跟操作系统互动的。
|
||||
|
||||
**@沉淀的梦想**就对这些内容,发表过感触:感觉学编译原理,真的能够帮助我们贯通整个计算机科学,涉及到的东西好多。
|
||||
|
||||
确实如他所说,那么我也希望《编译原理之美》这门课,能促使你去学习另外几门基础课,把基础夯实。
|
||||
|
||||
**后端技术的另一个特点,**是它比较偏工程性,不像前端部分有很强的理论性,对于每个问题有清晰的答案。而后端技术部分,往往对同一个问题有多种解决思路和算法,不一定有统一的答案,甚至算法和术语的名称都不统一。
|
||||
|
||||
后端技术的工程性特点,还体现在它会涉及很多技术细节,这些细节信息往往在教科书上是找不到的,必须去查厂商(比如Intel)的手册,有时要到社区里问,有时要看论文,甚至有时候要看源代码。
|
||||
|
||||
总的来说,如何学好后端,**我的建议主要有三个方面:**
|
||||
|
||||
- 学习关联的基础课程,比如《数据结构与算法》,互相印证;
|
||||
- 理解编译原理工程性的特点,接受术语、算法等信息的不一致,并从多渠道获得前沿信息,比如源代码、厂商的手册等等。
|
||||
- 注重实操,亲自动手。比如,你在学优化算法时,即使没时间写算法,也要尽可能用LLVM的算法做做实验。
|
||||
|
||||
按照上面三条建议,你应该可以充分掌握后端技术了。当然,如果你只是想做一个概要的了解,那么阅读文稿也会有不错的收获,因为我已经把主线梳理出来了,能避免你摸不着头脑,不知如何入手。
|
||||
|
||||
接下来,我们进入第二个问题:再次审视一下栈桢。
|
||||
|
||||
## 再次认识栈桢
|
||||
|
||||
**@刘强**同学问:操作系统在栈的管理中到底起不起作用?
|
||||
|
||||
这是操作系统方面的知识点,但可以跟编译技术中栈的管理联系在一起看。
|
||||
|
||||
我们应用程序能够访问很大的地址空间,但操作系统不会慷慨地,一下子分配很多真实的物理内存。操作系统会把内存分成很多页,一页一页地按需分配给应用程序。**那么什么时候分配呢?**
|
||||
|
||||
当应用访问自己内存空间中的一个地址,但实际上没有对应的物理内存时,就会导致CPU产生一个PageFault(在Intel手册中可以查到),这是一种异常(Exception)。
|
||||
|
||||
对异常的处理跟中断的处理很相似,会调用注册好的一个操作系统的例程,在内核态运行,来处理这个异常。这时候,操作系统就会实际分配物理内存。之后,回到用户态,继续执行你的程序,比如,一个push指令等等。整个过程对应用程序是透明的,其实背后有CPU和操作系统的参与。
|
||||
|
||||
**@风**提出了关于栈桢的第二个问题:看到汇编代码里管理栈桢的时候,用了rbp和rsp两个寄存器。是不是有点儿浪费?一个寄存器就够了啊。
|
||||
|
||||
确实是这样,用这种写法是习惯形成的,其实可以省略。而我在[34讲](https://time.geekbang.org/column/article/164017)里,用到的那个foo函数,根本没有使用栈,仅仅用寄存器就完成了工作。这时,可以把下面三行指令全部省掉:
|
||||
|
||||
```
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
popq %rbp
|
||||
|
||||
```
|
||||
|
||||
从而让产生的机器码少5个字节。最重要的是,还省掉两次内存读写操作(相比对寄存器的操作,对内存的操作是很费时间的)。
|
||||
|
||||
实际上,如果你用GCC编译的话,可以使用-fomit-frame-pointer参数来优化,会产生同样的效果,也就是不再使用rbp。在访问栈中的地址时,会采用4(%rsp)、8(%rsp)的方式,在rsp的基础上加某个值,来访问内存。
|
||||
|
||||
**@沉淀的梦想**提出了第三个问题:栈顶(也就是rsp的值)为什么要16字节对齐?
|
||||
|
||||
这其实是一个调用约定。是在GCC发展的过程中,形成的一个事实上的标准。不过,它也有一些好处,比如内存对齐后,某些指令读取数据的速度会更快,这会让你产生一个清晰的印象,每次用到栈桢,至少要占16个字节,也就是4个32位的整数的空间。那么,如果把一些尾递归转化为循环来执行,确实会降低系统的开销,包括内存开销和保存前一个桢的bsp、返回地址、寄存器的运行时间开销。
|
||||
|
||||
而**@不的**问了第四个问题: 为什么要设计成区分调用者、被调用者保护的寄存器,统一由被调用者或者调用者保护,有什么问题么?
|
||||
|
||||
这个问题是关于保护寄存器的,我没有仔细去研究它的根源。**不过我想,这种策略是最经济的。**
|
||||
|
||||
如果全部都是调用者保护,那么你调用的对象不会破坏你的寄存器的话,你也要保护起来,那就增加了成本;如果全部都是被调用者保护,也是一样的逻辑。如果调用者用了很少几个寄存器,被调用者却要保护很多,也不划算。
|
||||
|
||||
所以最优的方法,其实是比较中庸主义的,两边各负责保护一部分,不过,我觉得这可以用概率的方法做比较严谨的证明。
|
||||
|
||||
**关于栈桢,我最后再补充一点。**有的教材用活动记录这个术语,有的教材叫做栈桢。你要知道这两个概念的联系和区别。活动记录是比较抽象的概念,它可以表现为多种实际的实现方式。在我们的课程中,栈桢加上函数调用中所使用的寄存器,就相当于一个活动记录。
|
||||
|
||||
讲完栈桢之后,再来说说与数据流分析框架有关的问题。
|
||||
|
||||
## 细化数据流分析框架
|
||||
|
||||
数据流分析本身,理解起来并不难,就算不引入半格这个数学工具,你也完全可以理解。
|
||||
|
||||
对于数据流分析方法,不同的文献也有不同的描述,有的说是3个要素,有的说是4个要素。而我在文稿里说的是5个要素:方向(D)、值(V)、转换函数(F)、相遇运算(meet operation, Λ)和初始值(I)。你只要把这几个问题弄清楚,就可以了。
|
||||
|
||||
引入半格理论,主要是进一步规范相遇运算,这也是近些年研究界的一个倾向。用数学做形式化地描述虽然简洁清晰,但会不小心提升学习门槛。如果你只是为了写算法,完全可以不理半格理论,但如果为了方便看这方面算法的论文,了解半格理论会更好。
|
||||
|
||||
**首先,半格是一种偏序集。**偏序集里,某些元素是可以比较大小的。但怎么比较大小呢?其实,有时是人为定的,比如,{a, b}和{a, b, c}的大小,就是人为定的。
|
||||
|
||||
那么,既然能比较大小,就有上界(Upper Bound)和下界(Lower Bound)的说法。给定偏序集P的一个子集A,如果A中的每个元素a,都小于等于一个值x(x属于P),那么x就是A的一个上界。反过来,你也知道什么是下界。
|
||||
|
||||
**半格是偏序集中,一种特殊的类型,**它要求偏序集中,每个非空有限的子集,要么有最小上界(并半格,join-semilattice),要么有最大下界(交半格,meet-semilattice)。
|
||||
|
||||
其实,如果你把一个偏序集排序的含义反过来,它就会从交半格转换成并半格,或者并半格转换成交半格。我们还定义了两个特殊值:Top、Bottom。在不同的文献里,Top和Bottom有时刚好是反着的,那是因为排序的方向是反着的。
|
||||
|
||||
因为交半格和并半格是可以相互转化的,所以有的研究者采用的框架,就只用交半格。交半格中,集合{x, y}的最大下界,就记做x Λ y。在做活跃性分析的时候,我们就规定{a, b} > {a, b, c}就行了,这样就是个交半格。如果按照这个规矩,我在[28讲](https://time.geekbang.org/column/article/156878)中举的那个常数传播的例子,应该把大小反过来,也做成个交半格。文稿中的写法,实际是个并半格,不过也不影响写算法。
|
||||
|
||||
这样讲,你更容易理解了吧?现在你再看到不同文献里,关于数据流分析中的偏序集、半格的时候,应该可以明白是怎么回事了。
|
||||
|
||||
最后,我再讲讲关于Java的两个知识点:泛型和反射。这也是一些同学关注的问题。
|
||||
|
||||
## Java的两个知识点:泛型和反射
|
||||
|
||||
泛型机制大大方便了我们编写某些程序,不用一次次做强制类型转换和检查了。比如,我们要用一个String类型的List,就声明为:
|
||||
|
||||
```
|
||||
List<String> myList;
|
||||
|
||||
```
|
||||
|
||||
这样,你从myList中访问一个元素,获取的自然就是一个String对象,而不是基类Object对象。
|
||||
|
||||
**而增加泛型这个机制其实很简单。**它只是在编译期增加了类型检查的机制,运行期没有任何改变。List<string>和List<integer>运行的字节码都是完全相同的。</integer></string>
|
||||
|
||||
那么反射机制呢?它使我们能够在运行期,通过字符串形式的类名和方法名,来创建类,并调用方法。这其实绕过了编译期的检查机制,而是在运行期操纵对象:
|
||||
|
||||
```
|
||||
//获取Class
|
||||
Class<?> clazz = Class.forName("MyClass");
|
||||
//动态创建实例
|
||||
Object obj = clazz.newInstance();
|
||||
//获取add方法的引用
|
||||
Method method = clazz.getMethod("add",int.class,int.class);
|
||||
//调用add方法
|
||||
Object result = method.invoke(obj,1,4);
|
||||
|
||||
```
|
||||
|
||||
这样能带来很多灵活性,方便你写一些框架,或者写IDE。
|
||||
|
||||
从编译技术的角度看,实现反射很容易。因为在[32讲](https://time.geekbang.org/column/article/161944)中,你已经了解了字节码的结构。当时,我比较侧重讲指令,其实你还会看到它前面的,完整的符号表(也就是记录了类名、方法名等信息)。正因为有这些信息,所以反编译工具能够从字节码重新生成Java的源文件。
|
||||
|
||||
所以,虽然在运行时,Java类已经编译成字节码了,但我们仍然可以列出它所有的方法,可以实例化它,可以执行它的方法(因为可以查到方法的入口地址)。**所以你看,**一旦你掌握了底层机制,理解上层的一些特性就很容易了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
编译器的后端技术部分也告一段落了。我们用16讲的篇幅,涵盖了运行时机制、汇编语言基础知识、中间代码、优化算法、目标代码生成、垃圾收集、即时编译等知识点,还针对内存计算和Java的字节生码成做了两个练习,中间还一直穿插介绍LLVM这个工具。我之前就提到,实现一个编译器,后端的工作量会很大,现在你应该有所体会。
|
||||
|
||||
在这里,我也想强调,后端技术的工程性比较强,每本书所采用的术语和算法等信息,都不尽相同。在我们的课程中,我给你梳理了一条,比较清晰的脉络,你可以沿着这条脉络,逐步深化,不断获得自己的感悟,早日修炼成后端技术的高手!
|
||||
|
||||
在答疑篇的最后,我总结了一些案例,供你参考。
|
||||
|
||||
## 案例总结
|
||||
|
||||
**第一批示例程序,**与汇编代码有关,包括手写的汇编代码,以及从playscript生成汇编代码的程序。这部分内容,主要是打破你对汇编代码的畏惧心,知道它虽然细节很多,但并不难。在讲解后端技术部分时,我总是在提汇编代码,在34讲,我甚至写了一个黑客级的小程序,直接操作机器码。我希望经历了这些过程之后,你能对汇编代码亲切起来,产生可以掌握它的信心。
|
||||
|
||||
**第二批示例程序,**是基于LLVM工具生成IR的示例代码。掌握LLVM的IR,熟悉调用LLVM的API编程,能让你在写完前端以后,以最短的时间,拥有所有后端的功能。通过LLVM,你也会更加具体的体会,代码优化等功能。
|
||||
|
||||
**第三批示例程序,**是内存计算和字节码生成,这两个应用题目。通过这两个应用题目,你会体会到两点:
|
||||
|
||||
- 编译器后端技术对于从事一些基础软件的开发很有用;
|
||||
- 虽然课程没有过多讲解Java技术,只通过一个应用篇去使用Java的字节码,但你会发现,我们对后端技术的基本知识,比如对中间代码的理解,都可以马上应用到Java语言上,得到举一反三的感觉。
|
||||
|
||||
## 一课一思
|
||||
|
||||
如果你在工作中真的接到了一个任务,要实现某编译器的后端,你觉得学过本课程以后,你敢接手这个任务吗?还有哪些地方是需要你再去补足的?你完成这个任务比较可靠的路径是什么?欢迎在留言区分享你的观点。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
Reference in New Issue
Block a user