NodeJs中内存机制

node对内存泄露十分敏感,因为一旦我们线上有成千上万的大流量,即使是一个字节的内存泄露也会造成堆积,垃圾回收过程中会耗费很多时间进行对象扫描,导致我们的应用响应缓慢,直到进程内存溢出,整个应用崩溃。
一般情况下日常开发中我们应该不会遇到上述这种情况,不过一旦遇到的话还是需要引起我们的特别关注。

内存机制

整体上来讲,Node的内存应该分为两个部分。ChromeV8管理的部分(Javascript使用的部分),系统底层管理的部分(C++/C使用的部分)
二者实际上应该是处于一种包含关系。即ChromeV8的部分应当包含在系统底层管理的部分当中。

Chrome v8的内存管理机制

内存管理模型

node程序运行中,此进程占用的所有内存称为常驻内存。常驻内存由以下几部分组成:

  • 代码区:存放即将执行的代码片段
  • 栈:存放局部变量
  • 堆:存放对象和闭包上下文,v8使用垃圾回收机制管理堆内存
  • 堆外内存:不通过V8分配,也不受V8管理。Buffer对象的数据就存放于此。

除堆外内存,其余部分均由V8管理。

  1. 栈的分配与回收非常直接,当程序离开某作用域后,其栈指针下移(回退),整个作用域的局部变量都会出栈,内存收回。
  2. 最复杂的部分是堆的管理,V8使用垃圾回收机制进行堆的内存管理,也是开发中可能造成内存泄漏的部分,是程序员的关注点,也是本文的探讨点。

内存的限制

node不像其他后端语言,对内存的使用没有大小限制。在node中使用内存,其实只能使用到系统的一部分内存,这是因为node基于V8构建,V8的内存管理机制限制了内存的用量。

V8为何要限制堆的大小?原因是V8的垃圾回收机制的限制。垃圾回收会引起JavaScript线程暂停执行;内存太大,垃圾回收时间太长,在当时的考虑下,直接限制了堆内存大小。

老生代 新生代(默认) 新生代(最大)
64位系统 1400MB 32MB 64MB
32位系统 700MB 16MB 32MB

我们知道v8引擎的设计初衷其实只是运行在浏览器中,在浏览器的一般应用场景下使用起来是绰绰有余的,能够胜任前端页面中的几乎所有需求。虽然服务端操作大内存的场景不常见,但是如果有这样的需求,是可以解除限制的,即在启动node应用程序时,可以通过传递两个参数来调整内存限制的大小,在v8初始化时生效,一旦修改不能变化。

  • 新生代 node --max-nex-space-size=1024 app.js // 单位为KB
  • 老生代 node --max-old-space-size=2000 app.js // 单位为MB

不受内存限制的特例

在node中,使用Buffer可以读取超过V8内存限制的大文件。原因是Buffer对象不同于其他对象,它不经过V8的内存分配机制。这在于node并不同于浏览器的应用场景。在浏览器中,JavaScript直接处理字符串即可满足绝大多数的业务需求,而Node则需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。

在不需要进行字符串操作时,可以不借助v8,使用Buffer操作,这样不会受到v8的内存限制

内存的分配

chrome v8中所有的javascript对象都是堆存储,当在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
在这里插入图片描述

Chrome v8的垃圾回收机制

分代式垃圾回收机制

V8的垃圾回收策略主要基于分代式垃圾回收机制,基于这个机制,V8把堆内存分为新生代(New Space)和老生代 (Old Space)。
在这里我们说下老生代和新生代的区别:

对象存活时间 内存空间
老生代 存活时间较长或常驻内存的对象 –max-old-space-size命令设置老生代内存空间的最大值
新生代 存活时间较短的对象 –max-new-space-size命令设置新生代内存空间的大小

在这里插入图片描述
在这里会有个疑问,为什么要分为新老生代呢?

原因是因为:垃圾回收算法有很多种,但是并没有一种是胜任所有的场景,在实际的应用中,需要根据对象的生存周期长短不一,而使用不同的算法,来达到最好的效果。在V8中,按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同的内存施以更高效的算法。所以就有个新老生代之分。

  1. 新生代中的垃圾回收

在新生代中,主要通过Scavenge算法进行垃圾回收。具体实现采用Cheney算法。

Scavenge算法原理

Scavenge采用复制方式实现垃圾回收,在Scavenge算法中,它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另外一个处于闲置状态。处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间。当分配对象时,先从From空间分配。当开始进行垃圾回收时,会检查From空间中存活的对象,这些存活的对象会被复制到To空间中,而非存活的对象占用的空间会被释放。完成复制后,From空间和To空间角色互换。简而言之,在垃圾回收的过程中就是通过将存活对象在两个semispace空间之间进行复制

优点:时间短。
缺点:只能使用一半堆内存。新生代对象生命周期短,适合此算法。

在这里插入图片描述
注:当对象经过多次复制依然存活,就会晋升到老生代

在新生代中的对象怎样才能到老生代中?

在新生代中存活周期长的对象会被移动到老生代中,主要符合两个条件中的一个:

  • 对象是否经历过Scavenge回收

对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果已经经历过了,则将该对象从From空间中复制到老生代空间中。

  • To空间的内存占比超过25%限制

