V8引擎垃圾回收机制简介

1,217 阅读12分钟

什么是垃圾回收

垃圾回收(Garbage Collection),故名思义就是对内存中,已经不需要的内存进行回收,释放出内存来去完成其他的事情,在真正了解垃圾回收之前,我们需要先了解下面的几个含义。

内存的生命周期

在了解垃圾回收之前,我想我们有必要来了解一下内存的生命周期。对于一般的程序语言来说,内存的生命周期可以简单的概括为如下的三个阶段:

  • 申请内存:按照需求分配内存;
  • 使用内存:对已经分配到的内存进行读写;
  • 释放内存:归还释放的内存。

内存的使用可以分为“自动挡”和“手动挡”,

对于C/C++这样的底层语言来说,内存的使用是需要手动申请,手动释放的。

我们可以这样理解,你去食堂吃饭,打完饭之后,找个地方坐下(申请内存),在座位上面嘎嘎炫了两大碗(使用内存),吃完之后收拾一下桌面,收走餐盘(释放内存)。当然,我们可以吃完不收拾,就会导致这块内存一直被占用,长此以往没有新的内存可供分配,程序自然就会崩溃。

对于JavaScript这样的高级语言来说,因为有垃圾回收器的工作,在使用的过程中不需要过多的考虑内存使用的问题。

可以这样理解,你去外面的餐厅吃饭,会在服务员的指引下找到餐桌,吃完之后服务员也会对桌子进行清理。对于JS来说,垃圾回收器扮演的就是这样的一个角色

JavaScript中的内存分配

在JavaScript的内存分配是根据变量的数据类型来进行分配的,内存分为两种类型:

  • 栈内存:因为使用了栈的结构,所以叫栈内存,作为一种简单储存,适合存放生命周期短,占用空间小而且固定的数据,由系统直接管理,进行内存分配和自动释放,所以基本数据类型数据分配在栈存中。
  • 堆内存:可以理解成可以储存任何数据类型的,很大的一个存储空间。堆内存按需进行内存空间的申请,动态分配且不连续,值大小不固定,访问速度比栈内存要慢,无用数据需要JS引擎程序主动去回收。引用类型的数据会同时分配在栈内存和堆内存,其地址存在栈内存,其具体内容存在堆内存中。

为什么要垃圾回收

有了上面的前提的我想我们对垃圾回收有了一定的了解,这也能够说明垃圾回收的必要性,内存如果不释放导致程序崩溃。我们知道,在V8引擎逐行执行JavaScript代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。

V8的内存限制

垃圾回收本身就是一个非常消耗时间的操作,我们假设V8的堆内存为1.5G,那么V8做一次小的垃圾回收需要50ms以上,而做一次非增量式回收甚至需要1s以上!这对于浏览器来说已经是很难接受的了。为此V8采用了一种比较暴力的方式,就是限制堆内存的大小( 对于64位的机器只能使用大约1.4G,32位的就只能使用0.7G)的方式来减少垃圾回收对于主线程的影响。

我们知道Node.js是一个基于 Chrome V8 引擎的 JavaScript 运行时。JavaScript能都在node端告诉运行既得益于V8引擎,自然要受V8的内存限制。这个限制对于浏览器来说,足以胜任前端页面的几乎所有需求,当然对于服务器来说操作大内存也不是常见的需求场景,但是不能像其他服务端语言一样可以自由分配内存,而且还存在到达界限之后导致进程的退出。了解V8的垃圾回收方式,才能更好的进行内存管理。

V8引擎中的回收算法

V8引擎的垃圾回收策略是基于代际假说,认为:

  • 大部分的对象都是"朝生暮死"的;
  • 不死的对象会活的很久。

于此,V8把堆分为新生代老生代两种,对于新生代通常只是维护1~8M的容量,而老生代所支持的容量就大得多。对于这两块区域,V8分别使用不同的垃圾回收器,分别对应不同的算法,以便更加高效的进行垃圾回收。

  • 副垃圾回收器 - Scavenge:主要是负责新生代的垃圾回收
  • 主垃圾回收器 - Mark-Sweep & Mark-Compact:主要是负责老生代的垃圾回收

新生代

一般情况下,大多数小的对象都会被分配到新生代,这个地方虽然比较小但是更新是比较频繁的,所以需要一种比较快的算法。

在新生代中所使用的算法是Scavenge,这个算法是早在1970年就提出的理论。它会把新生代分成FromTo两块区域,当From区域存满时,它会将From区域中存活的对象复制到To区域中去,并将内存有序的排列起来,完成之后释放From内存,最后将From和To区域进行翻转,即原来的From变成To,现在的To变成From

我们可以从上图可以观察到,复制的操作不仅保存了存活的对象,也没有产生内存碎片,无需整理内存。对于Scavenge算法来说,是一种典型的使用牺牲空间换取时间的算法。因为它在空间上的开销很适合这种少量内存的新生代垃圾回收。

新生代为什么都设置的很小?

