Nodejs V8 的内存管理与垃圾回收机制

V8 的内存限制

在 Node 中通过 javascirpt 只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB),这个限制会导致 Node 无法直接操作大内存对象,计算机的内存资源无法得到充足的使用。

造成这个问题的主要原因在于 Node 基于 V8 构建,所以在 Node 中使用的 javascirpt 对象基本都是通过 V8 自己的方式进行分配和管理,V8 的这套内存管理机制在浏览器的应用场景下使用起来绰绰有余,但在 Node 中却限制了开发者随心所欲使用大内存的想法。还有一个深层原因是 V8 的垃圾回收机制的限制。官方说法,以1.5GB的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JS线程暂停执行的时间,在这样时间花销下,应用的性能和响应能力都会直线下降。

在 V8 中,所有的 JS 对象都是通过堆来进行分配的。通过 process.memoryUsage() 命令可查看 V8 内存使用量:

{
rss: 18702336,
heapTotal: 10295296, # 已申请到的堆内存
heapUsed:5409936 # 当前使用量
}

V8 依然提供了选项让我们使用更多的内存,Node 在启动时可以传递 --max-old-space-size--max-new-space-size 来调整内存限制的大小,启动之后就无法改变了。例如:

node --max-old-space-size=1700 app.js
node --max-new-space-size=1024 app.js

V8 的垃圾回收机制

在 V8 中,主要将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。

新生代内存回收机制

新生代的对象通过 Scavenge 算法进行垃圾回收,她将新生代的堆内存空间一分为二,每个空间称为 semispace,其中一个处于使用中( From 空间),另一个处于闲置状态( To 空间)。当我们分配对象时,先是从 From 空间进行分配,当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象会被复制到 To 空间,而非存活对象占用的空间将会释放,也就是释放 From 空间。完成复制后,From 空间和 To 空间角色互换。简单来说,就是通过将存活对象在两个 semispace 空间之间进行复制。

在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。对象晋升的条件有两个,一个是对象是否经历过 Scavenge 回收,一个是 To 空间的内存占用比超过限制。

  • 在对象从 From 空间复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收,如果已经经历过,会将该对象从 From 空间复制到老生代空间中,如果没有,则复制到 To 空间中。

  • 当从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过25%,则这个对象直接晋升到老生代空间中。因为当这次 Scavenge 回收完成后,这个 To 空间将变成 From 空间,接下来的内存分配将在这个空间中进行,如果占比过高,会影响后续的内存分配。

Scavenge 的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但 Scavenge 由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。由于 Scavenge 是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge 非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

老生代内存回收机制

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

  • Mark-Sweep

    Mark-Sweep 是标记清除的意思,它分为标记和清除两个阶段。与 Scavenge 复制活着的对象不同,Mark-Sweep 在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出 Scavenge 只复制活着的对象,Mark-Sweep 只清除死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。

  • Mark-Compack

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

    为了解决 Mark-Sweep 的内存碎片问题,Mark-Compack 被提出来了。Mark-Compack 是标记整理的问题,是在 Mark-Sweep 的基础上演变而来的,它们的差别在于对象在标记为死亡后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

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

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

增量标记

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

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

image

V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。V8 后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。

小结

从 V8 的自动垃圾回收机制的设计角度可以看到,V8 对内存使用进行限制的缘由。新生代设计为一个较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特别意义。V8对内存限制的设置对于 Chrome 浏览器这种每个选项卡页面使用一个 V8 实例而言,内存的使用是绰绰有余,对于 Node 编写的服务器端来说,内存限制也并不影响正常场景下的使用。但是对于V8的垃圾回收特点和 js 在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。