当对象从From空间复制到To空间时,如果To空间已经使用超过25%,则这个对象直接复制到老生代中。这么做的原因在于这次Scavenge回收完成后,这个To空间会变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

  1. 老生代中的垃圾回收

对于老生代的对象,由于存活对象占比较大比重,使用Scavenge算法显然不科学。第一是复制的对象太多会导致效率问题,第二是需要浪费多一倍的空间。所以,V8在老生代中主要采用Mark-Sweep算法与Mark-Compact算法相结合的方式进行垃圾回收。

Mark-Sweep
Mark-Sweep的字面意思是标记清除,分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记存活的对象,在随后的清除阶段中,只清除标记之外的对象。
但是Mark-Sweep有一个很严重的问题,就是进行一次标记清除回收之后,内存会变得碎片化。如果需要分配一个大对象,这时候就无法完成分配了。这时候就需要用到Mark-Compact了。

Mark-Compact
Mark-Compact的字面意思是标记整理,是在Mark-Sweep的基础上演变而来。Mark-Compact在标记存活对象之后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

Incremental Marking(增量标记)

主要分为如下三步:

  • 降低老生代的全堆垃圾回收带来的时间停顿
  • 从标记阶段入手,拆分为许多小步进,与应用逻辑交替运行
  • 垃圾回收最大停顿时间降为原来的1/6

具体实现如下:

由于Node单线程的特性,V8每次垃圾回收的时候,都需将应用逻辑暂停,待执行完垃圾回收后再恢复应用逻辑,被称为全停顿。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活对象也相对较少,即使全停顿也没多大影响。但在老生代中,存活对象较多,垃圾回收的标记、清理、整理都需长时间停顿,这样会严重影响系统的性能。

所以增量标记 (Incrememtal Marking)被提出来。它从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,拆分为许多小步,每做完一步进就让JavaScript应用逻辑执行一小会,垃圾回收与应用逻辑这样交替执行直到标记阶段完成。

注:垃圾回收是影响性能的因素之一,要尽量减少垃圾回收,尤其全堆垃圾回收

内存C/C++的部分

这是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是平级的。

如何高效使用内存

  • 手动销毁变量
    js中能形成作用域的有函数调用、with和全局作用域
    例如,在函数调用时,会创建对应的作用域,在执行结束后销毁,并且在该作用域申明的局部变量也会被销毁
  1. 标识符查找(即变量名) 先查找当前作用域,再向上级作用域,一直到全局作用域
  2. 变量主动释放 全局变量要直到进程退出才释放,导致引用对象常驻老生代,可以用delete删除或者赋undefined、null(delete删除对象的属性可能干扰v8,所以赋值更好)
  • 慎用闭包
    闭包是外部作用域访问内部作用域的方法,得益于高阶函数特性
var foo = function() {
   var bar = function() {
       var local = "内部变量";
       return function() {
           return local;
       };
   };
   var baz = bar();
   console.log(baz());
};

从上面代码知bar()返回一个匿名函数,一旦 有变量引用它,它的作用域将不会释放,直到没有引用。
注:把闭包赋值给一个不可控的对象时,会导致内存泄漏。使用完,将变量赋其他值或置空

  • 大内存使用
  1. 使用stream,当我们需要操作大文件,应该利用Node提供的stream以及其管道方法,防止一次性读入过多数据,占用堆空间,增大堆内存压力。
  2. 使用Buffer,Buffer是操作二进制数据的对象,不论是字符串还是图片,底层都是二进制数据,因此Buffer可以适用于任何类型的文件操作。
    Buffer对象本身属于普通对象,保存在堆,由V8管理,但是其储存的数据,则是保存在堆外内存,是有C++申请分配的,因此不受V8管理,也不需要被V8垃圾回收,一定程度上节省了V8资源,也不必在意堆内存限制。

查看内存使用情况

  • process.memoryUsage():表示查看进程内存占用,其中rss为进程的常驻内存(node所占的内存), 是分配的整体物理内存,包括堆、栈、代码段, heapTotal整体堆内存、heapUsed为堆内存使用情况,external: 代表v8管理的绑定到javascript的c++对象的内存,可以看到,rss是大于heapTotal的,因为rss包括且不限于堆。
  • os.totalmem(),os.freemem() 查看系统内存。

注:Node使用的内存不是都通过v8分配,还有堆外内存,用于处理网络流、I/O流

内存泄露

原因:缓存、队列消费不及时、作用域未释放等。
缓存:

  • 限制内存当缓存,要限制好大小,做好释放
  • 进程之间不能共享内存,所以用内存做缓存也是

为了加速模块引入,模块会在编译后缓存,由于通过exports导出(闭包),作用域不会释放,常驻老生代。要注意内存泄漏。

var arr = [];
exports.hello = function() {
    arr.push("hello" + Math.random());
};
//局部变量arr不停增加内存占用,且不会释放,如果必须如此设计,要提供释放接口

队列状态

  • 在生产者和消费者中间
  • 监控队列的长度,超过长度就拒绝
  • 任意的异步调用应该包含超时机制

内存泄露排查的工具

  • node-heapdump
  1. 安装 npm install heapdump
  2. 引入 var heapdump = require(‘heapdump’);
  3. 发送命令kill -USR2 ,heapdump会抓拍一份堆内存快照,文件为heapdump-..heapsnapshot格式,是json文件
  • node-memwatch
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事件。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章