跳到主要内容

V8 引擎的垃圾回收策略

一、学习垃圾回收之前

1.1 垃圾回收的基本概念

垃圾回收(Garbage Collection,GC) 中的 Garbage,也就是“垃圾”,具体指的是什么呢?

在现实世界中,说到垃圾,指的就是那些不读的书、不穿的衣服等。这种情况下的“垃圾”,指的是“自己不用的东西”。在 GC 中,“垃圾”的定义也是如此,GC 把程序不用的内存空间视为垃圾,而释放垃圾的过程称为垃圾回收

GC 的过程中涉及到两个阶段:

  1. 区分活对象(live object)垃圾对象(garbage)
  2. 回收垃圾对象的内存,使得程序可以重复使用这些内存;

1.2 为什么要进行垃圾回收

说起垃圾回收,就不得不提内存管理的概念,内存管理的流程有三个环节:

  1. 申请:分配你所需要的内存;
  2. 使用:对分配到的内存进行读、写;
  3. 释放:不需要时将其释放;

在没有 GC 的世界里,程序员必须自己手动进行内存管理,必须清楚地确保必要的内存空间,释放不要的内存空间。程序员在手动进行内存管理时,申请内存尚不存在什么问题,但在释放不要的内存空间时,就必须一个不漏地释放。这非常地麻烦。如果忘记释放内存空间,该内存空间就会 发生内存泄露,即无法被使用,但它又会持续存在下去。如果将发生内存泄露的程序放着不管,总有一刻内存会被占满,甚至还可能 导致系统崩溃

另外,在释放内存空间时,如果忘记初始化指向释放对象的内存空间的指针,这个指针就会一直指向释放完毕的内存空间。因为这个指针没有指向有效的内存空间,处于一种悬挂的状态,所以我们称其为 悬垂指针(dangling pointer)。如果在程序中错误地引用了悬垂指针,就会产生无法预期的 BUG 。此外,悬垂指针也会导致严重的安全漏洞。

为了省去上述手动内存管理的麻烦,人们开发出了 GC。如果把内存管理交给计算机,程序员就不用去想着释放内存了。在手动内存管理中,程序员要判断哪些是不用的内存空间(垃圾),留意内存空间的寿命。有了 GC 后,这一切都可以交给 GC 来做,程序员就不用再去担心因为忘了释放内存等而导致 BUG,从而大大减轻了负担。GC 能让程序员告别恼人的内存管理,把精力集中在更本质的编程工作上。

1.3 赋值器

赋值器(mutator)艾兹赫尔·韦伯·戴克斯特拉 (Edsger Wybe Dijkstra) 琢磨出来的词,用于更改 GC 对象间的引用关系,GC 就在赋值器内部精神饱满地工作着。

赋值器实际进行的操作有以下两种:

  • 生成对象
  • 更新指针

1.4 活动对象 / 非活动对象

我们将分配到内存空间中的对象中那些能通过赋值器引用的对象称为活动对象。反过来,把分配到堆中那些不能通过赋值器引用的对象称为 非活动对象。GC 会保留活动对象,销毁非活动对象。当销毁非活动对象时,其原本占据的内存空间会得到解放,供下一个要分配的新对象使用。

01

1.5 可达性

JavaScript 中主要的内存管理概念是 可达性(reachable) 。简而言之,可达值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。这里列出固有的可达值的基本集合:

  • 当前函数的局部变量和参数。
  • 嵌套调用时,当前调用链上所有函数的变量与参数。
  • 全局变量。
  • (以及一些内部的)

这些值被称作 根(roots) 。如果一个值可以通过引用或引用链从根访问任何其他值,则认为该值是可达的。比如说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则该对象被认为是可达的。而且它引用的内容也是可达的。如果一个值不可达,那这个值将会被释放。

1.6 V8

V8 是一个由 Google 开发的 开源JavaScript 引擎 ,用于 Google ChromeChromium 中。

