Node - 内存管理和垃圾回收

2,879 阅读16分钟

前言

从前端思维转变到后端, 有一个很重要的点就是内存管理。以前写前端因为只是在浏览器上运行, 所以对于内存管理一般不怎么需要上心, 但是在服务器端, 则需要斤斤计较内存。

V8的内存限制和垃圾回收机制

内存限制

内存限制 一般的后端语言开发中, 在基本的内存使用是没有限制的。 但由于Node是基于V8构建的, 而V8对于内存的使用有一定的限制。 在默认情况下, 64位的机器大概可以使用1.4G, 而32则为0.7G的大小。关于为什么要限制内存大小, 有两个方面。一个是V8一开始是为浏览器服务的, 而在浏览器端这样的内存大小是绰绰有余的。另一个则是待会提到的垃圾回收机制, 垃圾回收会暂停Js的运行, 如果内存过大, 就会导致垃圾回收的时间变长, 从而导致Js暂停的时间过长

当然, 我们可以在启动Node服务的时候, 手动设置内存的大小 如下:

node --max-old-space-size=768 // 设置老生代, 单位为MB  
node --max-semi-space-size=64 // 设置新生代, 单位为MB

查看内存
在Node环境中, 可以通过process.memoryUsage()来查看内存分配

rss(resident set size):所有内存占用,包括指令区和堆栈

heapTotal:V8引擎可以分配的最大堆内存,包含下面的 heapUsed

heapUsed:V8引擎已经分配使用的堆内存

external: V8管理C++对象绑定到JavaScript对象上的内存

事实上, 对于大文件的操作通常会使用Buffer, 究其原因就是因为Node中内存小的原因, 而使用Buffer是不受这个限制, 它是堆外内存, 也就是上面提到的external

v8的内存分代

目前没有一种垃圾自动回收算法适用于所有场景, 所以v8的内部采用的其实是两种垃圾回收算法。他们回收的对象分别是生存周期较短和生存周期较长的两种对象。关于具体的算法, 参考下文。 这里先介绍v8是怎么做内存分代的。

新生代
v8中的新生代主要存放的是生存周期较短的对象, 它具有两个空间semispace, 分别为From和To, 在分配内存的时候将内存分配给From空间, 当垃圾回收的时候, 会检查From空间存活的对象(广度优先算法)并复制到To空间, 然后清空From空间, 再互相交换From和To空间的位置, 使得To空间变为From空间

该算法缺陷很明显就是有一半的空间一直闲置着并且需要复制对象, 但是由于新生代本身具有的内存比较小加上其分配的对象都是生存周期比较短的对象, 所以浪费的空间以及复制使用的开销会比较小。

在64位系统中一个semisapce为16MB, 而32位则为8MB, 所以新生代内存大小分别为32MB和16MB

老生代
老生代主要存放的是生存周期比较长的对象。内存按照1MB分页,并且都按照1MB对齐。新生代的内存页是连续的,而老生代的内存页是分散的,以链表的形式串联起来。 它的内部有4种类型。

Old Space
Old Space 保存的是老生代里的普通对象(在 V8 中指的是 Old Object Space,与保存对象结构的 Map Space 和保存编译出的代码的 Code Space 相对),这些对象大部分是从新生代(即 New Space)晋升而来。

Large Object Space
当 V8 需要分配一个 1MB 的页(减去 header)无法直接容纳的对象时,就会直接在 Large Object Space 而不是 New Space 分配。在垃圾回收时,Large Object Space 里的对象不会被移动或者复制(因为成本太高)。Large Object Space 属于老生代,使用 Mark-Sweep-Compact 回收内存。

Map Space
所有在堆上分配的对象都带有指向它的“隐藏类”的指针,这些“隐藏类”是 V8 根据运行时的状态记录下的对象布局结构,用于快速访问对象成员,而这些“隐藏类”(Map)就保存在 Map Space。

Code Space
编译器针对运行平台架构编译出的机器码(存储在可执行内存中)本身也是数据,连同一些其它的元数据(比如由哪个编译器编译,源代码的位置等),放置在 Code Space 中。

关于Map Space和Code Space推荐大家看这两篇文章, 因为和本文关系不大, 所以不在这里赘述。 文章1文章2

v8的内存分配如下图, 图出处:

V8的垃圾回收机制

新生代
新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。关于算法的实现在上面中已经大致说明了, 但新生代的对象是怎么晋升到老生代里面呢?

在默认情况下,V8的对象分配主要集中在From空间中。对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。这个晋升流程如下图所示

另一个判断条件是To空间的内存占用比。当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中,这个晋升的判断示意图如下图所示。

