Node.js内存管理和V8垃圾回收机制

对于 Node.js 服务端研发的同窗来讲,关于垃圾回收、内存释放这块不须要向 C/C++ 的同窗那样在建立一个对象以后还须要手动建立一个 delete/free 这样的一个操做进行 GC(垃圾回收), Node.js 与 Java 同样,由虚拟机进行内存自动管理,可是这样并不表示就此能够高枕无忧了,在开发中可能因为疏忽或者程序错误致使的内存泄漏也是一个很严重的问题,因此作为一名合格的服务端研发工程师,仍是有必要的去了解下虚拟机是怎样使用内存的,遇到问题才能从容应对。javascript

快速导航

  • Nodejs中的GC
  • Nodejs垃圾回收内存管理实践
    • 内存泄漏识别
    • 内存泄漏例子
    • 手动执行垃圾回收内存释放
  • V8垃圾回收机制
    • V8堆内存限制
    • 新生代与老生代
    • 新生代空间 & Scavenge 算法
    • 老生代空间 & Mark-Sweep Mark-Compact 算法
    • V8垃圾回收总结
  • 内存泄漏
    • 全局变量
    • 闭包
    • 慎将内存作为缓存
    • 模块私有变量内存永驻
    • 事件重复监听
    • 其它注意事项
  • 内存检测工具

Nodejs中的GC

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,这是来自 Node.js 官网的一段话,因此 V8 就是 Node.js 中使用的虚拟机,在以后讲解的 Node.js 中的 GC 其实就是在讲 V8 的 GC。html

Node.js 与 V8 的关系也比如 Java 之于 JVM 的关系,另外 Node.js 之父 Ryan Dahl 在选择 V8 作为 Node.js 的虚拟机时 V8 的性能在当时已经领先了其它全部的 JavaScript 虚拟机,至今仍然是性能最好的,所以咱们在作 Node.js 优化时,只要版本升级性能也会伴随着被提高。java

Nodejs垃圾回收内存管理实践

先经过一个 Demo 来看看在 Node.js 中进行垃圾回收的过程是怎样的?node

内存泄漏识别

在 Node.js 环境里提供了 process.memoryUsage 方法用来查看当前进程内存使用状况,单位为字节git

  • rss(resident set size):RAM 中保存的进程占用的内存部分,包括代码自己、栈、堆。
  • heapTotal:堆中总共申请到的内存量。
  • heapUsed:堆中目前用到的内存量,判断内存泄漏咱们主要以这个字段为准。
  • external: V8 引擎内部的 C++ 对象占用的内存。
/** * 单位为字节格式为 MB 输出 */
const format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
};

/** * 封装 print 方法输出内存占用信息 */
const print = function() {
    const memoryUsage = process.memoryUsage();

    console.log(JSON.stringify({
        rss: format(memoryUsage.rss),
        heapTotal: format(memoryUsage.heapTotal),
        heapUsed: format(memoryUsage.heapUsed),
        external: format(memoryUsage.external),
    }));
}
复制代码

内存泄漏例子

堆用来存放对象引用类型,例如字符串、对象。在如下代码中建立一个 Fruit 存放于堆中。github

// example.js
function Quantity(num) {
    if (num) {
        return new Array(num * 1024 * 1024);
    }

    return num;
}

function Fruit(name, quantity) {
    this.name = name
    this.quantity = new Quantity(quantity)
}

let apple = new Fruit('apple');
print();
let banana = new Fruit('banana', 20);
print();
复制代码

执行以上代码,内存向下面所展现的,apple 对象 heapUsed 的使用仅有 4.21 MB,而 banana 咱们对它的 quantity 属性建立了一个很大的数组空间致使 heapUsed 飙升到 164.24 MB。算法

$ node example.js

{"rss":"19.94 MB","heapTotal":"6.83 MB","heapUsed":"4.21 MB","external":"0.01 MB"}
{"rss":"180.04 MB","heapTotal":"166.84 MB","heapUsed":"164.24 MB","external":"0.01 MB"}
复制代码

咱们在来看下内存的使用状况,根节点对每一个对象都持有引用,则没法释听任何内容致使没法 GC,正以下图所展现的数组

图片描述

手动执行垃圾回收内存释放

假设 banana 对象咱们不在使用了,对它从新赋予一些新的值,例如 banana = null,看下此刻会发生什么?缓存

图片描述

