V8 的垃圾回收机制

前言

我们知道使用 JavaScript 进行编程时,内存是有垃圾回收机制进行自动管理的,开发者不用像 C/C++ 程序员那样在编写代码时要关注内存的分配和释放。这在短时间执行的场景比如网页应用是能够基本应付的,这些场景执行时间短,随着进程退出,内存就会被释放。

但随着 JavaScript 的使用场景延伸到 Node 服务端,这种长时间使用的场景就对内存管理的提出了更高的要求,内存管理的好坏,对服务端的影响重大。对于Node,其内存管理主要与 JavaScript 执行引擎 V8 息息相关。

V8 的内存分代

在 V8 中,堆内存被分为新生代和老生代两代。新生代中存储的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。

img-1

V8 堆内存空间总大小就是新生代内存空间加老生代内存空间。在默认配置下,新生代内存最大值在64位系统和32位系统上分别为 32 MB 和 16 MB,老生代的内存最大值在64位系统和32位系统上分别为 1400 MB 和 700 MB。

这新生代和老生代的最大内存空间限制可以在 Node 启动时通过 max-new-space-sizemax-old-space-size 参数进行调整,比如:

node --max-old-space-size=2048 test.js // 单位为MB

V8 主要垃圾回收算法

Scavenge 算法

新生代中的对象主要通过 Scavenge 算法进行垃圾回收,在 Scavenge 的具体实现中,主要采用了 Cheney 算法,该算法由 C. J. Cheney 于1970年首次发表在 ACM 论文上。

Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。

img-2

对象晋升

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。

对象晋升的条件主要有两个,一个是对象是否经历过 Scavenge 回收,一个是 To 空间的内存占用比超过限制。

在默认情况下,V8 的对象分配主要集中在 From 空间中。对象从 From 空间中复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收。如果已经经历过了,会将该对象从 From 空间复制到老生代空间中,如果没有,则复制到 To 空间中。这个晋升流程如图:

img-3

另一个判断条件是 To 空间的内存占用比。当要从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过25%,则这个对象直接晋升到老生代空间中,这个晋升的判断示意图如图:

img-4

设置25%这个限制值的原因是当这次 Scavenge 回收完成后,这个 To 空间将变成 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。

Mark-Sweep & Mark-Compact 算法

对于老生代中的对象,由于存活对象占较大比重,再采用 Scavenge 的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个是浪费一半空间的问题。为此,V8 在老生代中主要采用 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。

Mark-Sweep 是标记清除的意思,它分为标记和清除两个阶段。与 Scavenge 相比,Mark-Sweep 并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与 Scavenge 复制活着的对象不同,Mark-Sweep 在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。下图为 Mark-Sweep 在老生代空间中标记的示意图,黑色部分标记为死亡对象:

img-5

Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 被提出来。Mark-Compact 是标记整理的意思,是在 Mark-Sweep 的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。下图为 Mark-Compact 完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。

img-6

img-7

这里将 Mark-Sweep 和 Mark-Compact 结合着介绍不仅仅是因为这两种策略是递进关系,在 V8 的回收策略中两者是结合使用的。下表是目前介绍的3种主要垃圾回收算法的简单对比:

回收算法Mark-SweepMark-CompactScavenge
速度中等最慢最快
空间开销少(有碎片)少(无碎片)双倍空间(无碎片)
是否移动对象

从上表可以看到,Mark-Sweep 和 Mark-Compact 之间,由于 Mark-Compact 需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用 Mark-Compact。

Incremental Marking

为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但 V8 的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。

为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。

V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。

V8 后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。

参考资料

  • 深入浅出Node.js(朴灵)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值