写屏障
关于新生代扫描的问题, 由于我们想回收的是新生代的对象, 那么只需检查指向新生代的引用, 那么在跟随根对象->新生代或者新生代->新生代的引用时, 那么扫描会很快。 但是还可能出现的一种情况是老生代指向了新生代或者指向了根对象, 如果选择跟随, 扫描整个堆, 就会花费太多时间。

对于这个问题,V8 选择的解决方案是使用写屏障(write barrier),即每次往一个对象写入一个指针(添加引用)的时候,都执行一段代码,这段代码会检查这个被写入的指针是否是由老生代对象指向新生代对象的,这样我们就能明确地记录下所有从老生代指向新生代的指针了。这个用于记录的数据结构叫做store buffer,每个堆维护一个,为了防止它无限增长下去,会定期地进行清理、去重和更新。这样,我们可以通过扫描,得知根对象->新生代和新生代->新生代的引用,通过检查 store buffer,得知老生代->新生代的引用,就没有漏网之鱼,可以安心地对新生代进行回收了。

新生代GC图:

老生代
老生代在64位和32位下具有的内存分别是1400MB和700MB, 如果还使用新生代的Scavenge算法, 不止浪费一半空间, 还需要复制大块内存。所以, V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合。

Mark-Sweep(标记清除)
标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

图中黑色部分为标记的死亡对象

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

由于标记整理需要移动对象, 所以它的速度相对较慢。 V8在主要使用标记清除算法, 在空间不足以分配新生代晋升的对象时才使用标记整理算法。

白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞

关于标记的具体算法, 如果将对中的对象看做由指针做边的有向图,标记算法的核心就是深度优先搜索。
V8使用每个对象的两个mark-bits和一个标记工作栈来实现标记,两个mark-bits编码三种颜色:白色(00),灰色(10)和黑色(11)。

  • 白色: 表示对象可以回收
  • 黑色: 表示对象不可以回收,并且他的所有引用都被便利完毕了
  • 灰色: 表示对象不可回收,他的引用对象没有扫描完毕。

当老生代GC启动时, V8会扫描老生代的对象, 并对其进行标记。 大致的流程如下:

  1. 将所有非根对象标记为白色。
  2. 将根的所有直接引用对象入栈,并标记为灰色(marking worklist)
  3. 从这些对象开始做深度优先搜索,每访问一个对象,就将它 pop 出来,标记为黑色,然后将它引用的所有白色对象标记为灰色,push 到栈上
  4. 栈空的时候,回收白色的对象

但这里需要留意一下, 当对象太大无法 push 进空间有限的栈的时候,V8 会先把这个对象保留灰色放弃掉,然后将整个栈标记为溢出状态(overflowed)。在溢出状态下,V8 会继续从栈上 pop 对象,标记为黑色,再将引用的白色对象标记为灰色和溢出,但不会将这些灰色的对象 push 到栈上去。这样没多久,栈上的所有对象都被标黑清空了。此时 V8 开始遍历整个堆,把那些同时标记为灰色和溢出对象按照老方法标记完。由于溢出后需要额外扫描一遍堆(如果发生多次溢出还可能扫描多遍),当程序创建了太多大对象的时候,就会显著影响 GC 的效率。 引用自文章
增量标记与惰性清理
事实上, v8为了降低全堆垃圾回收带来的停顿时间, 使用了增量标记和惰性清理两种方式。

增量标记
将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

因为增量标记的过程中, 很有可能被标记为白色的对象又被重新引用, 所以需要一个写屏障(write-barrier)来实现通知。

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

下图为增量标记示意图。

惰性清理
所有的对象已被处理,因此非死即活,堆上多少空间可以变为空闲已经成为定局。此时我们可以不急着释放那些空间,而将清理的过程延迟一下也并无大碍。因此无需一次清理所有的页,垃圾回收器会视需要逐一进行清理,直到所有的页都清理完毕。

Orinoco

V8将新一代的GC称为Orinoco, 在Orinoco下, GC的算法更加高效。

Orinoco 新生代
关于Orinoco在新生代中, 其实比较容易理解, 因为它只是增加了几个worker线程来帮助处理, 如图:

Orinoco 老生代

并行标记 parallel marking

并行标记是标记由主线程和工作线程进行, 程序会阻塞

其数据结构如图所示:

Marking worklist负责决定分给其他worker thread的工作量,决定了性能与保持本地线程的均衡,V8使用基于内存段的方式去平衡各个线程的工作量,避免线程同步的耗时与尽可能的工作。即将内存分为一段段给每个线程工作。

并发标记 Concurrent marking

并发标记是由工作线程进行标记, 主线程继续运行, 程序不会阻塞

