内存管理一直是我们开发过程中备受关注的问题。每个程序都会占用计算机的一部分内存,因此我们必须了解分配和释放内存的原理。

Node.js凭借其高效的自动垃圾回收机制,让开发人员无需关心内存管理细节。虽然这对开发者很友好,但了解V8和Node.js中的内存管理机制仍然很重要,尤其是在处理大型应用程序时。

这篇文件讲一讲怎么合理地分配和释放内存,以及在Node.js中如何防止内存泄漏。

一、为什么高效的堆内存使用在Node.js中很重要?

在执行程序时,变量和对象存储在栈或堆中。ECMAScript规范本身并没有规定如何分配和管理内存,实现细节依赖于JavaScript引擎和底层系统架构。

堆是一大段连续的内存块,当数据存储在堆中后,除非被垃圾回收器删除,否则会一直占据内存。如果分配不当的话,内存就会被浪费,甚至引发内存泄漏。

V8采用分代回收的垃圾回收管理方案,将堆内存分为新生代和老生代区域。新生代区域较小,老生代区域较大。

新生代区域通过Scavenge算法进行垃圾回收,将空间一分为二,一个叫From-Space,一个叫To-Space,From和To是相对的。分配对象时,会先分配到From-Space。当垃圾回收时,检查From-Space的存活对象,复制到To-Space中。完成复制后,From-SpaceTo-Space对调。如果仅仅在这两个Space中复制移动,新生代区域也很快占满,需要将部分数据移入老生代区域,这个移动称为晋升。对象晋升需要满足两个条件之一:1、对象之前经历过From-SpaceTo-Space的复制过程 2、To-Space的占用率超过25%。

老生代区域采用Full Mark-Compact方案进行垃圾回收,包括标记清除整理三个阶段。通过判断可访问性标记出可达对象,将需要清理的对象所占用的内存空间添加到空闲空间列表中,在之后新对象分配内存时,可以从空闲空间列表找到大小合适的空间直接利用。当内存占用高度分散时,会整理老生代区域,即复制存活对象到一段连续的空间。因为要回收的对象在老生代区域只是少部分,清理和整理的频率实际上很低。

二、导致Node.js内存泄漏的原因

垃圾收集器会查找不再使用的对象并释放内存,但有时它可能无法跟踪每一个对象引用,尤其是对于大型应用程序,这就导致了内存泄漏的发生。

内存泄漏最常见的原因有以下几种:

  • 多处引用
  • 全局变量
  • 闭包
  • 定时器
  • 事件

三、如何定位Node.js内存泄漏的问题

有几种工具可用于检测和调试Node.js中的内存泄漏,包括Chrome DevTools、Node的process.memoryUsage API等。

Chrome DevTools

如果你的Node.js程序入口是./src/main.js,先执行node --inspect ./src/main.js调试程序,再打开Chrome浏览器的chrome://inspect,可以看到以下界面:

点击inspect,选择内存tab的拍摄堆快照:

使用快照功能可以检查变量和它们的Retained Size大小,也可以使用多个快照,比如在内存泄漏之前拍摄快照,在内存泄漏之后拍摄另一个快照,然后将两者进行比较。

界面上有两项数据,Shadow Size(浅层大小)和Retained Size(保留的大小)。
Shadow Size指的是对象本地的大小,除了数组和字符串之外,大多数对象的浅层大小都不会太大。
Retained Size指的是对象所引用内存的大小,回收对象会将引用的内存也一并回收,所以指代的是回收内存后会释放出来的内存大小。

使用Chrome DevTools不是生成快照的唯一办法,还可以在Node程序运行时打点触发。在NodeJS 11.13之前,使用heapdump,在11.13之后,使用

1
require("v8").writeHeapSnapshot();

生成快照后同样导入到Chrome DevTools中分析。

更多内存快照的使用方法可参考NodeJS官方文档

process.memoryUsage API

还可以使用Node的process.memoryUsage API查看内存使用情况。程序中运行process.memoryUsage(),返回以下指标:

rss - 分配的内存量
heapTotal - 已分配堆的总大小
heapUsed - 执行进程时使用的内存总量
arrayBuffers - 为Buffer实例分配的内存

四、垃圾回收机制

垃圾回收器负责释放内存,为了有效地工作,垃圾回收算法必须正确定义和识别可以释放内存的情况。

引用计数垃圾回收算法中,如果堆中的对象在堆栈中不再有引用,即引用计数为0,则该对象将被垃圾回收。尽管此算法在大多数情况下都有效,但不适用于循环引用的情况下。

Node.js不采用引用计数的算法,而是通过判断能否从根对象直接或间接访问目标对象,来决定是否需要回收。在浏览器中,根对象是window;在Node.js中,根对象是global。这个算法叫作标记扫除,解决了循环引用的问题。开发者可以显式声明对象无法从根对象访问,以确保目标对象被垃圾回收。

五、如何避免内存泄漏

1、避免使用全局变量

全局变量包括使用var关键字、this关键字声明的变量和未使用关键字声明的变量。它们始终可以从根访问,因此除非明确设置为null,否则无法进行垃圾回收。

1
2
3
4
5
function variables() {
this.a = 'Variable one';
var b = 'Variable two';
c = 'Variable three';
}

以上代码3个变量都是全局变量,为了避免这种情况,应该采用严格模式来写代码。

2、使用JSON.parse

JSON的语法比JavaScript简单得多,因此它比JavaScript对象更容易解析。

事实上,如果您使用大型JavaScript对象,通过将字符串化形式解析为JSON,可以在V8和Chrome中将性能提高1.7倍

在其它JavaScript引擎如 Safari中,性能可能会更高。这种优化方法在Webpack中用于提高前端应用程序的性能。

1
const obj = { name: 'pomelo' };

改写为以下形式性能更好:

1
const obj = JSON.parse('{"name":"pomelo"}');

3、大数据分批处理

在处理大数据,通常是Excel文件导出时,往往会遇到内存不足的问题。比增加机器内存更好的办法,是将数据分块,分批导出。

4、注意定时器的使用

确保定时器能在生命周期中得到清除,在恰当的时机调用clearTimeoutclearImmediateclearInterval是个好习惯。

5、及时移除事件监听器

当添加事件监听器后,要记得在不使用时移除,同时防止多次注册同一监听器,还要限制同个事件的订阅器个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const obj = {
// 大对象
};
function listener() {
func(obj);
}

// 限制订阅器个数
eventBus.setMaxListeners('eventName', 10);

eventBus.on('eventName', listener);

// 生命周期销毁阶段记得移除
eventBus.off('eventName', listener);

6、释放闭包中无用的变量

如果一个变量在一个函数中使用,它会在函数返回时被标记为垃圾收集——但对于闭包来说可能不是这种情况。

1
2
3
4
5
6
7
const func = () => {
let obj1 = { name: 'a' };
// 省略若干行依赖obj1的逻辑代码
// ...
let obj2 = { name: 'b' };
return () => obj2;
};

上面例子中,obj1因为在闭包中,即使程序执行完,也不会被垃圾回收,可以手动声明释放:

1
2
3
4
5
6
7
8
const func = () => {
let obj1 = { name: 'a' };
// 省略若干行依赖obj1的逻辑代码
// ...
let obj2 = { name: 'b' };
obj1 = null;
return () => obj2;
};