浏览器垃圾回收机制
垃圾回收机制是由引擎来负责的。JS 引擎有很多种(各个浏览器都不同),其垃圾回收机制在一些细节及优化上也略有不同。本文以一些通用的回收算法作为切入,再由市场占有率大的 V8 引擎发展至今对该机制的优化为例。JavaScript 是门魅力无限的语言,关于它的 GC(垃圾回收)方面,你了解多少呢?想来大部分人是因为面试才去看一些面试题从而了解的垃圾回收,那在正式开始之前,给大家列几个小问题,大家可以先想一下答案,带着问题及答案再去看文章。
(一)GC 是什么
GC 即 Garbage Collection,程序工作过程中会产生很多垃圾,这些垃圾占据着内存空间。而 GC 就是负责回收垃圾的,因为它工作在引擎内部,所以对于前端来说,GC 过程是相对透明的。这一套引擎执行的操作就是垃圾回收机制。
并非所有语言都有 GC。一般的高级语言会自带 GC,比如 Java、Python、JavaScript 等,但也有无 GC 的语言,比如 C、C++ 等。后者需要程序员手动管理内存。
(二)垃圾产生&为何回收
代码中创建的基本类型、对象、函数等都需要占用内存,这是引擎自动分配的,不需要程序员显式手动分配内存。但当我们不再需要某个东西时,JavaScript 引擎又是如何发现并清理它的?
1 | let test = { |
如上所示,我们假设它是一个完整的程序代码。
JavaScript 的引用数据类型保存在堆内存中,然后在栈内存中保存了对堆内存中实际对象的引用,所以,JavaScript 中对引用数据类型的操作都是操作对象的引用而不是实际对象。即栈内存中保存了一个地址,这个地址指向堆内存中的实际值。
那上面代码首先声明了一个变量 test,它引用了对象 {name: 'mohui'}
,接着把这个变量重新赋值了一个数组对象,那么之前的对象引用关系就没有了,如下图。

没有了引用关系,也就成了无用的对象,其占据的内存需要被清理回收。程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。
在 JS 中,常见的内存泄露主要有 4 种,全局变量、闭包、DOM 元素的引用、定时器。
(三)垃圾回收策略
在 JavaScript 内存管理中有一个概念叫做可达性,就是以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)并给予清理的问题,JavaScript 垃圾回收机制的原理就依赖于定期查找那些不再用到的内存(变量),然后释放其内存。至于为何不实时查找并释放无用内存,因为实时开销太大了。
垃圾回收涉及到了一些算法策略,有多种方式,这里介绍最常见的两种:标记清除算法、引用计数算法。
1. 标记清除
标记清除(Mark-Sweep),在 JS 引擎里最为常见,目前大多数浏览器的 JS 引擎都在采用这种算法,只不过各大浏览器厂商还对此算法进行了优化加工,且不同 JS 引擎在运行垃圾回收的频率上有所差异。
标记清除算法分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(即非活动对象)销毁。标记变量有很多种办法,比如当变量进入执行环境时反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入和离开环境变量的两个列表,灵活地把变量从在列表中转移。其实,怎样标记并不重要,重要的是其策略。
引擎在使用标记清除算法执行 GC 时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组根对象,而所谓的根对象,其实在浏览器环境中包括又不止于全局 Window 对象、文档 DOM 树等。
标记清除的大致过程如下:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为 0
- 从各个根对象开始遍历,把非垃圾节点改成 1
- 清理所有标记为 0 的垃圾,销毁并回收它们所占用的内存空间
- 最后把所有内存中对象标记修改为 0,等待下一轮垃圾回收
标记清除算法实现简单,标记无非只有打与不打两种状态,这使得一位二进制位就能为其标记。当然这也是它唯一的优点。这个算法最大的缺陷在于回收的对象内存位置是不变的,这会导致空闲内存空间不连续,出现内存碎片。这些由不同大小内存组成的内存列表,不利于内存分配。

假设新建对象所需内存大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配。

可以采取下面三种分配策略来查找内存块:
- First-fit,找到不小于 size 的块立即返回
- Best-fit,遍历整个空闲列表,返回不小于 size 的最小份内存块
- Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回
这三种策略里面 Worst-fit 的空间利用率看似合理,但实际上切分后会造成更多的小块,所以不推荐使用。考虑到分配的速度和效率,采用 First-fit 更为明智。
综上所述,标记清除算法存在两个很明显的缺点。
- 内存碎片化:空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢:即便使用 First-fit 策略,其时间复杂度仍有 O(n),最坏情况是每次都要遍历到最后。同时因为碎片化,大对象的分配效率会更慢
2. 标记整理
而标记整理(Mark-Compact)算法就能有效处理内存碎片,它的标记阶段和标记清除别无二致,只是标记结束后会将不需要清理的对象向内存的一端移动,最后再清理垃圾。