并发标记允许标记行为与应用程序同时进行,很可能发生数据竞争, 所以main thread需要与worker threads在发生数据竞争时进行同步,大多数的数据竞争行为通过轻量级的原子级内存访问就可以同步,但是一些特殊的场景需要独占整个对象的访问。V8是利用一个Bailout worklist来处理被独占的整个对象, 并由主线程处理, 如图:

合并
基于并行标记和并发标记, v8最后的垃圾回收机制如图:

其步骤如下:

  1. 从root对象开始扫描,填充对象到marking worklist
  2. 分布并发标记任务到worker threads
  3. worker threads 通过合作耗尽marking worklist来帮助main threads 更快地完成标记。
  4. 有时候, main threads也会通过处理bailout worklist和marking worklist参与标记。
  5. 如果marking worklist为空, 则主线程完成垃圾回收
  6. 在结束之前,main thread重新扫描roots,可能会发现其他的白色节点,这些白色节点会在worker threads的帮助下,被平行标记

准确式GC

提到GC不得不提一下准确式GC, 这个也是V8引擎效率比较高的原因, 以下引用自文章

虽然 ECMAScript 中没有规定整数类型,Number 都是 IEEE 浮点数,但是由于在 CPU 上浮点数相关的操作通常比整型操作要慢,大多数的 JavaScript 引擎都在底层实现中引入了整型,用于提升 for 循环和数组索引等场景的性能,并配以一定的技巧来将指针和整数(可能还有浮点数)“压缩”到同一种数据结构中节省空间。

在 V8 中,对象都按照 4 字节(32 位机器)或者 8 字节(64 位机器)对齐,因此对象的地址都能被 4 或者 8 整除,这意味着地址的二进制表示最后 2 位或者 3 位都会是 0,也就是说所有指针的这几位是可以空出来使用的。如果将另一种类型的数据的最后一位也保留出来另作他用,就可以通过判断最后一位是 0 还是 1,来直接分辨两种类型。那么,这另一种类型的数据就可以直接塞在前面几位,而不需要沿着一个指针去读取它的实际内容。在 V8 的语境内这种结构叫做小整数(SMI, small integer),这是语言实现中历史悠久的常用技巧 tagging 的一种。V8 预留所有的字(word,32位机器是 4 字节,64 位机器是 8 字节)的最后一位用于标记(tag)这个字中的内容的类型,1 表示指针,0 表示整数,这样给定一个内存中的字,它能通过查看最后一位快速地判断它包含的指针还是整数,并且可以将整数直接存储在字中,无需先通过一个指针间接引用过来,节省空间。

由于 V8 能够通过查看字的最后一位,快速地分辨指针和整数,在 GC 的时候,V8 能够跳过所有的整数,更快地沿着指针扫描堆中的对象。由于在 GC 的过程中,V8 能够准确地分辨它所遍历到的每一块内存的内容属于什么类型,因此 V8 的垃圾回收器是准确式的。与此相对的是保守式 GC,即垃圾回收器因为某些设计导致无法确定内存中内容的类型,只能保守地先假设它们都是指针然后再加以验证,以免误回收不该回收的内存,因此可能误将数据当作指针,进而误以为一些对象仍然被引用,无法回收而浪费内存。同时因为保守式的垃圾回收器没有十足的把握区分指针和数据,也就不能确保自己能安全地修改指针,无法使用那些需要移动对象,更新指针的算法。

内存观察&GC日志

GC日志
范例中的图片来自:Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)

option

--trace_gc

--trace_gc_nvp

--trace_gc_verbose

内存观察
内存观察这一块需要借助第三方工具, 因为一些原因个人只是在开发和测试阶段开启了easy-monitor观察是否内存泄漏, 再使用heapdump + chrome dev tools来定位具体的泄漏原因。其实业内最好的还是接入alinode, 但是公司接入的困难度比较高, 原因大家都懂的啦~

另外推荐一些这方面不错的资料:
《Node.js 调试指南》
关于Nodejs性能监控思考

还有就是一些可能造成内存泄漏的代码(这里就不贴代码了, 网上例子会更详细):

  • 全局变量
  • 闭包(包括commonjs规范, 其实质是一个闭包生成)
  • 缓存

总结

关于内存和GC, 相应在编码的时候需要考虑的细节和客户端不同, 需要比较谨慎的为每一份资源做出安排。

参考

V8 —— 你需要知道的垃圾回收机制
聊聊V8引擎的垃圾回收
浅谈V8引擎中的垃圾回收机制
解读 V8 GC Log(一): Node.js 应用背景与 GC 基础知识
解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法
Orinoco: young generation garbage collection
Concurrent marking in V8
V8 之旅: 垃圾回收器
Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)