V8 这个名字来源于汽车的“ V 型 8 缸发动机”(V8 发动机)。V 型 8 缸发动机主要是在美国发展起来的,因为其马力十足而广为人知。也就是说开发者想告诉我们,V8 是“强力且高速的 JavaScript 引擎”。

二、常用垃圾回收算法

在学习常用 GC 算法时,我们需要评价 GC 算法的优劣,主要参照以下标准:

  • 吞吐量:吞吐量(throughput)指的是“在单位时间内的处理能力”,堆空间大小 / GC 消耗的时间 = 吞吐量
  • 最大暂停时间:因执行 GC 而暂停执行赋值器的最长时间,很多 GC 算法都要求在清理的时候,不能再更改引用关系,不能再创建新的对象,以避免并发的问题。
  • 堆使用效率:GC 算法使用到的堆空间占总堆空间的比率。
  • 访问的局部性:GC 算法是否会将不连续的内存空间给整理成连续的。
    • 一般我们会把所有的数据都放在内存里,当 CPU 访问数据时,仅把要使用的数据从内存读取到缓存。与此同时,我们还将它附近的所有数据都读取到缓存中,从而压缩读取数据所需要的时间。具有引用关系的对象之间通常很可能存在连续访问的情况。这在多数程序中都很常见,称为“访问的局部性”。考虑到访问的局部性,把具有引用关系的对象安排在堆中较近的位置,就能提高在缓存中读取到想利用的数据的概率,令赋值器高速运行。 参考《计算机组成与设计:硬件、软件接口》

2.1 引用计数算法

GC 是一种释放怎么都无法被引用的对象的机制。那么人们自然而然地就会想到,可以让所有对象事先 记录下有多少程序引用自己。让各对象知道自己的“人气指数”,从而让没有人气的对象自己消失。

引用计数算法(Reference Counting GC)中引入了一个概念,那就是计数器 。计数器表示的是对象的人气指数,也就是有多少程序引用了这个对象(被引用数)。当对象的被引用次数变为零时就将其释放。

02

优点

  • 吞吐量较大:可以即刻回收垃圾,将内存管理开销分摊在程序运行过程中,垃圾被立即回收,因此可以持续操作即将填满的堆。

  • 最大暂停时间短:只有当通过赋值器更新指针时程序才会执行垃圾回收。也就是说,每次通过执行赋值器生成垃圾时这部分垃圾都会被回收,因而大幅度地削减了赋值器的最大暂停时间。

缺点

  • 无法回收循环引用的对象

    03

  • 时间开销大:计数器值的增减处理繁重,在引用计数法中,每当指针更新时,计数器的值都会随之更新,因此值的增减处理必然会变得繁重。

  • 空间开销大:计数器占用的位数多,用于引用计数的计数器最大必须能数完堆中所有对象的引用数。假如我们用的是 32 位机器,那么就有可能要让 2 的 32 次方个对象同时引用一个对象。考虑到这种情况,就有必要确保各对象的计数器有 32 位大小。也就是说,对于所有对象,必须留有 32 位的空间。这就害得内存空间的使用效率大大降低了。打比方说,假如对象只有 2 个域,那么其计数器就占了它整体的 1/3 。

2.2 标记 - 清除算法

标记 - 清除算法(Mark Sweep GC) 由标记阶段和清除阶段构成:

  • 标记阶段是把所有活动对象都做上标记的阶段。
    • 考虑到深度优先遍历比广度优先遍历内存使用量要小一些,在标记阶段常用深度优先遍历;
    • 标记阶段会标记所有的活动对象,由此可知,标记时间与活动对象的总数成正比
  • 清除阶段是把那些没有标记的对象,也就是非活动对象回收的阶段。
    • 在清除阶段,GC 会遍历整个堆,也就是说,清除时间与堆大小成正比

通过这两个阶段,就可以令不能利用的内存空间重新得到利用。

04

