简单了解 V8 的垃圾回收算法
# V8 的垃圾回收
在我们的日常开发中,很少会遇到内存泄漏的情况,哪怕遇到了也可以通过使用 Buffer 或者放开 V8 的内存限制来解决(非常不推荐)。但是作为一个 NodeJS 开发者,我觉得很有必要认识 Node 的 V8 引擎是垃圾回收大概是怎么样的,它用到了什么样的算法去实现,也可以看下他跟 Java 的 JVM 或者其他语言的引擎有啥不同。
# 分代式垃圾回收机制
分代式垃圾回收机制是非常常见的一种垃圾回收机制,因为长时间的发展中,大家发现没有一种算法可以应付所有的情况,于是按对象的存活时间将内存的垃圾回收进行不同的处理,分别对不同的对象实施不同的更高效的算法。比如分代式指的就是把内存分为老生代和新生代。
# 新生代内存处理
# Scavenge 算法
在新生代的内存中 V8 主要通过 Scavenge 算法来进行垃圾回收。这个算法是一个采用一分为二的方式来实现的,它将新生代内存空间分为两份,每份空间叫 semispace,一个空间使用(From),一个空间闲置(To)。当我们分配对象时,先是在 From 空间中进行分配。当垃圾回收开始的时候,会检查 From 空间中的存活对象,这些存活对象会被复制到 To 空间中,然后 From 空间非存活对象占用的空间被释放,最后 From 空间和 To 空间的角色发生对换。
很明显,Scavenge 是典型的用空间换时间的算法,因为需要空闲出一半的内存空间来完成算法,虽然速度很快,但是牺牲了一半的空间,所以只适合在新生代内存空间这种生命周期很短,空间比较小的处理上。
到了这里,很多人就疑惑,如果一个对象经过多次回收一直存活,岂不是在做无用功?确实,如果一个对象长期存活,在频繁回收的新生代空间里面是一个浪费,这时候我们就需要把这个对象处理下了:我们把经历过一次 Scavenge 回收的对象复制到老生代内存空间:
但是还有一个问题 To 空间的大小问题,一次垃圾回收不是只回收一个对象,From 空间转移到 To 空间的过程中有可能会有比较多的内存对象被复制到 To 空间中去,这时候如果 To 空间不够用或者太小了就很比较麻烦,所以我们还得增加一个限制:
# 老生代内存处理
在老生代内存空间中,老生代的对象都是存活对象占的比重比较大,而且空间也比较大,用 Scavenge 算法已经捉襟见肘了,所以需要用不一样的算法。
# Mark-Sweep 算法
Mark-Sweep 算法分为两个阶段:标记和清除。首先标记阶段,Mark-Sweep 在标记阶段遍历堆中的所有对象,标记活着的对象,然后清理死亡对象即可,由于老生代中死亡对象相对比较小,所以效率很快。
# Mark-Compact 算法
Mark-Compact 算法跟 Mark-Sweep 算法不同的是它是直接标记死亡对象,然后把活着的对象往一端移动,最后把另外一端的内存全部清除掉。
# 结合使用
这两种算法各有优劣,很明显,Mark-Compact 算法虽然可以解决内存空间碎片的问题,但是效率肯定是没有 Mark-Sweep 算法来的高的,因为他需要移动内存对象,所以在 V8 中主要是使用 Mark-Sweep 算法,直到内存空间不足以对对象分配时才会使用 Mark-Compact 算法。
# 其余的优化
上面的算法和策略解决了基本的垃圾回收,但是 V8 的垃圾回收没有那么的简单:执行垃圾回收的时候通常需要暂停 JavaScript 应用逻辑,因为不暂停的话会出现 JavaScript 应用和垃圾回收器所获得的内存对象结果不一致的情况。在新生代的垃圾回收中暂停可能不会有什么影响,毕竟新生代内存小,存活对象也少,但是在老生代内存空间中,空间大,存活对象多,标记、清理和整理的过程会比较长,这时候就会让暂停的时间变得很长,这是无法接受的,所以 V8 引擎将常规的标记改为了增量标记,就是拆成很多步来实现完成完整的内存标记,这样可以让暂停的时间减少。同理,V8 还引入了延迟清理、增量式整理这些优化方法。。。
# 结语
其实 V8 引擎的垃圾回收远远不止那么简单,但是我们这次先大概了解下基本的内存处理的算法。