Node的垃圾回收机制与内存溢出捕获(上)

1,251 阅读14分钟

Node的垃圾回收机制与内存溢出捕获

一、什么是Node的内存?

  想必大家在用JavaScript开发的过程中,不太关心内存的管理,因为对于前端来说,浏览器的内存几乎不会出现用完的情况,因为所接触的是那些短时间执行的场景,比如网页的应用、命令工具等。这类场景由于是运行短时间,且运行在用户的机器上,即使内存被消耗过多或者内存发生了泄漏,已只会影响到终端用户,并不会大面积的扩散。因此运行时间短,随着进程的退出,内存会自动释放,几乎没有内存管理的必要。

1.Node的内存需要管理吗?

  答案是必须的。为啥呢?

  因为Node作为后端服务,操作复杂,并且长期运行在服务器端不重启。如果不关注内存管理,将会导致内存泄漏,就算是1TB,也会很快会被耗尽。

2.Node的内存究竟是什么样的呢?

2.1 Node是在什么环境下运行的呢?

   回溯历史可以发现,Node在发展的历程中离不开Chrome V8 (ps:下面会提到什么是V8),所以在官方的主页大家可以看到Node是一个构建在Chrome的 JavaScript运行上的平台(++Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.++)。换句话说,其实Node.js就是一个由JavaScript V8引擎控制的C++程序。

   Google V8是一个由Google开发的JavaScript引擎,但它也可以脱离浏览器被单独使用。 这使得它能够完美的契合Node.js,实际上V8也是Node.js平台中唯一能够理解JavaScript的部分。 V8会将JavaScript代码向下编译为本地代码(native code),然后执行它。在执行期间,V8会按需进行内存的分配和释放。 这意味着,如果我们在谈论Node.js的内存管理问题,也就是在说V8的内存管理问题。

2.2 V8的内存管理模式
2.2.1 V8的内存设计

   一个运行的程序通常是通过在内存中分配一部分空间来表示的。这部分空间被称为常驻内存(Resident Set)。

   V8的内存管理模式有点类似于Java虚拟机(JVM),它会将内存进行分段:

  • 代码区(Code Segment):存放即将执行的代码片段
  • 栈 Stack:包括所有的携带指针引用堆上对象的值类型(原始类型,例如整型和布尔),以及定义程序控制流的指针。
  • 堆 Heap:用于保存引用类型(包括对象、字符串和闭包)的内存段。
  • 堆外内存:不通过V8分配,也不受V8管理。Buffer对象的数据就存放于此。

1214547-f76a4eba8d3b0487
dce0143d9ff120114b87c63df9066ed5

2.2.2 V8内存模型

   除堆外内存,其余部分均由V8管理。

  • 栈(Stack)的分配与回收非常直接,当程序离开某作用域后,其栈指针下移(回退),整个作用域的局部变量都会出栈,内存收回。
  • 最复杂的部分是堆(Heap)的管理,V8使用垃圾回收机制进行堆的内存管理,也是开发中可能造成内存泄漏的部分,是我们需要关注的重点。

在Node.js中,当前的内存使用情况可以轻松的使用process.memoryUsage()进行查询, 实例程序如下:

$ node
$ process.memoryUsage()

这是公司内部的一个项目的Node进程的内存使用状况:

image

  • rss是Resident Set Size的缩写,为常驻内存的总大小(单位:bytes),大约21M。

  • heapTotal是V8为堆分配的总大小(单位:bytes),大约9.23M。

  • heapUsed是已使用的堆大小(单位:bytes),大约5.29M。

可以看到,rss是大于heapTotal的,因为rss包括且不限于堆。

  • external是堆外内存大小(单位:bytes),0.0085M。
    image

当我们在代码中声明变量并赋值的时候,所使用对象的内存就分配在堆中。如果已申请的堆空间内存不够分配新的对象,将继续申请内存,直到堆的大小超过V8的限制为止。

2.2.3 V8内存限制

   V8内存为何要限制大小呢?V8不就是为了浏览器设计的么,浏览器中不太可能遇到太大的内存场景,对于一般正常浏览网页来说,停留的时间不会太长,也不太会进行很多复杂的工作,照理说V8内存的限制已经绰绰有余了。但是遇到大内存的时候,比如读取大的文件进内存,那要怎么办呢?

   其实引起V8内存限制的深层次原因是其垃圾回收机制的限制。举个栗子,官方说法是,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的花销下,应用性能和相应时间能力都会直线下降。这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。因此,是时候需要考虑一下是否改变内存的阀值了。

   在启动node进程的时候,可以调整内存大小。

