内存控制
Node内存控制在海量请求的前提下需要进行探讨。
V8的垃圾回收机制与内存限制
JavaScript由垃圾回收机制来进行自动内存管理,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响。这一切都与Node的JavaScript执行引擎V8息息相关。
V8的内存限制
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB)。
因为Node中使用的JavaScript对象基本上都是通过V8自己的方式进行分配和管理的。而V8会限制使用的内存量。
V8的对象分配
在V8中,所有的JavaScript对象都是通过堆来进行分配的。Node提供了V8中内存使用量的查看方式
$ node
> process.memoryUsage();
{ rss: 14958592,
heapTotal: 7195904,
heapUsed: 2821496 }
heapTotal和heapUsed是V8的堆内存使用情况,前者是已申请到的堆内存,后者是当前使用的量。
如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
V8限制堆内存的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。深层原因是V8的垃圾回收机制的限制。以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至需要1s以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。因此,直接限制堆内存是一个好的选择。
当然V8依然提供了选项让我们使用更多的内存。Node在启动时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小
node --max-old-space-size=1700 test.js // 单位为MB
node --max-new-space-size=1024 test.js // 单位为KB
上述参数在V8初始化时生效,一旦生效就不能再动态改变。
V8的垃圾回收机制
1.V8主要的垃圾回收算法
V8的垃圾回收策略主要基于分代式垃圾回收机制。因为在实际应用中,对象的生存周期长短不一,不同的算法的算法只能针对特定情况具有良好的效果。
1.1-V8的内存分代
将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象
V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。前面提到的内存限制调整的就是新老生代。这两个最大值需要在启动时就指定,这意味着V8使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值,就会引起进程出错。
在64位系统和32位系统下会分别只能使用约1.4GB和约0.7GB的大小。这个限制在源码中可以找到。源码中老生代设置在64位系统下为1400MB,在32位系统下为700MB
新生代内存由两个reserved_semispace_size_所构成。reserved_semispace_size_在64和32位系统上分别为16MB和8MB
V8堆内存的最大保留空间为 4 * reserved_semispace_size_ + max_old_generation_size_;在64位系统上为1464MB,32位系统上为732MB.
1.2-Scavenge算法 新生代对象主要通过Scavenge算法进行垃圾回收。在具体实现中,主要采用了Cheney算法。
Cheney算法一种采用复制方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象被复制到To空间,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。
Scavenge算法的缺点是只能使用堆内存中的一半,但由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。
当一个对象经过多次复制依然存活时,它随后会被移动到老生代中,From空间中存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,这个过程也叫晋升。
晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。
在默认情况下,V8的对象分配主要集中在From空间中,对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。
另一个判断条件是当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中
设置25%这个限制值的原因是当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
1.3-Mark-Sweep & Mark-Compact
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,老生代采用Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep是标记清除的意思。在标记阶段遍历堆中所以对象,并标记活着的对象,清除阶段,清除没有被标记的对象。死对象在老生代中只占较少部分,这是Mark-Sweep能高效处理的原因。下图,黑色部分标记为死亡对象
Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
Mack-Compact是标记整理的意思,是在在Mark-Sweep的基础上演变而来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。下图白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。
完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。
在V8的回收策略中两者是结合使用的。
在取舍中,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。
1.4-Incremental Marking
为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中活动对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收的标记、清理、整理等动作造成的停顿就会比较可怕。
为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,每做完一“步进”将让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。
V8后续还引入了延迟清理与增量式整理,让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
总结
想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。
服务端在访问量大、内存大量占用的时候,老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出。
查看垃圾回收日志
在启动时添加--trace_gc参数。执行结束后,将会在gc.log文件中得到所有垃圾回收信息:
node --trace_gc -e "var a = [];for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log
通过分析垃圾回收日志,可以了解垃圾回收的运行状况,找出垃圾回收的哪些阶段比较耗时,触发的原因是什么。
通过在Node启动时使用--prof参数,可以得到V8执行时的性能分析数据,其中包含了垃圾回收执行时占用的时间。
// test01.js
for (var i = 0; i < 1000000; i++) {
var a = {};
}
执行
$ node --prof test01.js
得到一个日志文件,该日志文件基本不具备可读性,v8提供了linux-tick-processor工具用于统计日志信息。将该目录添加到环境变量PATH中调用:
$ linux-tick-processor v8.log
其中垃圾回收部分
[GC]:
ticks total nonlib name
2 5.4%
由于不断分配对象,垃圾回收所占的时间为5.4%。这意味着事件循环执行1000毫秒要给出54毫秒的时间用于垃圾回收。
高效使用内存
作用域
提到如何触发垃圾回收,第一个要介绍的是作用域。在JavaScript中能形成作用域的有函数调用、with以及全局作用域
var foo = function () {
var local = {};
};
foo函数在每次调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。在这个示例中,由于对象非常小,将会分配在新生代中的From空间。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放。 1.1 标识符查找
与作用域相关的即是标识符查找。所谓标识符,可以理解为变量名。
var bar = function () {
console.log(local);
};
JavaScript在执行时会去查找该变量定义在哪里。它最先查找的是当前作用域,如果在当前作用域中无法找到该变量的声明,将会向上级的作用域里查找,直到查到为止。
1.2 作用域链
var foo = function () {
var local = 'local var';
var bar = function () {
var local = 'another var';
var baz = function () {
console.log(local);// another var
};
baz();
};
bar();
};
foo();
存在作用域链,不断向上查找。了解作用域,有助于我们了解变量的分配和释放。
1.3 变量的主动释放
如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清理和整理的过程中,会被释放。
global.foo = 'a';
console.log(global.foo);
delete global.foo;// a
// 或者重新赋值
// global.foo = undefined; // or null
console.log(global.foo);
如果在非全局作用域中,想主动释放变量引用的对象,也可以通过这样的方式。虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性有可能干扰V8的优化,所以赋值方式较好。
闭包
在JavaScript实现外部作用域访问内部作用域变量的方法叫做闭包
var foo = function () {
var bar = function () {
var local = "局部变量";
return function () {
return local;
};
};
var baz = bar();
console.log(baz());
};
foo();// "局部变量"
在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可。闭包的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再引用,才会逐步释放。
小结
在正常的JavaScript执行中,无法立即回收的内存由闭包和全局变量引用这两种情况。由于V8的内存限制,要注意此类变量的使用。
内存指标
一般而言,应用中存在一些全局性的对象是正常的,而且在正常使用中,变量都会自动释放回收。但是也会存在一些我们认为会回收但是却没有被回收的对象。
查看内存使用情况
除了process.memoryUsage(),os模块的totalmem()和freemem()也可以查看内存使用情况。
1.查看进程的内存占用
调用process.memoryUsage()可以查看Node进程的内存占用情况
$ node
> process.memoryUsage()
{ rss: 13852672,
heapTotal: 6131200,
heapUsed: 2757120 }
rss是resident set size的缩写,即进程常驻内存部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
heapTotal和heapUsed对应的是V8的堆内存信息。heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用中的内存量,单位都是字节。
例子
var showMem = function () {
var mem = process.memoryUsage();
var format = function (bytes) {
return (bytes / 1024 / 1024).toFixed(2) + 'MB';
}
console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss));
console.log('-----------------------------');
}
var useMem = function () {
var size = 20 * 1024 * 1024;
var arr = new Array(size);
for (var i = 0; i < size; i++) {
arr[i] = 0;
}
return arr;
};
var total = [];
for (var j = 0; j < 15; j ++) {
showMem();
total.push(useMem());
}
showMem();
执行结果
Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.66MB
-----------------------------
Process: heapTotal 167.75MB heapUsed 164.33MB rss 181.11MB
-----------------------------
Process: heapTotal 327.76MB heapUsed 324.33MB rss 341.19MB
-----------------------------
Process: heapTotal 487.77MB heapUsed 484.34MB rss 501.32MB
-----------------------------
Process: heapTotal 647.78MB heapUsed 644.34MB rss 661.38MB
-----------------------------
Process: heapTotal 807.79MB heapUsed 804.34MB rss 821.39MB
-----------------------------
Process: heapTotal 967.80MB heapUsed 964.35MB rss 981.41MB
-----------------------------
Process: heapTotal 1130.32MB heapUsed 1123.57MB rss 1141.77MB
-----------------------------
Process: heapTotal 1290.33MB heapUsed 1283.57MB rss 1301.79MB
-----------------------------
<--- Last few GCs --->
[39083:0x103800000] 1338 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 110.8 / 0.0 ms (average mu = 0.230, current mu = 0.003) last resort GC in old space requested
[39083:0x103800000] 1450 ms: Mark-sweep 1283.5 (1287.8) -> 1283.5 (1287.8) MB, 111.4 / 0.0 ms (average mu = 0.131, current mu = 0.000) last resort GC in old space requested
可以看到,3个值都在不断增长,在接近1500MB的时候,无法继续分配内存,然后进程内存溢出了。
查看系统的内存占用
os模块的totalmem()和freemem()用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,以字节为单位
var os = require('os');
var showMem = function () {
var format = function (bytes) {
return (bytes / 1024 / 1024).toFixed(2) + 'MB';
}
console.log('os.totalmem', format(os.totalmem()));
console.log('os.freemem', format(os.freemem()));
}
showMem();
// os.totalmem 8192.00MB
// os.freemem 452.36MB
可以看到我的电脑的总内存为8GB,当前闲置内存为452.36MB
堆外内存
可以看到堆中的内存用量总是小于进程的常驻内存用量,这意味着Node中的内存使用并非都是通过V8进行分配的。我们将那些不是通过V8分配的内存称为堆外内存
我们将Array换成Buffer,将size变大
var useMem = function () {
var size = 200 * 1024 * 1024;
var buffer = new Buffer(size);
for (var i = 0; i < size; i ++) {
buffer[i] = 0;
}
return buffer;
}
结果
Process: heapTotal 6.23MB heapUsed 3.81MB rss 19.80MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.53MB rss 221.43MB
-----------------------------
Process: heapTotal 8.23MB heapUsed 4.54MB rss 422.14MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 593.79MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 4.01MB rss 745.22MB
-----------------------------
Process: heapTotal 10.23MB heapUsed 3.77MB rss 945.27MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1145.29MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1142.21MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1194.72MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1306.51MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1320.05MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.72MB rss 1382.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1582.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1782.52MB
-----------------------------
Process: heapTotal 10.73MB heapUsed 3.71MB rss 1955.04MB
-----------------------------
Process: heapTotal 7.73MB heapUsed 3.71MB rss 1961.53MB
-----------------------------
(node:39922) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
heapTotal和heapUsed的变化极小,唯一变化的是rss值,并且该值已经超过V8的限制值。这是因为Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。
小结
Node的内存构成主要由通过V8进行分配的部分和Node自行分配的部分。受V8的垃圾回收限制的主要是V8的堆内存。
内存泄漏
一旦线上应用有成千上万的流量,那怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。
内存泄漏通常产生于无意间,较难排查。尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。
通常由如下原因:
- 缓存
- 队列消费不及时
- 作用域未释放
慎将内存当做缓存
在Node中,一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。
如果需要,只要限定缓存对象的大小,加上完整的过期策略以防止内存无限制增长,还是可以一用的
另一个案例在于模块机制。所有模块都会通过编译执行,然后被缓存起来。由于通过exports导出的函数,可以访问文件模块中的私有变量,这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放
(function (exports, require, module, __filename, __dirname) {
var local = "局部变量";
exports.get = function () {
return local;
};
});
由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要小心内存泄漏的出现。
var leakArray = [];
exports.leak = function () {
leakArray.push('leak');
};
如果不可避免要这么设计,那么请添加清空队列的响应接口,以供调动者释放内存。
缓存的解决方案
直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。这些缓存不可避免地有重复,对物理内存的使用是一种浪费。
目前较好的解决方案是采用进程外的缓存如Redis。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能且进程间可以共享缓存。
关注队列状态
队列(数组对象)在消费者-生产者模型中经常充当中间产物,如果消费者速度低于生产速度,将会形成堆积。
解决方案是监控队列的长队,一旦堆积,通过报警通知相关人员。另一个解决方案是设置超时机制,通过回调函数传递超时异常,给消费速度一个下限值。
内存泄漏排查
排查内存泄漏的原因主要通过对堆内存进行分析而找到的,有许多工具用来定位Node应用的内存泄漏,如node-heapdump和node-memwatch等。???暂略
大内存应用
在Node中,不可避免地还是会存在操作大文件的场景。好在Node提供了stream模块用于处理大文件。Node中的大多数模块都有stream的应用。
由于V8的内存限制,我们无法通过fs.readFile和fs.writeFile直接进行大文件的操作,而改用fs.createReadStream和fs.createWriteStream方法通过流的方式实现对大文件的操作。
如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。
参考
- 《深入浅出node.js》