结果如上图所示,没法从根对象在到达到 Banana 对象,那么在下一个垃圾回收器运行时 Banana 将会被释放。安全

让咱们模拟一下垃圾回收,看下实际状况是什么样的?

// example.js
let apple = new Fruit('apple');
print();
let banana = new Fruit('banana', 20);
print();
banana = null;
global.gc();
print();
复制代码

如下代码中 --expose-gc 参数表示容许手动执行垃圾回收机制,将 banana 对象赋为 null 后进行 GC,在第三个 print 打印出的结果能够看到 heapUsed 的使用已经从 164.24 MB 降到了 3.97 MB

$ node --expose-gc example.js
{"rss":"19.95 MB","heapTotal":"6.83 MB","heapUsed":"4.21 MB","external":"0.01 MB"}
{"rss":"180.05 MB","heapTotal":"166.84 MB","heapUsed":"164.24 MB","external":"0.01 MB"}
{"rss":"52.48 MB","heapTotal":"9.33 MB","heapUsed":"3.97 MB","external":"0.01 MB"}
复制代码

下图所示,右侧的 banana 节点没有了任何内容,通过 GC 以后所占用的内存已经被释放了。

图片描述

V8垃圾回收机制

垃圾回收是指回收那些在应用程序中不在引用的对象,当一个对象没法从根节点访问这个对象就会作为垃圾回收的候选对象。这里的根对象能够为全局对象、局部变量,没法从根节点访问指的也就是不会在被任何其它活动对象所引用。

V8堆内存限制

内存在服务端原本就是一个寸土寸金的东西,在 V8 中限制 64 位的机器大约 1.4GB,32 位机器大约为 0.7GB。所以,对于一些大内存的操做需谨慎不然超出 V8 内存限制将会形成进程退出。

一个内存溢出超出边界限制的例子

// overflow.js
const format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
};

const print = function() {
    const memoryUsage = process.memoryUsage();
    console.log(`heapTotal: ${format(memoryUsage.heapTotal)}, heapUsed: ${format(memoryUsage.heapUsed)}`);
}

const total = [];
setInterval(function() {
    total.push(new Array(20 * 1024 * 1024)); // 大内存占用
    print();
}, 1000)
复制代码

以上例子中 total 为全局变量每次大约增加 160 MB 左右且不会被回收,在接近 V8 边界时没法在分配内存致使进程内存溢出。

$ node overflow.js
heapTotal: 166.84 MB, heapUsed: 164.23 MB
heapTotal: 326.85 MB, heapUsed: 324.26 MB
heapTotal: 487.36 MB, heapUsed: 484.27 MB
heapTotal: 649.38 MB, heapUsed: 643.98 MB
heapTotal: 809.39 MB, heapUsed: 803.98 MB
heapTotal: 969.40 MB, heapUsed: 963.98 MB
heapTotal: 1129.41 MB, heapUsed: 1123.96 MB
heapTotal: 1289.42 MB, heapUsed: 1283.96 MB

<--- Last few GCs --->

[87581:0x103800000]    11257 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1290.9) MB, 512.1 / 0.0 ms  allocation failure GC in old space requested
[87581:0x103800000]    11768 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1287.9) MB, 510.7 / 0.0 ms  last resort GC in old space requested
[87581:0x103800000]    12263 ms: Mark-sweep 1283.9 (1287.9) -> 1283.9 (1287.9) MB, 495.3 / 0.0 ms  last resort GC in old space requested


<--- JS stacktrace --->
复制代码

在 V8 中也提供了两个参数仅在启动阶段调整内存限制大小

分别为调整老生代、新生代空间,关于老生代、新生代稍后会作介绍。

  • --max-old-space-size=2048
  • --max-new-space-size=2048

固然内存也并不是越大越好,一方面服务器资源是昂贵的,另外一方面听说 V8 以 1.5GB 的堆内存进行一次小的垃圾回收大约须要 50 毫秒以上时间,这将会致使 JavaScript 线程暂停,这也是最主要的一方面。

新生代与老生代

绝对大多数的应用程序对象的存活周期都会很短,而少数对象的存活周期将会很长为了利用这种状况,V8 将堆分为两类新生代和老生代,新空间中的对象都很是小大约为 1-8MB,这里的垃圾回收也很快。新生代空间中垃圾回收过程当中幸存下来的对象会被提高到老生代空间。

新生代空间