优点

  • 堆空间利用率较高:可以在整个堆中存放对象,堆使用效率较高;
  • 兼容保守式 GC 算法:保守式 GC 算法中,对象是不能被移动的。而 GC 标记 - 清除算法因为不会移动对象,所以非常适合搭配保守式 GC 算法。事实上,在很多采用保守式 GC 算法的处理程序中也用到了 GC 标记 - 清除算法。

缺点

  • 碎片化:经过多轮空间分配和垃圾回收后,堆从一整片完整的空间被划分为了很多不连续的碎片。空闲链表中的每个节点就都指向这样一个大小不一的分块。如下图所示:

    05

  • 分配速度受限:由于分块不是连续的,每次分配都必须遍历空闲链表,找到足够大的分块。最坏的情况下,每次进行分配都会要遍历整个空闲链表。

2.3 标记 - 整理算法

标记 - 整理算法(Mark Compact GC) 是将 标记 - 清除算法复制算法 相结合的产物,该算法由 标记阶段整理阶段 构成:

  • 标记阶段:与标记 - 清除算法的标记阶段一致;
  • 整理阶段:移动活动对象的位置,使活动对象聚集到堆的一端。
    • 整理阶段并不会改变对象的排列顺序,只是缩小了它们之间的空隙。

06

优点

  • 堆空间利用率较高:可以在整个堆中存放对象,堆使用效率较高;
  • 没有碎片化的问题:经过整理后的堆内存空间是连续的;

缺点

  • 吞吐量较差:整理过程需要多次整堆遍历,速度较慢,吞吐量较差;

2.4 复制算法

复制算法(Copying GC) 就是只把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。我们将复制活动对象的原空间称为 From 空间,将粘贴活动对象的新空间称为 To 空间。

07

GC 复制算法是利用 From 空间进行分配的。当 From 空间被完全占满时,GC 会将活动对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。From 空间和 To 空间大小必须一致。这是为了保证能把 From 空间中的所有活动对象都收纳到 To 空间里。

08

优点

  • 优秀的吞吐量:因为复制算法只搜索并复制活动对象,所以跟标记 - 清除算法相比,它能在较短时间内完成 GC。也就是说,其吞吐量优秀。尤其是堆越大,差距越明显。
  • 不会发生碎片化:复制算法每次运行 GC 时都会执行整理,因此不会发生碎片化。
  • 可实现高速分配:复制算法不使用空闲链表,因为分块是一个连续的内存空间,只要这个分块大小不小于所申请的大小,那么就可以进行分配了。比起 GC 标记 - 清除算法和引用计数法等使用空闲链表的分配,GC 复制算法明显快得多。

缺点

  • 堆使用效率低下:复制算法把堆二等分,通常只能利用其中的一半来安排对象。也就是说,只有一半堆能被使用。相比其他能使用整个堆的 GC 算法而言,可以说这是复制算法的一个重大的缺陷。通过搭配使用 标记 - 清除算法可以改善这个缺点。
  • 不兼容保守式 GC 算法:复制算法必须移动对象重写指针,所以有着跟保守式 GC 算法不相容的性质。

2.5 分代垃圾回收

分代垃圾回收(Generational GC) 在对象中引入了“年龄”的概念,通过优先回收容易成为垃圾的对象,提高垃圾回收的效率。

人们从众多程序案例中总结出了一个经验:“大部分的对象在生成后马上就变成了垃圾,很少有对象能活得很久。”分代垃圾回收利用该经验,在对象中导入了“年龄”的概念,经历过一次 GC 后活下来的对象年龄为 1 岁。

09

分代垃圾回收中把对象分类成几代,针对不同的代使用不同的 GC 算法,我们把刚生成的对象称为 新生代对象(new generation),到达一定年龄的对象则称为 老年代对象(old generation)

我们将对新对象执行的 GC 称为 新生代 GC(minor GC) ,新生代 GC 的前提是大部分新生代对象都没存活下来,GC 在短时间内就结束了。

新生代 GC 将存活了一定次数的新生代对象当作老年代对象来处理。我们把类似于这样的新生代对象提升为老年代对象的情况称为 晋升(promotion)

