Node.js(四)-内存控制

再次阅读《深入浅出Node.js》的笔记系列,内容主要摘自书中

在node中使用内存时只能使用部分内存(64位为1.4GB,32位为0.7GB),造成这个问题的主要原因是Node基于V8构建,所以在Node中使用的javascript对象基本上是通过V8自己的方式来进行分配和管理的(Buffer对象不经过V8的内存分配限制)。
在V8中,所有的javascript对象都是通过堆来进行分配的。在Node启动时可以传递
–max-old-space-size / –max-new-space-size调整内存限制

v8的内存管理模式

一个运行的程序通常是通过在内存中分配一部分空间来表示的。这部分空间被称为驻留集(Resident Set)。 V8的内存管理模式有点类似于Java虚拟机(JVM),它会将内存进行分段:

  • 代码 Code:实际被执行的代码
  • 栈 Stack:包括所有的携带指针引用堆上对象的值类型(原始类型,例如整型和布尔),以及定义程序控制流的指针。
  • 堆 Heap:用于保存引用类型(包括对象、字符串和闭包)的内存段

v8的垃圾回收机制

垃圾回收器解决基本问题就是,识别需要回收的内存。一旦辨别完毕,这些内存区域即可在未来的分配中重用,或者是返还给操作系统。一个对象当它不是处于活跃状态的时候它就死了。一个对象处于活跃状态,当且仅当它被一个根对象或另一个活跃对象指向。根对象被定义为处于活跃状态,是浏览器或V8所引用的对象。比如说全局对象属于根对象,因为它们始终可被访问;浏览器对象,如DOM元素,也属于根对象,尽管在某些场合下它们只是弱引用。

堆的构成

在 node-v4.x 之后,堆主要由以下及部分构成:

  • 新生区:大多数对象开始时被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。
  • 老生区:保存原始数据对象,大多数从新生代晋升的对象会被移动到这里
  • 大对象区:这里存放体积超过1MB大小的对象。每个对象有自己的内存。垃圾回收器从不移动大对象
  • Map区
  • Code区:代码对象,也就是包含JIT之后指令的对象,会被分配到这里

识别指针和数据

垃圾回收器面临的第一个问题是,如何才能在堆中区分指针和数据,因为指针指向着活跃的对象。大多数垃圾回收算法会将对象在内存中挪动(以便减少内存碎片,使内存紧凑),因此即使不区分指针和数据,我们也常常需要对指针进行改写。
V8采用了标记指针法:这种方法需要在每个指针的末位预留一位来标记这个字代表的是指针或数据。

写屏障

如果新生区中某个对象,只有一个指向它的指针,而这个指针恰好是在老生区的对象当中,我们如何才能知道新生区中那个对象是活跃的呢? 为了解决这个问题,实际上在写缓冲区中有一个列表 store-buffer{.cc,.h,-inl.h},列表中记录了所有老生区对象指向新生区的情况。新对象诞生的时候,并不会有指向它的指针,而当有老生区中的对象出现指向新生区对象的指针时,我们便记录下来这样的跨区指向。由于这种记录行为总是发生在写操作时,它被称为写屏障.

回收策略

V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件时,会被移动到老生代(晋升)

1、V8的分代内存
默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。具体的计算公式是4*reserved_semispacespace + max_old_generationsize,新生代由两块reserved_semispacespace组成,每块16MB(64位)或8MB(32位)

2、新生代
大多数的对象被分配在这里,这个区域很小但是垃圾回收特别频繁。新生代使用半空间(Semi-space)分配策略,其中新对象最初分配在新生代的活跃半空间内。

  • Scavenge算法
    新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法

Cheney算法算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间,当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。
Scavenge算法(牺牲空间换取时间)的缺点是只能使用内存的一半,但它只复活存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它的时间效率比较高

执行过程:
a、将From空间中所有能从根对象到达的对象复制到To区(scanPtr和allocationPtr指针,分别指向即将扫描的活跃对象和即将为新对象分配内存的地方)
b、开始循环,查找当前scanPtr所指向的对象的内部指针指向,指向From区,就把这个所指向的对象从From区复制到To区
c、复制完成后将scanPtr所指对象内的指针修改为新复制对象存放的地址,并移动allocationPtr
d、下一轮循环,直到scanPtr和allocationPtr相遇,开始清理垃圾

3、对象晋升
具体移动的标准有两种:

  • 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中
  • 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代

4、老生代
V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合
Mark-Sweep:标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高
问题:内存碎片

Mark-Compact(标记整理):标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片

V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,如果空间不足以分配从新生代晋升过来的对象时,才使用标记整理

5、优化
Incremental Marking(增量标记)
拆分成很多部分,每做完一部分就停下来,让JS的应用逻辑执行一会,这样垃圾回收与应用逻辑交替完成。经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原来的1/6左右

思维导图

cache