因为新生代使用Scavenge算法每次执行清理操作时,都需要将存活的对象从From区域复制到To区域。复制这一个过程本身就需要时间成本,如果把新生代设置的很大,那么就会导致时间太长,为了执行效率一般不会把新生代设置的很大。

垃圾回收器是怎么知道哪些对象是活动对象和非活动对象的呢?

有一个概念叫对象的可达性,表示从初始的根对象(window,global)的指针开始,这个根指针对象被称为根集(root set),从这个根集向下搜索其子节点,被搜索到的子节点说明该节点的引用对象可达,并为其留下标记,然后递归这个搜索的过程,直到所有子节点都被遍历结束,那么没有被标记的对象节点,说明该对象没有被任何地方引用,可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

老生代

对于老生代来说,存活兑现的对象占比比较大,使用Scavenge算法会导致效率较低,而且还要浪费一半的内存空间。这两个问题让该算法在处理老生代时显得捉襟见肘,所以我们需要新的算法来处理这里内存。

来源

老生代的来源主要有两个

  • 对象空间占用较大,一些大的对象会直接被放到老生代。
  • 从新生代来的晋升对象

新生代的对象满足一定条件就可以被转移到老生代,称为晋升,晋升的途径包括:

  • 对象是否经历过一次Scavenge算法,对于经历过一个新生代循环的对象,下次不会被复制到To区域,而是被扔到老生代。
  • To区域的内存占比已经超过25%。之所以有这个限制是因为,To区域在经历一个Scavenge算法之后,会与From区域进行角色互换,后续的内存分配都是在From区域进行的,如果内存使用过高,甚至是溢出,则会影响后续的对象分配,所以才会有25%的限制。

Mark-Sweep

老生代是使用Mark-Sweep(标记-清除)算法来进行回收未存活的对象,它的过程主要是分为两个阶段

  • 标记:标记阶段会从一组根元素开始,递归遍历这个组根元素,在遍历过程中,能够到达的元素标记为存活对象,没有到达的对象则就判断为垃圾。
  • 清除:依旧是递归遍历,清除所有未被标记的活动对象。

    上图我们可以观察到,经过一次Mark-Sweep之后会产生一段一段的不连续的内存碎片,如果我们不对这些碎片进行处理,后面有大对象需要内存时,很有可能造成无法分配,所有我们需要新的方法来处理这些内存碎片。

Mark-Compact

为了解决如上问题,Mark-Compact被提出来,它将所有的活动对象往一端移动,移动完成之后,直接清理掉边界外的内存,这样就可以得到连续的内存。

性能优化问题

全停顿 - stop-the-world

由于垃圾回收是在JS引擎中进行的,在进行垃圾回收时为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致的问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)

这个结果我们显然是不能接受的,新生代的垃圾回收还好,内存比较小,但是对于老生代的垃圾回收就会造成页面的卡顿,为了优化用户体验,V8也提出了一系列的解决方案。但是主要思想就是如下两个方向:

  • 大任务拆成一个个的小任务,分步进行;
  • 将一些任务放在后台执行,不占用主线程。

增量标记 - Incremental marking

增量标记就是把标记任务分成多个阶段,每个阶段都只标记一部分对象,和主线程穿插进行。

为了支持增量标记,V8必须支持垃圾回收的暂停恢复,这里采用了黑白灰三色标记法

  • 黑色表示这个节点被垃圾回收根引用到了,而且该节点的子节点都已经标记完成了
  • 灰色表示这个节点被 垃圾回收根引用到了,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
  • 白色表示此节点还没未被垃圾回收器发现,如果在本轮遍历结束时还是白色,那么这块数据就会被收回

引入黑白灰标记法之后,就可以通过标记判断,重新开始或是暂停正在进行的标记。

惰性清理 -  Lazy sweeping

增量标记完成之后,惰性清理才是真正能够清理垃圾的阶段。当我们的内存能够使我们流畅的运行代码,其实我们是没有必要进行清理内存的,它会稍稍延迟一下清理过程,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象都清理完毕。

并行 - parallel

新生代的垃圾回收采取并行策略提升垃圾回收速度,它会开启多个辅助线程来执行新生代的垃圾回收工作

并行执行需要的时间等于所有的辅助线程时间的总和加上管理的时间

并行执行的时候也是全停顿的状态,主线程不能进行任何操作,只能等待辅助线程的完成

这个主要应用于新生代的垃圾回收

并发 - Concurrent

并发就是在 JS 主线程运行的时候,同时开启辅助线程,清理和主线程没有任何逻辑关系的垃圾。

总结

其实,对于大部分的前端开发人员,在日常开发中基本不会关注内存管理问题,但是了解一些垃圾回收的内部原理,对于一些优化项目还是有帮助的 。

V8引擎对于不同的区域、不能功能的内存,分别执行不同的策略,其中Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象, 这样合理分配极大提升了清理效率;再加上各种功性能优化的手段让我们在使用的过程中,根本不会体验到卡顿。

其实V8对于垃圾回收还做出了很多改进,笔者能说明也只是冰山一角,文章中也会有不准确的部分,欢迎大佬指正!

参考文献