node对内存泄露十分敏感,因为一旦我们线上有成千上万的大流量,即使是一个字节的内存泄露也会造成堆积,垃圾回收过程中会耗费很多时间进行对象扫描,导致我们的应用响应缓慢,直到进程内存溢出,整个应用崩溃。
一般情况下日常开发中我们应该不会遇到上述这种情况,不过一旦遇到的话还是需要引起我们的特别关注。
整体上来讲,Node的内存应该分为两个部分。ChromeV8管理的部分(Javascript使用的部分),系统底层管理的部分(C++/C使用的部分)
二者实际上应该是处于一种包含关系。即ChromeV8的部分应当包含在系统底层管理的部分当中。
node程序运行中,此进程占用的所有内存称为常驻内存。常驻内存由以下几部分组成:
除堆外内存,其余部分均由V8管理。
node不像其他后端语言,对内存的使用没有大小限制。在node中使用内存,其实只能使用到系统的一部分内存,这是因为node基于V8构建,V8的内存管理机制限制了内存的用量。
V8为何要限制堆的大小?原因是V8的垃圾回收机制的限制。垃圾回收会引起JavaScript线程暂停执行;内存太大,垃圾回收时间太长,在当时的考虑下,直接限制了堆内存大小。
老生代 | 新生代(默认) | 新生代(最大) | |
---|---|---|---|
64位系统 | 1400MB | 32MB | 64MB |
32位系统 | 700MB | 16MB | 32MB |
我们知道v8引擎的设计初衷其实只是运行在浏览器中,在浏览器的一般应用场景下使用起来是绰绰有余的,能够胜任前端页面中的几乎所有需求。虽然服务端操作大内存的场景不常见,但是如果有这样的需求,是可以解除限制的,即在启动node应用程序时,可以通过传递两个参数来调整内存限制的大小,在v8初始化时生效,一旦修改不能变化。
在node中,使用Buffer可以读取超过V8内存限制的大文件。原因是Buffer对象不同于其他对象,它不经过V8的内存分配机制。这在于node并不同于浏览器的应用场景。在浏览器中,JavaScript直接处理字符串即可满足绝大多数的业务需求,而Node则需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。
在不需要进行字符串操作时,可以不借助v8,使用Buffer操作,这样不会受到v8的内存限制
chrome v8中所有的javascript对象都是堆存储,当在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
V8的垃圾回收策略主要基于分代式垃圾回收机制,基于这个机制,V8把堆内存分为新生代(New Space)和老生代 (Old Space)。
在这里我们说下老生代和新生代的区别:
对象存活时间 | 内存空间 | |
---|---|---|
老生代 | 存活时间较长或常驻内存的对象 | –max-old-space-size命令设置老生代内存空间的最大值 |
新生代 | 存活时间较短的对象 | –max-new-space-size命令设置新生代内存空间的大小 |
原因是因为:垃圾回收算法有很多种,但是并没有一种是胜任所有的场景,在实际的应用中,需要根据对象的生存周期长短不一,而使用不同的算法,来达到最好的效果。在V8中,按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同的内存施以更高效的算法。所以就有个新老生代之分。
在新生代中,主要通过Scavenge算法进行垃圾回收。具体实现采用Cheney算法。
Scavenge算法原理
Scavenge采用复制方式实现垃圾回收,在Scavenge算法中,它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另外一个处于闲置状态。处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间。当分配对象时,先从From空间分配。当开始进行垃圾回收时,会检查From空间中存活的对象,这些存活的对象会被复制到To空间中,而非存活的对象占用的空间会被释放。完成复制后,From空间和To空间角色互换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。
在新生代中的对象怎样才能到老生代中?
在新生代中存活周期长的对象会被移动到老生代中,主要符合两个条件中的一个:
对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果已经经历过了,则将该对象从From空间中复制到老生代空间中。
当对象从From空间复制到To空间时,如果To空间已经使用超过25%,则这个对象直接复制到老生代中。这么做的原因在于这次Scavenge回收完成后,这个To空间会变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
对于老生代的对象,由于存活对象占比较大比重,使用Scavenge算法显然不科学。第一是复制的对象太多会导致效率问题,第二是需要浪费多一倍的空间。所以,V8在老生代中主要采用Mark-Sweep算法与Mark-Compact算法相结合的方式进行垃圾回收。
Mark-Sweep
Mark-Sweep的字面意思是标记清除,分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记存活的对象,在随后的清除阶段中,只清除标记之外的对象。
但是Mark-Sweep有一个很严重的问题,就是进行一次标记清除回收之后,内存会变得碎片化。如果需要分配一个大对象,这时候就无法完成分配了。这时候就需要用到Mark-Compact了。
Mark-Compact
Mark-Compact的字面意思是标记整理,是在Mark-Sweep的基础上演变而来。Mark-Compact在标记存活对象之后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
Incremental Marking(增量标记)
主要分为如下三步:
具体实现如下:
由于Node单线程的特性,V8每次垃圾回收的时候,都需将应用逻辑暂停,待执行完垃圾回收后再恢复应用逻辑,被称为全停顿。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活对象也相对较少,即使全停顿也没多大影响。但在老生代中,存活对象较多,垃圾回收的标记、清理、整理都需长时间停顿,这样会严重影响系统的性能。
所以增量标记 (Incrememtal Marking)被提出来。它从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,拆分为许多小步,每做完一步进就让JavaScript应用逻辑执行一小会,垃圾回收与应用逻辑这样交替执行直到标记阶段完成。
注:垃圾回收是影响性能的因素之一,要尽量减少垃圾回收,尤其全堆垃圾回收
这是node的原生部分也是根本上区别与前端js的部分,包括核心运行库,在一些核心模块的加载过程中,Node会调用一个名为js2c的工具。这个工具会将核心的js模块代码以C数组的方式存储在内存中,用以提升运行效率。
在这个部分,我们也不会有内存的使用限制,但是作为C/C++扩展来使用大量内存的过程中,风险也是显而易见的。
C/C++没有内存回收机制。作为没有C/C++功底的纯前端程序员,不建议去使用这部分,因为C/C++模块非常强大,如果对于对象生命周期的理解不够到位,而在使用大内存对象的情境中,很容易就造成内存溢出导致整个Node的崩溃甚至是系统的崩溃。安全的使用大内存的方法就是使用buffer对象。
使用javascript的部分是由ChromeV8接管的吗?那为什么仍然可以使用大量内存创立缓存呢?
这是node运行在服务端和Chrome运行在前端的区别,Chrome和Node都采用ChromeV8作为JS的引擎,但是实际上他们所面对的对象是不同的,Node面对的是数据提供,逻辑和I/O,而Chrome面对的是界面的渲染,数据的呈现。因此在Chrome上,几乎不会遇到大内存的情况,作为为Chrome的而生的V8引擎自然也不会考虑这种情况,因此才会出现上文所述的内存限制。而现在,Node面对这样的情况是不可以接受的,所以Buffer对象,是一个特殊的对象,它由更低层的模块创建,存储在V8引擎以外的内存空间上。
在内存的层面上讲Buffer和V8是平级的。
var foo = function() {
var bar = function() {
var local = "内部变量";
return function() {
return local;
};
};
var baz = bar();
console.log(baz());
};
从上面代码知bar()返回一个匿名函数,一旦 有变量引用它,它的作用域将不会释放,直到没有引用。
注:把闭包赋值给一个不可控的对象时,会导致内存泄漏。使用完,将变量赋其他值或置空
注:Node使用的内存不是都通过v8分配,还有堆外内存,用于处理网络流、I/O流
原因:缓存、队列消费不及时、作用域未释放等。
缓存:
为了加速模块引入,模块会在编译后缓存,由于通过exports导出(闭包),作用域不会释放,常驻老生代。要注意内存泄漏。
var arr = [];
exports.hello = function() {
arr.push("hello" + Math.random());
};
//局部变量arr不停增加内存占用,且不会释放,如果必须如此设计,要提供释放接口
队列状态
var memwatch = require('memwatch');
memwatch.on('leak', function(info) {
console.log('leak:');
console.log(info);
});
memwatch.on('stats', function(stats) {
console.log('stats:') console.log(stats);
});
在进程使用node-memwatch后,每次全堆垃圾回收,会触发stats事件,该事件会传递内存的统计信息
stats: {
num_full_gc: 4, // 第几次全堆垃圾回收
num_inc_gc: 23, // 第几次增量垃圾回收
heap_compactions: 4, // 第几次对老生代整理
usage_trend: 0, // 使用趋势
estimated_base: 7152944, // 预估基数
current_base: 7152944, // 当前基数
min: 6720776, // 最小
max: 7152944 //最大
}
如果经过连续的5次垃圾回收后,内存仍没有被释放,意味有内存泄漏,node-memwatch会触发leak事件。