因为新空间中的垃圾回收很频繁,所以它的处理方式必须很是的快,采用的 Scavenge 算法,该算法由 C.J. Cheney 在 1970 年在论文 A nonrecursive list compacting algorithm 提出。

Scavenge 是一种复制算法,新生代空间会被一分为二划分红两个相等大小的 from-space 和 to-space。它的工做方式是将 from space 中存活的对象复制出来,而后移动它们到 to space 中或者被提高到老生代空间中,对于 from space 中没有存活的对象将会被释放。完成这些复制后在将 from space 和 to space 进行互换。

图片描述

Scavenge 算法很是快适合少许内存的垃圾回收,可是它有很大的空间开销,对于新生代少许内存是能够接受的。

老生代空间

新生代空间在垃圾回收知足必定条件(是否经历过 Scavenge 回收、to space 的内存占比)会被晋升到老生代空间中,在老生代空间中的对象都已经至少经历过一次或者屡次的回收因此它们的存活几率会更大。在使用 Scavenge 算法则会有两大缺点一是将会重复的复制存活对象使得效率低下,二是对于空间资源的浪费,因此在老生代空间中采用了 Mark-Sweep(标记清除) 和 Mark-Compact(标记整理) 算法。

Mark-Sweep

Mark-Sweep 处理时分为标记、清除两个步骤,与 Scavenge 算法只复制活对象相反的是在老生代空间中因为活对象占多数 Mark-Sweep 在标记阶段遍历堆中的全部对象仅标记活对象把未标记的死对象清除,这时一次标记清除就已经完成了。

图片描述

看似一切 perfect 可是还遗留一个问题,被清除的对象遍及于各内存地址,产生不少内存碎片。

Mark-Compact

在老生代空间中为了解决 Mark-Sweep 算法的内存碎片问题,引入了 Mark-Compact(标记整理算法),其在工做过程当中将活着的对象往一端移动,这时内存空间是紧凑的,移动完成以后,直接清理边界以外的内存。

图片描述

V8垃圾回收总结

为什么垃圾回收是昂贵的?V8 使用了不一样的垃圾回收算法 Scavenge、Mark-Sweep、Mark-Compact。这三种垃圾回收算法都避免不了在进行垃圾回收时须要将应用程序暂停,待垃圾回收完成以后在恢复应用逻辑,对于新生代空间来讲因为很快因此影响不大,可是对于老生代空间因为存活对象较多,停顿仍是会形成影响的,所以,V8 又新增长了增量标记的方式减小停顿时间。

关于 V8 垃圾回收这块笔者讲的很浅只是本身在学习过程当中作的总结,若是你想了解更多原理,深刻浅出 Node.js 这本书是一个不错的选择,还可参考这两篇文章 A tour of V8: Garbage CollectionMemory Management Reference.

内存泄漏

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存因为某种缘由程序未释放或没法释放,形成系统内存的浪费,致使程序运行速度减慢甚至系统崩溃等严重后果。

全局变量

未声明的变量或挂在全局 global 下的变量不会自动回收,将会常驻内存直到进程退出才会被释放,除非经过 delete 或 从新赋值为 undefined/null 解决之间的引用关系,才会被回收。关于全局变量上面举的几个例子中也有说明。

闭包

这个也是一个常见的内存泄漏状况,闭包会引用父级函数中的变量,若是闭包得不到释放,闭包引用的父级变量也不会释放从而致使内存泄漏。

一个真实的案例 — The Meteor Case-Study,2013年,Meteor 的建立者宣布了他们遇到的内存泄漏的调查结果。有问题的代码段以下

var theThing = null
var replaceThing = function () {
  var originalThing = theThing
  var unused = function () {
    if (originalThing)
      console.log("hi")
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage)
    }
  };
};
setInterval(replaceThing, 1000)
复制代码

以上代码运行时每次执行 replaceThing 方法都会生成一个新的对象,可是以前的对象没有释放致使的内存泄漏。这块涉及到一个闭包的概念 “同一个做用域生成的闭包对象是被该做用域中全部下一级做用域共同持有的” 由于定义的 unused 使用了做用域的 originalThing 变量,所以 replaceThing 这一级的函数做用域中的闭包(someMethod)对象也持有了 originalThing 变量(重点:someMethod 的闭包做用域和 unused 的做用域是共享的),之间的引用关系就是 theThing 引用了 longStr 和 someMethodsomeMethod 引用了 originalThingoriginalThing 又引用了上次的 theThing,所以造成了链式引用。

