This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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。
**它的核心思想是:**在程序中,往往新创建的对象会很快死去,比如,你在一个方法中,使用临时变量指向一些新创建的对象,这些对象大多数在退出方法时,就没用了。**根据这个原理,**垃圾收集器将注意力集中在比较“年轻”的数据上,因为它们成为垃圾的概率比较高。
我们把堆划分成若干“代”GenerationG0是最新代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语言做好调优。
## 一课一思
垃圾收集机制曾经给你造成了什么困惑吗?你是怎么解决的?学完本讲后,能否从原理的角度分析一下?欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View 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) &gt; 100
```
**这个语句的意思是:**针对某个表统计一下字段x和y的和大于100的记录有多少条。这个SQL在运行时需要遍历所有的行并对每一行计算“(x + y) &gt; 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技术提升性能欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多朋友。

View 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都小于等于一个值xx属于P那么x就是A的一个上界。反过来你也知道什么是下界。
**半格是偏序集中,一种特殊的类型,**它要求偏序集中每个非空有限的子集要么有最小上界并半格join-semilattice要么有最大下界交半格meet-semilattice
其实如果你把一个偏序集排序的含义反过来它就会从交半格转换成并半格或者并半格转换成交半格。我们还定义了两个特殊值Top、Bottom。在不同的文献里Top和Bottom有时刚好是反着的那是因为排序的方向是反着的。
因为交半格和并半格是可以相互转化的,所以有的研究者采用的框架,就只用交半格。交半格中,集合{x, y}的最大下界就记做x Λ y。在做活跃性分析的时候我们就规定{a, b} &gt; {a, b, c}就行了,这样就是个交半格。如果按照这个规矩,我在[28讲](https://time.geekbang.org/column/article/156878)中举的那个常数传播的例子,应该把大小反过来,也做成个交半格。文稿中的写法,实际是个并半格,不过也不影响写算法。
这样讲,你更容易理解了吧?现在你再看到不同文献里,关于数据流分析中的偏序集、半格的时候,应该可以明白是怎么回事了。
最后我再讲讲关于Java的两个知识点泛型和反射。这也是一些同学关注的问题。
## Java的两个知识点泛型和反射
泛型机制大大方便了我们编写某些程序不用一次次做强制类型转换和检查了。比如我们要用一个String类型的List就声明为
```
List&lt;String&gt; myList
```
这样你从myList中访问一个元素获取的自然就是一个String对象而不是基类Object对象。
**而增加泛型这个机制其实很简单。**它只是在编译期增加了类型检查的机制运行期没有任何改变。List<string>和List<integer>运行的字节码都是完全相同的。</integer></string>
那么反射机制呢?它使我们能够在运行期,通过字符串形式的类名和方法名,来创建类,并调用方法。这其实绕过了编译期的检查机制,而是在运行期操纵对象:
```
//获取Class
Class&lt;?&gt; clazz = Class.forName(&quot;MyClass&quot;);
//动态创建实例
Object obj = clazz.newInstance();
//获取add方法的引用
Method method = clazz.getMethod(&quot;add&quot;,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语言上得到举一反三的感觉。
## 一课一思
如果你在工作中真的接到了一个任务,要实现某编译器的后端,你觉得学过本课程以后,你敢接手这个任务吗?还有哪些地方是需要你再去补足的?你完成这个任务比较可靠的路径是什么?欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。