因为老年代对象很难成为垃圾,所以我们对老年代对象减少执行 GC 的频率。相对于新生代 GC,我们将面向老年代对象的 GC 称为老年代 GC(major GC)

优点

  • 吞吐量得到改善:“很多对象年纪轻轻就会死”这一法则虽然是经验之谈,不过还是适用于大多数情况的。以这个法则为前提,新生代 GC 只将刚生成的对象当成对象,这样一来就能减少时间上的消耗。反过来,因为老年代 GC 是针对很难变成垃圾的老年代对象执行的,所以要比新生代 GC 花的时间长。

缺点

  • 在部分程序中会起到反作用:“很多对象年纪轻轻就会死”这个法则毕竟只适合大多数情况,并不适用于所有程序。当然,对象会活得很久的程序也有很多。对这样的程序执行分代垃圾回收,就会产生以下两个问题:
    • 新生代 GC 所花费的时间增多
    • 老年代 GC 频繁运行

当“很多对象年纪轻轻就会死”这个法则不成立时,分代垃圾回收就没有什么作用,或者说反而可能会起到反作用。这种情况下我们还是使用基本算法更好。

2.6 增量式垃圾回收

增量式垃圾回收(Incremental GC) 是一种通过逐渐推进垃圾回收来控制赋值器最大暂停时间的方法。

通常的 GC 处理很繁重,一旦 GC 开始执行,不过多久赋值器就没法执行了,这是常有的事。也就是说,GC 本来是从事幕后工作的,可是它却一下子嚣张起来,害得赋值器这个主角都没法发挥作用了。我们将像这样的在执行时害得赋值器完全停止运行的 GC 叫作停止型 GC 。停止型 GC 的示意图如下:

10

根据应用程序(mutator)的用途不同,有时停止型 GC 是很要命的。因此人们想出了增量式垃圾回收这种方法。增量(incremental)这个词有“慢慢发生变化”的意思。就如它的名字一样,增量式垃圾回收是将 GC 和赋值器一点点交替运行的手法。增量式垃圾回收的示意图如下:

11

描述增量式垃圾回收的算法时我们有个方便的概念,那就是 Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking)。顾名思义,这个算法就是将 GC 中的对象按照各自的情况分成三种:

  • 白色:还未搜索过的对象
  • 灰色:正在搜索的对象
  • 黑色:搜索完成的对象

GC 开始运行前所有的对象都是白色。GC 一开始运行,所有从根能到达的对象都会被标记,然后被堆到栈里。GC 只是发现了这样的对象,但还没有搜索完它们,所以这些对象就成了灰色对象。

灰色对象会被依次从栈中取出,其子对象也会被涂成灰色。当其所有的子对象都被涂成灰色时,对象就会被涂成黑色。

当 GC 结束时已经不存在灰色对象了,活动对象全部为黑色,垃圾则为白色。

三色标记算法能应用于所有搜索型 GC 算法,以将 GC 标记 - 清除算法增量式运行 为例,增量式的 GC 标记 - 清除算法可分为以下三个阶段:

  • 根查找阶段
  • 标记阶段
  • 清除阶段

我们在根查找阶段把能直接从根引用的对象涂成灰色。在标记阶段查找灰色对象,将其子对象也涂成灰色,查找结束后将灰色对象涂成黑色。在清除阶段则查找堆,将白色对象连接到空闲链表,将黑色对象变回白色。

优点

  • 缩短最大暂停时间:增量式垃圾回收不是一口气运行 GC,而是和赋值器交替运行的,因此不会长时间妨碍

    到赋值器的运行。增量式垃圾回收适合那些比起提高吞吐量,更重视缩短最大暂停时间的应用程序。

缺点

  • 降低了吞吐量:在复制算法部分提到过,缩短 GC 时间可以提高吞吐量,但在增量式 GC 里缩短的是最大暂停时间,所以就会牺牲一定的吞吐量。

