Node - 内存管理和垃圾回收

前言

从前端思惟转变到后端, 有一个很重要的点就是内存管理。之前写前端由于只是在浏览器上运行, 因此对于内存管理通常不怎么须要上心, 可是在服务器端, 则须要斤斤计较内存。html

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

内存限制

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

固然, 咱们能够在启动Node服务的时候, 手动设置内存的大小 以下:node

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

查看内存
在Node环境中, 能够经过process.memoryUsage()来查看内存分配git

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

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

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

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

复制代码

事实上, 对于大文件的操做一般会使用Buffer, 究其缘由就是由于Node中内存小的缘由, 而使用Buffer是不受这个限制, 它是堆外内存, 也就是上面提到的externalgithub

v8的内存分代

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

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

该算法缺陷很明显就是有一半的空间一直闲置着而且须要复制对象, 可是因为新生代自己具备的内存比较小加上其分配的对象都是生存周期比较短的对象, 因此浪费的空间以及复制使用的开销会比较小。segmentfault

在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)

相关文章
相关标签/搜索