上述代码来自 Meteor blog An interesting kind of JavaScript memory leak,更多理解还可参考 Node-Interview issues #7 讨论

慎将内存作为缓存

经过内存来作缓存这多是咱们想到的最快的实现方式,另外业务中缓存仍是很经常使用的,可是了解了 Node.js 中的内存模型和垃圾回收机制以后在使用的时候就要谨慎了,为何呢?缓存中存储的键越多,长期存活的对象也就越多,垃圾回收时将会对这些对对象作无用功。

如下举一个获取用户 Token 的例子,memoryStore 对象会随着用户数的增长而持续增加,如下代码还有一个问题,当你启动多个进程或部署在多台机器会形成每一个进程都会保存一份,显然是资源的浪费,最好是经过 Redis 作共享。

const memoryStore = new Map();

exports.getUserToken = function (key) {
    const token = memoryStore.get(key);

    if (token && Date.now() - token.now > 2 * 60) {
        return token;
    }

    const dbToken = db.get(key);
    memoryStore.set(key, {
        now: Date.now(),
        val: dbToken,
    });
    return token;
}
复制代码

模块私有变量内存永驻

在加载一个模块代码以前,Node.js 会使用一个以下的函数封装器将其封装,保证了顶层的变量(var、const、let)在模块范围内,而不是全局对象。

这个时候就会造成一个闭包,在 require 时会被加载一次,将 exports 对象保存于内存中,直到进程退出才会回收,这个将会致使的是内存常驻,因此避免一些不必的模块加载,不然也会形成内存增长。

(function(exports, require, module, __filename, __dirname) {
    // 模块的代码实际上在这里
});
复制代码

一个小的建议,对于一个模块的引用建议仅在头部初次加载以后使用 const 缓存起来,而不是在使用时每次都去加载一次(每次 require 都要进行路径分析、缓存判断的)

例1:

const a = require('a.js') // 推荐

function test() { 
    a.run()
}
复制代码

例2:

function test(){ // 不推荐
  require('a.js').run()
}
复制代码

事件重复监听

在 Node.js 中对一个事件重复监听则会报以下错误,实际上使用的 EventEmitter 类,该类包含一个 listeners 数组,默认为 10 个监听器超出这个数则会报警以下所示,用于发现内存泄漏,也能够经过 emitter.setMaxListeners() 方法为指定的 EventEmitter 实例修改限制。

(node:23992) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 connect listeners added. Use emitter.setMaxListeners() to increase limit
复制代码

Cnode 论栏有篇文章分析了 Socket 重连致使的内存泄漏,参考 原生Socket重连策略不恰当致使的泄漏,还有 Node.js HTTP 模块 Keep-Alive 产生的内存泄漏,参考 Github Node Issues #714

其它注意事项

在使用定时器 setInterval 时记的使用对应的 clearInterval 进行清除,由于 setInterval 执行完以后会返回一个值且不会自动释放。另外还有 map、filter 等对数组进行操做,每次操做以后都会建立一个新的数组,将会占用内存,若是单纯的遍历例如 map 可使用 forEach 代替,这些都是开发中的一些细节,可是每每细节决定成败,每一次的内存泄漏也都是一次次的不经意间形成的。所以,这些点也是须要咱们注意的。

console.log(setInterval(function(){}, 1000)) // 返回一个 id 值
[1, 2, 3].filter(item => item % 2 === 0) // [2]
[1, 2, 3].map(item => item % 2 === 0) // [false, true, false]
复制代码

内存检测工具

node-heapdump

heapdump是一个dumpV8堆信息的工具,node-heapdump

node-profiler

node-profiler 是 alinode 团队出品的一个 与node-heapdump 相似的抓取内存堆快照的工具,node-profiler

Easy-Monitor

轻量级的 Node.js 项目内核性能监控 + 分析工具,github.com/hyj1991/eas…

Node.js-Troubleshooting-Guide

Node.js 应用线上/线下故障、压测问题和性能调优指南手册,Node.js-Troubleshooting-Guide

alinode

Node.js 性能平台(Node.js Performance Platform)是面向中大型 Node.js 应用提供 性能监控、安全提醒、故障排查、性能优化等服务的总体性解决方案。alinode

阅读推荐

相关文章
相关标签/搜索