这篇文章上次修改于 340 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

Node.js内存管理机制

对于一个高性能的web服务而言,内存管理的好坏也会直接影响到服务的质量。Node.js采用的是V8引擎来处理内存管理的问题,V8就是Node.js使用的虚拟机。Node.js 选择 V8 做为 Node.js 的虚拟机时, V8 的性能在当时已经领先了其它所有的 JavaScript 虚拟机,至今仍然是性能最好的。

内存管理

V8的内存存在一个限制, 64 位的机器大约 1.4GB,32 位机器大约为 0.7GB,超越限制就会造成进程的退出。

Node.js可通过process.memoryUsage()来查看当前进程的使用内存的情况,返回的是一个对象,属性单位为字节。

  • rss(resident set size):进程的常驻内存,包括代码本身、栈、堆。
  • heapTotal:堆中总共申请到的内存量。
  • heapUsed:堆中目前用到的内存量,判断内存泄漏我们主要以这个字段为准。
  • external: V8 引擎内部的 C++ 对象占用的内存。

举个例子:

// test.js
const forMat = (bytes) => {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}

const showMem = function () {
    const mem = process.memoryUsage();
    console.log('Process: heapTotal ' + forMat(mem.heapTotal) +
        '-----heapUsed ' + forMat(mem.heapUsed) + '-----rss ' + forMat(mem.rss));
    console.log('-----------------------------------------------------------');
};

const useMem = function () {
    const size = 20 * 1024 * 1024;
    const arr = new Array(size);
    for (let i = 0; i < size; i++) {
        arr[i] = 0;
    }
    return arr;
};

const total = [];
for (let j = 0; j < 15; j++) {
    showMem();
    total.push(useMem());
}
showMem();

输出的结果:

Process: heapTotal 5.20 MB-----heapUsed 2.62 MB-----rss 29.11 MB
-----------------------------------------------------------
Process: heapTotal 167.21 MB-----heapUsed 163.46 MB-----rss 190.79 MB
-----------------------------------------------------------
Process: heapTotal 327.23 MB-----heapUsed 323.47 MB-----rss 350.80 MB
-----------------------------------------------------------
Process: heapTotal 487.24 MB-----heapUsed 483.47 MB-----rss 510.90 MB
-----------------------------------------------------------
Process: heapTotal 647.25 MB-----heapUsed 643.48 MB-----rss 671.00 MB
-----------------------------------------------------------
Process: heapTotal 807.26 MB-----heapUsed 803.48 MB-----rss 830.85 MB
-----------------------------------------------------------
Process: heapTotal 967.77 MB-----heapUsed 962.56 MB-----rss 990.92 MB
-----------------------------------------------------------
Process: heapTotal 1127.79 MB-----heapUsed 1122.56 MB-----rss 1151.00 MB
-----------------------------------------------------------
Process: heapTotal 1287.80 MB-----heapUsed 1282.56 MB-----rss 1287.96 MB
-----------------------------------------------------------
<--- Last few GCs --->
[14991:0x2d1d010]     1711 ms: Mark-sweep 1282.5 (1287.3) -> 1282.5 (1287.3) MB, 129.6 / 0.0 ms  (average mu = 0.428, current mu = 0.017) last resort GC in old space requested
[14991:0x2d1d010]     1851 ms: Mark-sweep 1282.5 (1287.3) -> 1282.5 (1286.8) MB, 139.9 / 0.0 ms  (average mu = 0.271, current mu = 0.000) last resort GC in old space requested
<--- JS stacktrace --->

不难看出由于内存超过限制,for循环只执行了10次就被强行停止了。

但是有例外,因为Node.js的内存并不是V8全权分配的,那些不是V8分配的内存称为堆外内存

举个例子:将useMem重写

const useMem = function () {
    const size = 200 * 1024 * 1024;
    const buffer = new Buffer(size);
    for (let i = 0; i < size; i++) {
        buffer[i] = 0;
    }
    return buffer;
};

再次执行test.js

$ node test.js
Process: heapTotal 5.20 MB-----heapUsed 2.62 MB-----rss 28.78 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 3.72 MB-----rss 230.91 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 3.73 MB-----rss 431.14 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.70 MB-----heapUsed 2.98 MB-----rss 631.18 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.70 MB-----heapUsed 2.99 MB-----rss 831.25 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.70 MB-----heapUsed 2.75 MB-----rss 1031.31 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 10.20 MB-----heapUsed 2.73 MB-----rss 1231.36 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 10.20 MB-----heapUsed 2.73 MB-----rss 1431.42 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 10.20 MB-----heapUsed 2.73 MB-----rss 1631.48 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 2.73 MB-----rss 1831.12 MB[objizhiject Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 2.73 MB-----rss 2031.18 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 2.73 MB-----rss 2230.99 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 2.73 MB-----rss 2427.23 MB[object Object]
-----------------------------------------------------------
Process: heapTotal 7.20 MB-----heapUsed 2.73 MB-----rss 2607.45 MB[object Object]
........

从运行结果看,随着Buffer的内存增加,进程的总内存已经远远超过了限制,heapTotal与heapUsed却变化变化极小,这说明Buffer并不是V8进行分配的,那下面简单介绍下Buffer对象。

Buffer是一个像Array的对象,主要用于操作字节。其结构是js与C++结合的模块,性能相关部分用C++实现,非性能相关的部分用js实现。Buffer是Node.js的全局对象,在进程初始化的时候就已经加载了它,而它的内存是由C++层面申请的。

那为什么不用V8申请呢?

当处理大量的字节数据时采用需要一点就去系统申请一点的内存方式,这就造成了大量的内存申请的系统调用,对操作系统有一定的压力,为此Node.js采用在内存的使用上应用的是在C++层面申请内存、在js中分配内存的策略。

V8提供了选项让我们能使用更多的内存

node --max-old-space-size=xxx test.js (更换老生代内存大小,单位为: MB)

node --max-new-space-size=xxx test.js  (更换新生代内存大小,单位为: KB)

垃圾回收机制

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

新生代与老生代

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 的内存占比超过25%)会被晋升到老生代空间中,在老生代空间中的对象都已经至少经历过一次或者多次的回收所以它们的存活概率会更大。在使用 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 又新增加了增量标记的方式减少停顿时间。

内存泄漏

内存泄漏(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,因此形成了链式引用。

慎将内存做为缓存

通过内存来做缓存这可能是我们想到的最快的实现方式,另外业务中缓存还是很常用的,但是了解了 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()
}

其它注意事项

在使用定时器 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 应用提供 性能监控、安全提醒、故障排查、性能优化等服务的整体性解决方案。