node --max-old-space-size=1700 test.js // 单位为MB
node --max-new-space-size=1024 test.js // 单位为KB

   上述参数在初始化进程的时候就生效,一旦生效就不能动态扩容,一般用来扩充内存,以免稍微多一些内存就崩溃。

2.2.4 V8的内存分代

   V8垃圾回收策略主要基于分代垃圾回收机制。在实际应用过程中发现,对象的生存周期长短不一,因此只能按照对象的存活时间将内存的垃圾回收进行不同的分代。

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

image
V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。就是上面所提到的用--max-old-space-size来设置老生代内存空间的最大值,--max-new-space-size来设置新生代内存空间的最大值。

   v8源码中,我们可以看到这个说明,在代码Page::kPageSize=1下:

// semispace_size_ should be a power of 2 and old_generation_size_ should be
// a multiple of Page::kPageSize
#if defined(V8_TARGET_ARCH_X64)
#define LUMP_OF_MEMORY(2 * MB)
    code_range_size_(512 * MB),
#else
#define LUMP_OF_MEMORY MB
    code_range_size_(0),
#endif
#if defined(ANDROID)
    reserved_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    max_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    initial_semispace_size_(Page:: kPageSize),
    max_old_generation_size_(192 * MB),
    max_executable_size_(max_old_generation_size_),
#else
    reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    max_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    initial_semispace_size_(Page:: kPageSize),
    max_old_generation_size_(700ul * LUMP_OF_MEMORY),
    max_executable_size_(256l * LUMP_OF_MEMORY),
#endif

   依照上面的代码,我们可以看到如果V8标记是64位系统的需要*2,32位的不需要。

   对于新生代来说,它是由两个reserved_semispace_size_所构成的,这个后面会讲到。单个reserved_semispace_size_在32位上reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),由于Page:: kPageSize为1,所以为8MB,推算出64位的为16MB。因此,新生代内存的最大值在64位和32位上分别是32MB和16MB。

   对于老生代来说,max_old_generation_size_(700ul * LUMP_OF_MEMORY),32位为700MB,推算出64位的为1400MB。

   那堆内存的最大值是多少呢?    v8堆内存的最大保留空间可以从这个代码中看出,其公式为:

// Returns the maximum amount of memory reserved for the heap. For
// the young generation, we reserve 4 times the amount needed for a
// semi space. The young generation consists of two semi spaces and
// we reserve twice the amount needed for those in order to ensure
// that new space can be aligned to its size
intptr_t MaxReserved() {
    return 4 * reserved_semispace_size_ + max_old_generation_size_;
}

   因此,在默认配置下V8堆内存最大值:

  • 32位:4*8+700=732MB;
  • 64位:4*16+1400=1464MB

01uncx3JkBTi
微信截图_20190110004041
微信截图_20190110004200

2.2.5 V8内存算法

   在上面的提到了在内存分配的时候分为新生代和老生代。那新老生代之间有什么区别?如何分配的呢?

   接下来我们先讲新生代的那些事儿:

2.2.5.1 新生代(Scavenge算法)

   新生代主要是存放存活时间较短的对象,这些对象主要是用Scavenge算法进行垃圾回收,在Scavenge的具体 实现中,主要采用了Cheney算法。

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

   Scavenge算法只能使用堆内存的一半。但是由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它具有极高的时间效率。相当于可以理解为牺牲空间换取时间的算法。

  其实From空间和To空间进行角色交换的时候是需要进行判断检查的,在一定条件下,需要将存活周期长的对象移动到老生代中,完成对象的晋升。

  对象晋升的主要条件有两个:

  • 对象是否经历过Scavenge回收。
  • To空间是否超过25%的限制。

下图是判断流程:

微信截图_20190110004123

2.2.5.2 老生代(Mark-Sweep & Mark-Compact)

   由于在老生代中存放对象占较大比重,若再继续使用新生代的Scavenge算法会产生两个问题:

  • 由于存活对象比较多,复制存活对象的效率将会降低。
  • 浪费一半的空间。

   因此在老生代中采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾的回收。

  • Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。Mark-Sweep 在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。
  • Mark-Compact是对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。这是由于Mark-Sweep 在进行一次标记清除回收后,内存空间会出现不连续的状态引起的,因为这种内存碎片会对后续的内存分配造成问题,很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

