理解 NodeJS 的内存管理机制

1,260 阅读5分钟

v8 的内存限制

众所周知,Node 是基于 v8 引擎来构建的,所以在 Node 中使用的对象基本都是通过 v8 引擎来统一进行内存分配和管理。然而 v8 引擎 本身对内存的使用限制了大小,在64位系统下只能用 1.4GB 的系统内存。

这个大小足以应付日常的大部分应用程序需要,但为什么 v8 引擎 要限制内存使用的大小呢?

一方面是因为 v8 引擎最初是为浏览器而设计的。对于页面来说, 很少有需要长时间运行和使用大内存的场景,1.4GB 的内存限制是足够的。

更重要的原因是 v8 引擎的垃圾回收机制的性能所限制。以内存上限1.4GB 为例子,v8 引擎的垃圾回收机制对于这个数量级别的数据,做一次简单的垃圾回收就需要50ms以上,而在垃圾回收过程中,JavaScript 线程会完全暂停,等待垃圾回收结束。无论对于前端浏览器程序还是后端服务程序来说,这样的时间开销都是不可接受的。因此 v8 引擎限制内存堆的大小,更多是基于性能方面的考虑。

当然对于特殊场景,v8 引擎也是可以通过启动参数 --max-old-space-size 或者 --max-new-space-size 来调整内存限制的大小。这里的 old-space 和 new-space 就是下文要提到的老生代和新生代内存。

新生代和老生代

v8 引擎的垃圾回收策略主要是基于分代式垃圾回收机制。何为分代式呢?就是将内存中的对象按照存活时间长短大致分为两类:新生代老生代

v8 引擎的堆内存大小就是等于新生代所用内存大小 + 老生代所用内存大小。前面提到的 1.4GB 大小会分出 32MB 的内存大小给新生代空间,剩余的都分给老生代空间。

新生代和老生代的空间差距这么大,是因为新生代中的对象的生命周期都比较短,新生代对象存在一段时间后往往都会被晋升到老生代空间中去。在晋升机制的保障下,新生代空间一般不会存在太多对象,因此也不需要太多的预留空间。

新生代垃圾回收算法

在了解晋升机制前,我们还需要先了解新生代的回收机制。

新生代使用的垃圾回收算法名为 Scavenge 算法,该算法的核心思想是将堆内存平均分为两份空间,处于使用状态的称为 From 空间,处于闲置状态的称为 To 空间。

当我们创建对象时,会在From空间分配内存,当开始新生代垃圾回收时,会将From空间的存活对象全部复制到To空间,剩下的非存活对象则全部回收。完成复制后,From空间和To空间的身份互换。如此循环下去,就相当于把存活对象在两个空间之间进行复制。

Scavenge 算法的缺点就是只能利用新生代空间的一半内存来分配对象,这是一种典型的牺牲空间换取时间的做法。

在新生代空间中存活对象只占少部分(大部分长期存活对象都被晋升到老生代),由于 Scavenge 算法只复制少量的存活对象,因此它的回收占用时间非常短。

另外每次从From空间复制对象到To空间前会对对象进行晋升判断,新生代对象的晋升条件有两个:

  1. 该对象是否已经经历过 Scavenge 回收
  2. To空间已经使用了25%的内存

对象只要满足其中一个条件即可获得晋升。

老生代垃圾回收算法

对于老生代中的对象,由于存活对象占较大比重,因此不适合使用 Scavenge 算法。v8 引擎对于老生代空间使用了 Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

Mark-Sweep如名字所示,整个垃圾回收过程分为标记清除两个阶段。Mark-Sweep在标记阶段会遍历堆中的所有对象,并标记存活对象。然后在清除阶段中,只对没有标记的对象进行清除。前面提到老生代空间中死亡对象占较小部分,因此清除阶段所占用的时间也比较少。

Mark-Sweep存在的问题是只清除而不整理,这样会导致内存空间出现不连续的状态,这不利于分配内存给大对象。

为了解决这一问题,Mark-Compact出现了。他们的差别在于第二阶段,Mark-Compact是将活着的对象都往左侧移动,全部移动完成后再一次性清理掉边界右侧的无效内存空间。这样就可以得到完整连续的可用内存空间。

但Mark-Compact也不是十全十美的,它的缺点在于移动大量的存活对象会消耗比较多的时间。所以 v8 引擎在实际使用中会根据具体情况选择使用Mark-Sweep或者Mark-Compact。

三种算法对比:

停顿优化

由于程序在执行运算时会不断地生成新对象,因此垃圾回收进行时必须要将应用暂停下来。

在分代式垃圾回收机制下,新生代的垃圾回收过程比较快,因此可以直接将程序暂停下来进行回收,然后再恢复程序运行。

而对于老生代的垃圾回收来说,全程停顿造成的暂停时间较长,会影响程序的执行性能,因此 v8 引擎会标记阶段和清理阶段改为增量方式执行。也就是说将某个阶段拆分为几个小步骤,每执行完一步后就恢复程序执行,将垃圾回收工作穿插在程序执行的间隙中。