Node.js学习笔记:内存控制

在过去很长一段时间内,Javascript开发者很少会在开发过程中遇到需要对内存精确控制的场景,也缺乏控制的手段。那些短时间执行的场景,如网页应用、命令行工具等,运行时间短内存很快地释放,即使内存使用过多或内存泄漏也只会影响到终端用户。但随着Node在服务器端的广泛应用,内存控制问题就暴露出来了。

基于无阻塞、事件驱动的Node服务,具有内存消耗低,适合处理海量网络请求的优点。服务器端的资源向来是寸土寸金,要为海量用户服务,就得使一切资源都要高效利用。本文将介绍Node如何合理高效地使用内存。

1. V8的垃圾回收机制与内存限制

Javascript与Java一样,由垃圾回收机制在来进行自动内存管理,开发者不需要像C/C++程序员那样时刻关注内存的分配和释放问题。在Node中,内存管理的好坏,垃圾回收状况是否优良,都与Node的Javascript执行引擎V8息息相关。

1.1 V8的对象分配

在V8中,所有JS对象都是通过堆来分配。在Node命令行下执行process.memoryUsage()可以查看内存信息,返回的结果中heapTotal是已申请到的堆内存,heapUsed是当前使用的量。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,知道超过V8的限制为止。

V8对单个进程的Node有内存限制(64位系统约1.4GB,32位系统约0.7GB)。表层原因是V8最初为浏览器设计,不太可能遇到大量内存的场景,深层原因是V8垃圾回收需要一定的开销,内存限制放得过大会使性能和响应能力下降。当然,这个限制是可以打开的,Node在启动时可以传递两个参数来调整内存限制的大小(注意参数只在初始化时生效,之后无法动态改变):

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

1.2 V8的垃圾回收机制

1.2.1 内存分代

V8的垃圾回收策略主要基于分代式,主要将内存分为新生代老生代,前者存放存活时间较短的对象,后者存放存活时间较长或常驻内存的对象。V8堆的整体大小就是两代内存空间之和,上面提到的两个参数中带有“old”和“new”,就是用于放宽新老生代的内存限制。

1.2.2 新生代的Scavenge算法

Scavenge算法将堆内存对半分为两个semispace空间,只有一个处于使用中,称为From空间,另一个处于闲置状态,称为To空间。分配对象时先从From空间分配,当开始垃圾回收时,会将From空间中的存活对象复制到To空间中,非存活对象占用的空间会被释放。完成复制后,From和To空间对换,开始下一轮的垃圾回收。

该算法的缺点是只能使用堆内存的一半,是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中,但这个算法很适合应用在新生代中,因为新生代的对象生命周期都较短。

当一个对象经过多次复制依然存活时,它就会被认为是生命周期较长的对象,其随后会被移动到老生代中采用新的算法管理,这个过程称为晋升。晋升的条件主要有两个,一个是对象是否经过Scavenge回收,一个是To空间的内存占用比超过25%的限制。设置25%的限制是因为当此次Scavenge回收完成后,这个To空间会变成From空间,如果占比过高会影响后续的内存分配。

1.2.3 老生代的Mark-Sweep和Mark-Compact算法

Mark-Sweep是标记清除的意思,该算法在标记阶段遍历堆中所有对象,并标记活着的对象,接下来在清除阶段只清除没有被标记的对象。可以看出,活对象在新生代只占小部分,Scavenge只复制活对象;死对象在老生代只占小部分,Mark-Sweep只清理死对象,这就是两种回收方式能高效运作的原因。

Mark-Sweep最大的问题是进行一次标记清除后,内存空间会出现不连续的状态,这很可能会造成无法分配一个大对象的问题。而Mark-Compact则是一种改进,它在清除阶段将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。但由于Mark-Compact需要移动对象,执行速度不可能很快,所以再取舍上,V8主要使用Mark-Sweep,在空间不足以分配时才使用Mark-Compact。

1.2.4 增量标记

为了避免出现JS应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,这种暂停成为“全停顿”。V8老生代通常配置得很大,且存活对象较多,全堆垃圾回收的各种动作造成的停顿会比较可怕,需要设法改善。V8先从标记阶段入手,将原本一口气停顿完成的动作改为增量标记,也就是拆分为许多小“步进”,没做完一步就让JS应用逻辑执行一小会儿。这种垃圾回收与应用逻辑交替执行直到标记完成,可以让最大停顿时间减少5倍左右。

1.3 查看垃圾回收日志

用命令行启动node时,加入--trace_gc可以输出垃圾回收的日志信息,--prof可以得到V8执行时的性能分析数据。通过分析日志,可以找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。不过,通过--prof得到的log不具备可读性,可以使用Node自带的windows-tick-processor.bat工具来分析。

2. 高效使用内存

2.1 内存泄漏

Node对内存泄漏十分敏感,一旦线上应用有成千上万的流量,哪怕是一个字节的内存泄漏也会造成堆积。在V8的垃圾回收机制机制下,在通常的代码编写中,很少会出现内存泄漏,但内存泄漏通常产生于无意间,较难排查。一旦发生,其实质只有一个:当回收的对象出现意外没有被回收变成了常驻在老生代的对象。造成内存泄漏的原因有这么几个:缓存,队列消费不及时,作用域未释放。

JS开发者通常喜欢使用对象的键值对来当缓存,但严格意义上的缓存有着完善的过期策略,而普通对象的键值对并没有。所以在Node中,试图拿内存当缓存的行为应该受到限制,小心而为之。除了用对象来当缓存的案例之外,还有一种案例是模块机制。Node的模块都会通过编译执行并缓存起来常驻于老生代,由于通过exports导出的函数可以访问文件模块的私有变量,所以每个文件模块中被导出函数引用的变量不会被释放。在设计模块时,一定要十分小心内存泄漏的出现。下面的代码每次调用leak()方法时,都导致局部变量leakArray不停增加内存占用,不被释放:

var leakArray = [];
exports.leak = function() {
    leakArray.push('leak' + Math.random());
};

如果要大量使用缓存,目前比较好的做法是采用进程外缓存,如Redis和Memcached。这样Node进程自身不存储状态减少内存泄漏的可能性,进程之间也可以共享缓存。

2.2 内存泄漏排查

可以使用以下一些常见的工具来定位Node应用的内存泄漏:

  • node-heapdump:来自Node核心贡献者之一的模块,允许对V8堆内存抓取快照,用于事后分析
  • node-memwatch:用法和node-heapdump类似,来自于Mozilla成员贡献的模块
  • node-mtrace:使用了GCC的mtrace工具来分析堆的使用

2.3 大内存应用

由于Node的内存限制,操作大文件时要小心,好在Node提供了原生stream模块用于处理大文件。不能通过fs.readFile()fs.writeFile()来直接进行大文件操作,应该使用fs.createReadStream()fs.createWriteStream()通过流的方式来读写大文件。若不需要进行字符串层面的操作,可以尝试使用Buffer来操作,这不会受到V8堆内存的限制,但依然会受到物理内存限制。

参考资料:《深入浅出NodeJS》第五章

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器