Node.js中的内存分配及垃圾回收

448 阅读7分钟

最近在读《深入浅出Node.js》,其中关于v8引擎垃圾回收的机制讲述的比较透彻有趣,特此记录一下。

Node在JS的执行上依赖V8,可以随着V8的升级就能享受到更好的性能或新的语言特性,同是也会受到V8的一些限制。

  • V8的内存限制

在Node中通过JS分配内存时会发现只能使用部分内存(64位系统下为1.4 GB,32位系统下为0.7 GB),在这样的限制下,Node无法操作大内存对象,比如将一个2GB的文件读到内存中进行字符串分析处理,即使物理内存有32GB。

造成这个问题的主要原因是node基于V8构建,所以在Node中使用的JS对象基本都是通过V8来进行分配的。V8这套内存管理机制在浏览器使用场景下绰绰由于,足以胜任前端页面中的所有需求,但是在服务端却可能会造成内存不足的问题。

  • V8的对象分配

在V8中所有JS对象都是通过堆来分配。当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中,如果申请的堆空闲内存不够分配新的对象,将继续申请堆内存,知道超过限制位置。

至于为何限制大小,表面上是因为最初V8位浏览器设计,不太可能用到大内存的场景;深层原因是V8的垃圾回收机制的限制,按照官方的说法,1.5GB的垃圾回收堆内存为例,V8做一次笑的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1s以上,这是垃圾回收中引起的JS线程暂停的时间,这样的时间开销下应用的响应能力是难以接受的。

当然这个限制也可以强行打开:

  • V8的垃圾回收机制

1. V8主要的垃圾回收算法

V8的垃圾回收主要基于分代 式垃圾回收机制,根据统计学的经验,现代的垃圾回收算法案对象的成活时间对讲内存的垃圾回收进行了分代,然后分别对不同分代的内存使用更加高效的算法。

V8主要讲内存分为新生代和老生代,新生代中的对象为存活时间较短的对象,老生代的对象为存活时间较长的对象:

V8堆整体空间大小就是这两部分之和。

2.  Scavenge算法

新生代的对象只要通过该算法回收,在Scavenge算法具体实现中主要采用了Cheney算法。

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

简而言之,在垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制。

Scaverge算法的只要缺点是只能使用一半内存空间。但是由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以他们在时间效率上有优异的表现。属于用空间换时间,故该算法很适合V8中的新生代内存回收中。

当一个对象经过多次复制后,他会被认定为较长生命周期的对象而转移至老生代中,采用新的算法来管理。这个过程称为_晋升_。

晋升的条件有两个,一个是对象经历过Scaverge回收,一个是To空间内存占用比例超过限制(25%)

3. Mark-Sweep & Mark-Compact(标记清除和标记整理)

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

Mark-Sweep是标记清楚的意思,它分为标记和清除两个阶段,在标记阶段会遍历堆中的所对象,并标记活着的对象,在随后的的清除阶段,只清除没有标记的对象。由于老生代中死对象占较小部分,所以这种做法相对高效。

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

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

在上图中可以看到,在Mark-Sweep与Mark-Compact之间,由于Mark-Compack需要移动

对象,所以他执行的速度不可能很快,所以在取舍上,V8主要是用Mark-Sweep,在空间不足以对新生代今生过来的对象进行分配时才使用Mark-Compact。

4. Incremental Marking

为了避免出现JS应用逻辑与垃圾回收看到的不一致的情况,垃圾回收的三种基本算法都需要将应用逻辑停下来,待执行完垃圾回收后在执行应用逻辑,这种行为被称为全停顿。在V8的分代式垃圾回收中,一次笑的垃圾回收纸收集新生代,由于新生代默认配置的较小,且存活数量不大,所以即使全停顿也不会有太大影响;但老生代通常配置的较大,且存活数量较多,全堆垃圾回收造成的停顿会比较可怕。

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

经过这样改善后,垃圾回收的最大停顿时间减少为原来的1/6左右。

V8后续还引入了延迟清理,增量式整理,让清理和整理动作也变成增量式的,同时还引入并行标记与并行清理,进一步利用多核性能降低停顿时间,这不部分有需要再了解。

说开头的话题,node并非不能操作超过V8限制大小的对象,node还可以使用V8所管理的内存之外的内存,这部分称为堆外内存,由node内部的C++实现内存申请。