3. 引用计数
引用计数(Reference Counting)是早先的一种垃圾回收算法,它把垃圾简化定义为没有被其它对象引用的对象。如果没有引用指向该对象(零引用),则其将被回收。这种算法慢慢被淘汰了,因为它存在很多缺陷。
它的策略是跟踪记录每个变量值被使用的次数。当声明了一个变量并且将一个引用类型赋值给该变量时,这个值的引用次数就为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。如果该变量的值被其他的值覆盖了,则引用次数减 1。当这个值的引用次数变为 0 时,说明没有变量在使用,这个值没法被访问。垃圾回收器会在运行时清理掉引用次数为 0 的对象。
1 | let a = new Object() // 此对象的引用计数为 1(a 引用) |
这种方式虽然简单,但无法处理循环引用。即两个对象互有属性引用对方。
1 | function test(){ |
如上所示,对象 A 和 B 通过各自的属性相互引用,按照上文的引用计数策略,它们的引用数量都是 2。在函数 test 执行完后,对象 A 和 B 本应被清理,但它们的引用数量不会变成 0。假如此函数被多次调用,那么会白白占据大量的内存。再从标记清除的角度看,当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象清除掉。这也是后来放弃引用计数,使用标记清除的原因之一。
在 IE8 以及更早版本的 IE 中,BOM 和 DOM 对象是由 C++ 实现的组件对象模型对象(COM,Component Object Model),并非原生 JS 对象。而 COM 对象使用引用计数来实现垃圾回收,所以即使浏览器使用标记清除算法,对于涉及到 COM 对象的循环引用也无能为力,比如两个互相引用的 DOM 对象等。要想解决循环引用,需要将引用地址置为 null,来切断变量与之前引用值的关系。
1
2
3
4
5
6
7
8
9 // COM 对象
let ele = document.getElementById("xxx")
let obj = new Object()
// 造成循环引用
obj.ele = ele
ele.obj = obj
// 切断引用关系
obj.ele = null
ele.obj = null不过在 IE9 及以后的 BOM 与 DOM 对象都改成了 JS 对象,也就避免了上述问题。
优点:相较于标记清除,引用计数能立即回收垃圾,在引用值为 0,即变成垃圾时就被回收。而标记清除需要每隔一段时间才进行,线程必须要暂停去执行一段时间的 GC。另外标记清除还需要遍历堆里的活动及非活动对象,而引用计数则只需要在引用时计数即可。
缺点:引用计数需要一个计数器,而该计数器需要占很大空间,因为无法确定被引用数量的上限。另外无法解决循环。
(四)V8 对 GC 的优化
跟多数浏览器一样,V8 也基于标记清除算法,但其对回收的机制进行了优化处理。
1. 分代式垃圾回收
垃圾清理算法在每次垃圾回收时都要检查内存中所有对象。但这不适合将一些大、老、存活时间长的对象和新、小、存活时间短的对象进行相同频率的检查,因为前者需要时间长并且不需要频繁进行清理,后者恰好相反。分代式就能对此优化。
新老生代
V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。新生代的对象为存活时间较短的对象,即新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,即经历过新生代垃圾回收后还存活下来的对象,容量通常较大。
V8 整个堆内存的大小就等于新生代加上老生代的内存。

对于新老两块内存区域的垃圾回收,V8 采用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫做新生代垃圾回收器,将管理老生代的垃圾回收器叫做老生代垃圾回收器。
新生代垃圾回收
新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge 算法的具体实现中,主要采用了一种复制式的方法即 Cheney 算法。Cheney 算法中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为使用区,一个是处于闲置状态的称之为空闲区。

新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区
当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。另外,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中。设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。
老生代垃圾回收
大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果像新生代一般不断复制会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器采用标记清除来管理其垃圾回收执行。
首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象清理掉。标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了标记整理算法来解决这一问题。
为什么需要分代式?
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。
2. 并行回收 (Parallel)
JavaScript 是一门单线程的语言,它是运行在主线程上的。在进行垃圾回收时会阻塞 JavaScript 脚本的执行,等到垃圾回收完毕后才恢复执行,这种行为叫做全停顿(Stop-The-World)。比如一次 GC 需要 60ms,那应用逻辑就得暂停 60ms,假如一次 GC 的时间过长,对用户来说就可能造成页面卡顿等问题。
既然存在执行一次 GC 比较耗时的情况,V8 团队引入了并行回收机制,垃圾回收器在主线程上执行的过程中,开启多个辅助线程同时处理同样的回收工作,以此加快垃圾回收的执行速度。

尽管并行回收使得执行速度加快,但主线程还是需要让出来的,也正因此,这个过程内存是静态的,无需考虑内存中对象的引用关系改变,只需要考虑协同,实现起来也很简单。新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针,此即并行回收。
3. 增量标记与懒性清理
并行策略虽然可以增加垃圾回收的效率,有效优化新生代垃圾回收器,但它仍是一种全停顿式的垃圾回收方式。对于老生代来说,它的内部存放的都是一些比较大的对象,对其使用并行回收依然可能会消耗大量时间。所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记。
① 什么是增量
增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记。

将一次完整的 GC 标记分次执行,那在每一小次 GC 标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢?那假如我们在一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?增量的实现要比并行复杂,V8 对这两个问题对应的解决方案分别是三色标记法与写屏障。
② 三色标记法 (暂停与恢复)
老生代采用标记清理算法,在没有采用增量算法前,单纯使用黑色和白色来标记数据即可:在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组根对象出发,将所有能访问到的数据标记为黑色,遍历结束后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象就是待清理的垃圾对象
如果采用非黑即白的标记策略,那在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段 JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了。为了解决这个问题,V8 团队采用了三色标记法:使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑
- 白色指的是未被标记的对象
- 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
- 黑色指自身和成员变量皆被标记

最初所有的对象都是白色,意味着回收器没有标记它们。从一组根对象开始,先将这组根对象标记为灰色并推入到标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其由灰色转变成黑色,并将自身的下一个引用对象转为灰色。就这样一直往下走,直到没有可标记灰色的对象时,那么剩下的所有白色对象都是没被引用、无法到达的,等待被回收(如上图中的 C、E 将要等待回收)。
三色标记法能够很好恢复执行,通过当前内存中有无灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行即可。三色标记法的 mark 操作可以渐进执行,而无需每次都扫描整个内存空间,有效配合增量回收进行暂停恢复的一些操作,从而减少全停顿的时间。
③ 写屏障 (增量中修改引用)
一次完整的 GC 标记分块暂停后,重新执行任务程序时,如果内存中标记好的对象引用关系被修改了,那么增量中修改引用。

假如有 A、B、C 三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行应用程序也就是 JavaScript 脚本,在脚本中将对象 B 的指向由对象 C 改为了对象 D,接着恢复执行下一次增量分段。
这时对象 C 已经无引用关系了,但目前它是黑色(代表活动对象),此一整轮 GC 不会清理 C,但就算此轮不清理,下一轮的 GC 也会清理,这对程序运行影响不大。
而新的对象 D 尽管被引用,但仍是初始的白色,由于此时已没有灰色对象了,代表全部标记完毕,对象 D 将在此轮 GC 的清理阶段被回收。为了解决这个问题,V8 增量回收使用写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,则强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作强三色不变性。所以对象 B 的指向由对象 C 改为对象 D 后,白色对象 D 会被强制改为灰色。
增量标记与惰性清理的出现,使得主线程的停顿时间大大减少,让用户与浏览器交互的过程变得更加流畅。但由于每个小的增量标记之间执行了 JavaScript 代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记并没有减少主线程的总暂停的时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量。
④ 懒性清理
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理 (Lazy Sweeping)。当增量标记完成后,假如当前的可用内存足以迅速地执行代码,则没必要立即清理内存,而是将稍微延迟清理,让 JavaScript 脚本代码先执行。也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。
4. 并发回收 (Concurrent)
并行回收依然会阻塞主线程,而增量标记增加了总暂停时间、降低了应用程序吞吐量,那怎么才能在不阻塞主线程的情况下执行垃圾回收,并且比增量更高效呢?
这就要说到并发回收了,它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起。

辅助线程在执行垃圾回收时,主线程可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点。
V8 的垃圾回收策略主要基于分代式垃圾回收机制。对于新生代垃圾回收器,使用并行回收能有效增加垃圾回收的效率。而老生代垃圾回收器则融合使用上面的策略。老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)。标记完成后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)。同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行。
虽然引擎有优化,但并非就能完全不关心垃圾回收了。我们的代码中依然要主动避免一些不利于引擎做垃圾回收的操作,因为不是所有无用对象内存都可以被回收。当不再用到的内存,没有及时回收时,就导致了内存泄漏。这是另外一个值得探讨的问题。