跳到主要内容

5.2-内存泄漏

Create by fall on 2022-05-19 Recently revised in 2022-05-20

内存

三个主要的 Node 内存消耗区域:

  • 代码 - 代码被执行的地方
  • 调用栈(栈内存)函数和基础数据类型的局部变量(number,string)
  • 堆内存

现在,在堆上分配一些内存

// 模拟字节数的分配
function allocateMemory(size) {
const numbers = size / 8;
const arr = [];
arr.length = numbers;
for (let i = 0; i < numbers; i++) {
arr[i] = i;
}
return arr;
}
const memoryLeakAllocations = [];
const field = "heapUsed";
const allocationStep = 10000 * 1024; // 10MB
const TIME_INTERVAL_IN_MSEC = 40;
setInterval(() => {
const allocation = allocateMemory(allocationStep);
memoryLeakAllocations.push(allocation);
const mu = process.memoryUsage(); // process.memoryUsage 是一个收集堆利用率指标的原生工具。
// # bytes / KB / MB / GB
const gbNow = mu[field] / 1024 / 1024 / 1024;
const gbRounded = Math.round(gbNow * 100) / 100;
console.log(`Heap allocated ${gbRounded} GB`);
}, TIME_INTERVAL_IN_MSEC);

通过该方法实现每隔 40 毫秒分配 10 MB 左右内存,这给垃圾回收足够的时间,将存活的对象晋升到老生代中。

在一台内存为 32GB 的搭载 Windows 10 操作系统的笔记本电脑上,垃圾回收器在放弃并抛出“内存栈溢出”异常之前,会尝试压缩内存作为最后手段。这个进程用了 26.6s 达到了 4.1GB 的内存限制,此时它意识到是时候终止了。V8 垃圾回收器一开始运行在具有严格内存限制的 32 位的浏览器进程中。这些结果暗示内存限制也许来自遗留代码。

内存泄露

内存泄漏(Memory Leak)

随着应用的处理,可能持有越来越多的无用内存,这些无用的内存变多会引起服务器响应速度变慢,严重的情况下导致内存达到某个极限(可能是进程的上限,如 v8 的上限;也可能是系统可提供的内存上限)会使得应用程序崩溃。

在 Node.js 中内存泄露的原因就是本该被清除的对象,被可到达对象引用以后,未被正确的清除而常驻内存。

JS 中常见的内存泄漏有四种原因:

  • 全局变量
  • 闭包
  • DOM 元素的引用
  • 定时器

全局变量

从 root(node)window(浏览器)依次梳理对象的引用,如果能访问到,V8 就会将其标记为可到达对象,反之为不可到达对象。被标记为不可到达对象(即无引用的对象)后就会被 V8 回收。

// 未声明对象。
a = 10;
//全局变量引用
global.b = 11;

闭包

闭包会引用到父级函数中的变量,如果闭包未释放,就会导致内存泄漏。

function out() {
const bigData = new Buffer(100);
inner = function () {
void bigData;
}
}

上面例子是 inner 直接挂在了 root 上,从而导致内存泄漏(bigData 不会释放)。需要注意的是,这里举得例子只是简单的将引用挂在全局对象上,实际的业务情况可能是挂在某个可以从 root 追溯到的对象上导致的。

事件监听

例如对同一个事件重复监听,忘记移除(removeListener),将造成内存泄漏。这种情况很容易在复用对象上添加事件时出现,所以事件重复监听可能收到如下警告:

(node:2752) Warning: Possible EventEmitter memory leak detected。11 haha listeners added。Use emitter。setMaxListeners() to increase limit

例如,Node.js 中 Agent 的 keepAlive 为 true 时,可能造成的内存泄漏。当 Agent keepAlive 为 true 的时候,将会复用之前使用过的 socket,如果在 socket 上添加事件监听,忘记清除的话,因为 socket 的复用,将导致事件重复监听从而产生内存泄漏

原理上与添加事件监听的时候忘了清除是一样的。在使用 Node.js 的 http 模块时,不通过 keepAlive 复用是没有问题的,复用了以后就会可能产生内存泄漏。所以,你需要了解添加事件监听的对象的生命周期,并注意自行移除。

定时器和其它

定时器要记得 setInterval,以及不断调用,没有处理的 setTimeout

一些其他的情况可能会导致内存泄漏,比如缓存。在使用缓存的时候,得清楚缓存的对象的多少,如果缓存对象非常多,需要对缓存最大数量限制。还有就是非常占用 CPU 的代码也会导致内存泄漏,服务器在运行的时候,如果有高 CPU 的同步代码,因为 Node.js 是单线程的,所以不能处理处理请求,请求堆积导致内存占用过高。

定位内存泄漏

  • 正常使用可以复现的泄露
  • 与特殊输入相关的内存泄漏(使用内存快照获取状态,服务端的内存快照需要在生产环境,可能对)

快照工具推荐使用 heapdump 用来保存内存快照,使用 devtool 来查看内存快照。使用 heapdump 保存内存快照时,只会有 Node.js 环境中的对象,不会受到干扰(如果使用 node-inspector 的话,快照中会有前端的变量干扰)。

使用 npm install heapdump -target=Node.js 安装。

将 heapdump 引入代码中,使用 heapdump.writeSnapshot 就可以打印内存快照了了。为了减少正常变量的干扰,可以在打印内存快照之前会调用主动释放内存的 gc() 函数(启动时加上 --expose-gc 参数即可开启)。

const heapdump = require('heapdump');

const save = function () {
gc();
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
}

在打印线上的代码的时候,建议按照内存增长情况来打印快照。heapdump 可以使用 kill 向程序发送信号来打印内存快照(只在 *nix 系统上提供)。

kill -USR2 <pid>

使用对比的方式来定位内存泄漏:推荐打印 3 个内存快照,一个是内存泄漏之前的内存快照,一个是少量测试以后的内存快照,还有一个是多次测试以后的内存快照。第一个内存快照作为对比,来查看在测试后有哪些对象增长。在内存泄漏不明显的情况下,可以与大量测试以后的内存快照对比,这样能更容易定位。

避免内存泄漏

  • ESLint 检测代码检查非期望的全局变量。
  • 使用闭包的时候,得知道闭包了什么对象,还有引用闭包的对象何时清除闭包。
  • 绑定事件的时候,一定得在恰当的时候清除事件。

参考文章

作者链接
CarlosChenhttps://juejin.cn/post/7087578623393136670