# Node内存管理和垃圾回收

参考文章Node-内存管理和垃圾回收 (opens new window)

# 垃圾回收

由于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
1
2

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

rss(resident set size):所有内存占用,包括指令区和堆栈
heapTotal:V8引擎可以分配的最大堆内存,包含下面的heapUsed
heapUsed:V8引擎已经分配使用的堆内存
external: V8管理C++对象绑定到JavaScript对象上的内存
1
2
3
4

对于大文件的操作通常会使用Buffer,主要是因为Node中可以使用内存小,而Buffer是堆外内存即external

# V8的内存分代

V8的内部采用两种垃圾回收算法,分别针对生存周期较短和生存周期较长的两种对象。

# 新生代

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

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

在64位系统中一个semisapce16MB,32位为8MB,所以新生代内存大小分别为32MB16MB

# 老生代

主要存放生存周期比较长的对象。内存按照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中。

# V8的垃圾回收机制

# 新生代

新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。新生代的对象晋升到老生代,主要有两个判断条件:

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

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

写屏障write barrier

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

# 老生代

老生代在64位和32位下具有的内存分别是1400MB700MB,其垃圾回收策略为Mark-SweepMark-Compact相结合。

标记清除Mark-Sweep

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

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

标记整理Mark-Compact

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

如果将对中的对象看做由指针做边的有向图,标记算法的核心就是深度优先搜索。

V8使用每个对象的两个mark-bits和一个标记工作栈来实现标记,两个mark-bits编码三种颜色:白色(00),灰色(10)和黑色(11)。

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

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

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

当对象太大无法push进空间有限的栈的时候,V8会先把这个对象保留灰色放弃掉,然后将整个栈标记为溢出状态(overflowed)。在溢出状态下,V8会继续从栈上pop对象,标记为黑色,再将引用的白色对象标记为灰色和溢出,但不会将这些灰色的对象push到栈上去。这样没多久,栈上的所有对象都被标黑清空了。此时V8开始遍历整个堆,把那些同时标记为灰色和溢出对象按照老方法标记完。由于溢出后需要额外扫描一遍堆(如果发生多次溢出还可能扫描多遍),当程序创建了太多大对象的时候,就会显著影响GC的效率。

事实上,V8为了降低全堆垃圾回收带来的停顿时间,使用了增量标记和惰性清理两种方式。

增量标记

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

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

惰性清理

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

# Orinoco

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

Orinoco新生代

Orinoco新生代中,增加了几个Worker线程处理内存

Orinoco老生代

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

marking worklist负责决定分给其他worker thread的工作量,决定了性能与保持本地线程的均衡

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

并发标记允许标记行为与应用程序同时进行,很可能发生数据竞争,V8利用一个bailout worklist来处理被独占的整个对象,并由主线程处理

基于并行标记和并发标记,V8的垃圾回收机制步骤如下:

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

# 准确式GC

虽然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,即垃圾回收器因为某些设计导致无法确定内存中内容的类型,只能保守地先假设它们都是指针然后再加以验证,以免误回收不该回收的内存,因此可能误将数据当作指针,进而误以为一些对象仍然被引用,无法回收而浪费内存。同时因为保守式的垃圾回收器没有十足的把握区分指针和数据,也就不能确保自己能安全地修改指针,无法使用那些需要移动对象,更新指针的算法。