想要优先提高吞吐量,最大暂停时间就会增加;想要优先缩短最大暂停时间,吞吐量就会恶化。这两者是一个权衡关系。至于要优先哪一方,则要根据应用程序而定。

三、V8 的垃圾回收策略

V8 实现了准确式 GC,GC 算法方面采用了分代垃圾回收。分代垃圾回收的结构如下表所示。

名称
新生代 GCGC 复制算法、GC 标记 - 压缩算法
老年代 GCGC 标记 - 清除算法、GC 标记 - 压缩算法

V8 将堆空间分配如下图所示:

12

V8 将空间一分为二,默认情况下:

  • 32 位系统:新生代内存大小为 16MB,老生代内存大小为 700MB ;
  • 64 位系统:新生代内存大小为 32MB,老生代内存大小为 1.4GB ;

3.1 如何回收新生代对象

新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。

鱼鱼新生代对象生存时间比较短,大多数都是要回收的对象,采用标记 - 清除算法则内存空间碎片化严重,采用复制算法可以灵活高效,且便与整理空间。

V8 中新生代对象回收过程使用 GC 复制算法GC 标记 - 整理算法 ,详细过程如下:

  1. 将新生代存储区分为两个等大小的空间,使用空间为 From,空闲空间为 To ;
  2. 活动对象存储于 From 空间,经过标记整理后将活动对象拷贝至 To 空间;
  3. From 空间和 To 空间交换完成释放;

在把活动对象从 From 空间拷贝至 To 空间时,出现以下情况活动对象将被晋升到老生代存储区:

  1. 经过一轮 GC 仍然存活的对象,会被晋升到老生代存储区;
  2. 当 To 空间使用率超过 25% 时,该对象直接晋升到老生代中;设置 25% 这个阈值的原因是当这次回收完成后,这个 To 空间会变为 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

3.2 如何回收老年代对象

在老生代中,存活对象占较大比重,如果继续采用 GC 复制算法GC 标记 - 整理算法 算法进行管理,就会存在两个问题:

  1. 效率低下:由于存活对象较多,复制存活的对象的效率会变的很低;
  2. 堆利用率低下:复制算法把存储区一分为二,利用率直接降低了一半,并且老生代所占堆内存远大于新生代,所以浪费会很严重;

所以在老年代存储区主要使用的 GC 标记 - 清除GC 标记 - 整理增量标记 算法,相对于新生代 GC 来说堆的利用率更高。垃圾回收过程如下:

  1. 首先,使用 标记 - 清除算法,完成垃圾空间的回收,此时可能会有内存空间碎片化的问题;
  2. 当从新生代存储区将对象晋升到老生代存储区时,再使用 标记 - 整理算法 对内存空间进行优化;

当垃圾回收工作的时候,是会阻塞 JavaScript 执行的,使用 增量标记 算法将垃圾回收操作拆成多段与赋值器交替执行,从而降低阻塞的时间。

13

3.3 小结

新生代存储区 对象生存周期短回收非活动对象 带来的 时间消耗较大 ,使用 GC 复制算法可以减少清除垃圾对象所带来的时间消耗,并且由于新生代存储区空间本来就很小,浪费掉的一半空间相对于它所带来的时间上的提升是微不足道的。

老生代存储区 对象生存周期长 ,并且老生代 存储区域比较大 ,垃圾回收 不适合复制算法 ,空间大复制所产生的时间消耗也更大。由于大多数对是活动对象,所以 回收非活动对象 带来的 时间消耗较小 。更适合追踪式的算法。

四、学习垃圾回收能解决什么问题

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。

解除引用

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null ,从而释放其引用。这也可以叫作解除引用。

通过 constlet 声明提升性能

ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。

内存泄漏

写得不好的 JavaScript 可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。

例 1:意外声明的全局变量

function setName() {
name = 'Jake';
}

例 2:闭包引用外部变量

let name = 'Jake';
setInterval(() => {
console.log(name);
}, 100);
let outer = function () {
let name = 'Jake';
return function () {
return name;
};
};

以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。