微信截图_20190110004139

   接下来我们看看3种垃圾回收算法的简单对比:

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

   V8主要使用Mark-Sweep,在空间不足的情况下对从新生代中晋升过来的对象进行分配才使用Mark-Compact。

2.2.5.3 增量标记(Incremental Marking)

   在执行上述三种算法的时候,垃圾回收机制会先把应用逻辑暂停下来,待执行垃圾回收完后再恢复执行应用逻辑。“停顿”现在新老生代中都会发生,新生代由于存活对象时间短,全停顿对全局影响不大,但是在老生代中配置较大,且存活对象较多,全停顿的话影响比较大,因此需要改善。

   这时候就需要引入“增量标记”的方式,也就是拆分为许多小的“进步”,每做完一“进步”就让JavaScript应用逻辑执行一会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

   例如:一次执行标记可能需要几百毫秒才能完成一个大的堆。

2ea90e4181e1c262671ec21bfeea5275deb

  在增量标记期间,垃圾收集器将标记工作分解为更小的块,并且允许应用程序在块之间运行:

a26e1c69e9a6eaba679233085b4e37cfb6d
  垃圾收集器选择在每个块中执行多少增量标记来匹配应用程序的分配速率。一般情况下,这极大地提高了应用程序的相应速度。对内存压力较大的堆,收集器仍然可能出现长时间的暂停来维持分配。

  总的来说,V8经过增量标记后的,垃圾回收机制最大停顿时间可以减少到原本的1/6左右。同时还引入了延迟清理和增量式整理,让清理与整理也变成增量式。

2.2.5.4 并行标记

  并行标记发生在主线程和工作线程上。应用程序在整个并行标记阶段暂停。它是 stop-the-world 标记的多线程版本。

986cccc9bb8f4d242ef854d1241fc444eaa

  并发标记主要发生在工作线程上。当并发标记正在进行时,应用程序可以继续运行。

112cb5dab6fd6a59317d54a86f6a2351a9d

  在并行标记的时候,我们可以假定应用都不会同时运行。这大大的简化了实现,是因为我们可以假定对象图是静态的,而且不会改变。为了并行标记对象图,我们需要让垃圾收集数据结构的线程是安全的,而且寻找一个可以在线程间运行的高效共享标记的方法。下面的示意图展示了并行标记包含的数据结构。箭头代表数据流的方向。简单来说,示意图省略了堆碎片处理所需的数据结构。

77712bc8a0a04b591b4c35b34f7edb0d864
  注意,这些线程只能读取对象图,而不能修改它。对象的标记位和标记列表必须支持读写访问。

2.2.5.4 并发标记

  并发标记允许 JavaScript 在主线程上运行,而工作线程正在访问堆上的对象。这为潜在的竞态数据打开大门。举个例子:当工作者线程正在读取字段时,JavaScript 可能正在写入对象字段。竞态数据会混淆垃圾回收器释放活动对象或者将原始值和指针混合在一起。

主线程的每个改变对象图表的操作将会是竞态数据的潜在来源。由于 V8 是具有多种对象布局优化功能的高性能引擎,潜在竞态数据来源目录相当长。以下是高层次故障:

  • 对象分配

  • 写对象

  • 对象布局变化

  • 快照反序列化

  • 功能脱优化实现

  • 年轻代垃圾回收期间的疏散

  • 代码修补

  在以上这些操作上,主线程需要与工作线程同步。同步代价和复杂度是操作而定。大部分操作允许轻量级的同步和院子操作之间的访问,但是少部分操作需独占访问对象。

  总的来说,并发标记就是为解决数据竞争的问题。

image
  有了平行标记与并发标记后,对比上面讲的流程,GC的流程变为: 从root对象开始扫描,填充对象到marking worklist 分布并发标记任务到worker threads worker threads帮助main thread去更快地消费marking worklist中的对象 main thread 偶尔会通过执行bailout worklist 和 marking worklist来marking 一旦marking worklists为空,main thread 就完成GC行为 在结束之前,main thread重新扫描roots,可能会发现其他的白色节点,这些白色节点会在worker threads的帮助下,被平行标记。

课外学习

image
01uncx4kNcCX

《深入简出nodeJS》很不错哦~