内存管理一直是计算机科学中备受关注的问题。每个软件都分配了计算机有限内存的一小部分;必须妥善管理此内存(仔细分配和释放)。

凭借其高效的自动垃圾回收机制,Node.js 试图处理繁琐的内存管理任务,并让开发人员腾出时间来处理其他任务。虽然这很好,但了解 V8 和 Node.js 中的内存管理机制仍然很重要,尤其是在处理大型应用程序时。

本文解释了如何在堆中分配和释放内存。我们还将提供指南来帮助您最大限度地减少堆分配并防止 Node.js 中的内存泄漏。

我们走吧!

Node.js 中的堆分配

JavaScript 和 Node.js 为您抽象了很多东西,并在后台完成了大部分繁重的工作。

在执行程序时,变量和对象存储在堆栈或堆中。JavaScript 代码存储在要执行的执行上下文中。

ECMAScript 规范本身并没有规定如何分配和管理内存。这是一个依赖于 JavaScript 引擎和底层系统架构的实现细节。深入了解引擎如何处理变量超出了本文的范围,但如果您想了解更多关于 V8 是如何做到的,请参阅文章 JavaScript 内存模型揭秘和数据如何存储在V8 JS中引擎记忆?.

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

存储在堆中的内存将继续存在,除非它被垃圾收集器删除或释放。堆是一大块连续的内存块,预计即使在分配和释放之后也会如此。

不幸的是,由于堆内存的分配和释放方式,内存可能会被浪费并导致泄漏。

V8 使用Generational Garbage Collection,即将对象划分为不同的代(年轻代和老年代)。世代进一步划分为分区——例如,年轻一代由新空间组成,老年代分为旧空间、映射空间和大对象空间。新对象最初分配在新生代的托儿所,当内存用完时,垃圾收集器会运行清理以释放空间。在一次 GC 运行中幸存下来的对象被复制到年轻代的中间空间,而在第二次运行中幸存下来的对象被移动到老年代。

由于运行程序涉及积累宝贵的虚拟内存资源,因此程序必须在不再需要时释放内存(这就是释放)。

此外,如果内存被释放(不管它在堆中的哪个位置被释放),堆应该合并以保持连续的内存块形式。由于堆内存中的复杂性增加,在此处存储内存会导致更高的开销(但具有更大的灵活性)。

尽管 Node.js 具有高效的垃圾回收机制,但堆的低效使用会导致内存泄漏。应用程序可能会耗尽太多内存甚至崩溃。

Node.js 堆中内存泄漏的原因

垃圾收集器努力寻找和释放孤立的内存,但有时它可能无法跟踪每一块内存。这可能会导致不必要的负载增加,尤其是对于大型应用程序。稍后我们将详细讨论垃圾收集器在 Node.js 中的工作方式。

内存泄漏的一些最常见原因包括:

  • 多重参考
  • 全局变量
  • 关闭
  • 定时器
  • 事件

对一个对象有多个指针或引用很容易。虽然这对您来说可能很方便,但如果一个对象的引用被垃圾回收,而其他引用则没有,它也会导致内存问题。

忘记计时器和回调是 Node.js 和 JavaScript 应用程序中内存泄漏的两个最常见原因。绑定到计时器的对象在超时之前不会被垃圾回收。如果计时器永远运行(这很容易在错误代码中发生),对象将永远不会被垃圾回收。即使没有指向对象的指针也会发生这种情况,因此会在堆中造成内存泄漏。

考虑以下程序:

const language = () => {
  console.log("Javascript");
  setTimeout(() => language(), 1000);
};

该程序将永远运行并且永远不会被垃圾收集。

如何在 Node.js 中查找内存泄漏

有多种工具可用于检测和调试 Node.js 中的内存泄漏,包括 Chrome DevTools、Node 的process.memoryUsageAPI 和 AppSignal 的垃圾收集魔术仪表板。

Chrome 开发者工具

Chrome DevTools 可能是最简单的工具之一。要激活调试器,您需要以检查模式启动 Node。跑来node --inspect做这个。

更具体地说,如果您的 Node 的入口点是app.js,您将需要运行node --inspect app.js以调试 Node 应用程序。然后,打开 Chromium 浏览器并转到chrome://inspect. 您还可以在 Edge 上打开检查器页面edge://inspect。在检查器页面上,您应该看到如下页面:

Chrome 检查器设备

请注意,您尝试调试的 Node 应用程序出现在检查器页面的底部。单击inspect打开调试器。调试器有两个重要的选项卡 —MemoryProfiler— 但我们将在本次讨论中重点关注Memory选项卡。

节点调试器内存选项卡

使用 Chrome 调试器查找内存泄漏的最简单方法是获取堆快照。快照可以帮助您检查一些变量或检查它们的保持器大小。

您还可以比较多个快照以查找内存泄漏。例如,您可以在内存泄漏前拍一张快照,在内存泄漏后拍一张快照,然后比较两者。要拍摄快照,请单击Heap snapshot,然后单击Take snapshot按钮。这可能需要一些时间,具体取决于您的应用程序的Total JS heap size. 您还可以通过单击LoadDevTool 底部的按钮来加载现有快照。

当您有两个或多个快照时,您可以轻松地比较堆分配以找到内存泄漏的来源。您可以通过以下方式查看快照:

  • 总结- 按照构造函数名称对 Node 应用程序中的对象进行分组
  • 比较- 显示两个快照之间的差异
  • Containment - 允许您探索堆内容并分析全局命名空间中引用的对象
  • 统计数据
Chrome DevTools 内存配置文件调试器视图

在 DevTools Heap Profiler 中有两列很突出——即Shadow SizeRetainer Size

Shadow Size对象本身的内存大小。对于大多数对象来说,这个内存大小不会很大,数组和字符串除外。另一方面,是Retained Size当相关对象和依赖对象被释放或从根无法访问时释放的内存大小。

Chrome DevTools 并不是获取堆快照的唯一方法。如果您使用 Node.js 12.0 或更高版本,您还可以通过运行命令来获取快照node --heapsnapshot-signal

node --heapsnapshot-signal=SIGUSR2 app.js

SIGUSR1虽然您可以为此使用任何信号,但SIGUSR2建议使用用户定义的信号。

如果您想从服务器上运行的应用程序获取堆快照,最好使用包writeHeapSnapshot中的函数v8

require("v8").writeHeapSnapshot();

此方法在 Node.js 11.13 或更高版本中可用。在早期版本中,您可以使用 heapdump 包获得相同的结果。

堆快照并不是使用 Chrome DevTools 调试内存问题的唯一方法。您还可以使用 跟踪每个堆分配Allocation instrumentation on timeline

分配时间线显示随时间变化的检测内存分配。要启用此功能,请启动探查器,然后运行您的应用程序示例以开始调试内存问题。如果要记录长时间运行的操作的内存分配并打算最小化性能开销,最好的选择是方法Allocation sampling

节点的process.memoryUsageAPI

您还可以使用 Node 的process.memoryUsageAPI 观察内存使用情况。运行process.memoryUsage(),您将访问以下内容:

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

AppSignal 的垃圾收集魔术仪表板

为了关注堆的增长,AppSignal 有一个方便的垃圾收集魔术仪表板。当您将 Node.js 应用程序连接到 AppSignal 时,会自动为您生成此仪表板!

查看此示例,您可以在“V8 堆统计”图表中清楚地看到内存使用量激增:

堆仪表板

如果此仪表板显示稳定增加,则您的代码或依赖项中可能存在内存泄漏。

发现更多关于 AppSignal for Node.js 的信息

现在是了解垃圾回收工作原理的好时机。

垃圾收集的工作原理

您知道如何查找内存泄漏,但如何修复它们呢?我们很快就会知道,但首先,了解 Node.js 和 V8(通常)如何处理垃圾收集很重要。

垃圾收集 (GC) 在不需要时释放(解除分配)内存。为了有效地工作,垃圾收集算法必须正确定义和识别不需要内存的含义

在引用计数 GC 算法中,如果堆中的对象在堆栈中不再有引用,则该对象将被垃圾回收。该算法通过对引用进行计数来工作——因此如果引用计数为零,则该对象将被垃圾回收。尽管此算法在大多数情况下都有效,但在循环引用时却失败了。

考虑以下示例。

let data = {};
data.el = data;
 
let obj1 = {};
let obj2 = {};
obj1.a = obj2;
obj2.a = obj1;

具有循环引用的对象永远不会超出范围或被垃圾收集,即使不再需要或使用它们也是如此。这会形成内存泄漏并使您的应用程序效率低下。值得庆幸的是,Node.js 不再使用这种算法进行垃圾回收。

Node.js 不是计算引用,而是认为一个对象准备好进行垃圾回收,如果它不能再从根访问或引用(直接或间接)。

Javascript 中的根是一个全局对象。在浏览器中,根是window对象,但在 Node.js 中,根是对象global。该算法比引用计数算法更高效,解决了循环引用问题。

考虑到上面的例子,虽然obj1obj2仍然有一个循环引用,但如果它们不再可以从根访问(不再需要),它们将被垃圾收集。

这个算法,俗称mark and sweep算法,很有用。但是,您必须小心并显式地使对象无法从根访问,以确保它被垃圾收集。

修复 Node.js 应用程序中的内存泄漏

现在是有趣的部分!下面是一些改进堆内存分配和防止内存泄漏的方法。

避免全局变量

全局变量包括用关键字声明的变量varthis关键字和未用关键字声明的变量。

我们已经确定意外的全局变量(以及任何其他形式的全局变量)会导致内存泄漏。它们始终可以从根访问,因此除非明确设置为 null,否则无法进行垃圾回收。

考虑以下示例:

function variables() {
  this.a = "Variable one";
  var b = "Variable two";
  c = "Variable three";
}

这三个变量是全局变量。为避免使用全局变量,请考虑strict通过将指令添加use strict到文件顶部来切换模式。

使用JSON.parse

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

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

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

例如,而不是使用以下 JavaScript 对象:

const Person = { name: "Samuel", age: 25, language: "English" };

将它们字符串化然后将其解析为 JSON 会更有效。

const Person = JSON.parse('{"name":"Samuel","age":25,"language":"English"}');

将大数据处理拆分为块和 Spawn 进程

Heap out of memory在处理大型数据集(例如大型 CSV 文件)时,您可能会遇到奇怪的错误。当然,您可以增加应用程序的内存限制来处理这些处理,但更好的方法是将数据分成块。

在某些情况下,在多核机器上扩展您的 Node.js 应用程序可能会有所帮助。这涉及将您的应用程序分离为主进程和辅助进程。workers 处理繁重的逻辑,而 master 控制 workers 并在内存耗尽时重新启动它们。

有效地使用定时器

我们已经确定计时器会导致内存泄漏。要改进堆内存管理,请确保您的计时器不会永远运行。

特别是,当使用 创建定时器时,在不再需要定时器时setInterval调用它是至关重要的。clearInterval

当您不再需要使用or创建计时器时调用clearTimeoutor也是一个好习惯。clearImmediatesetTimeoutsetImmediate

const timeout = setTimeout(() => {
  console.log("timeout");
}, 1500);
 
const immediate = setImmediate(() => {
  console.log("immediate");
});
 
const interval = setInterval(() => {
  console.log("interval");
}, 500);
 
clearTimeout(timeout);
clearImmediate(immediate);
clearInterval(interval);

移除闭包中不需要的变量

闭包是 Javascript 中的一个常见概念。它们是另一个函数中的函数(或回调)。如果一个变量在一个函数中使用,它会在函数返回时被标记为垃圾收集——但对于闭包来说可能不是这种情况。

考虑以下示例:

const func = () => {
  let Person1 = { name: "Samuel", age: 25, language: "English" };
  let Person2 = { name: "Den", age: 23, language: "Dutch" };
 
  return () => Person2;
};

此函数将引用一个范围并将每个变量保留在该范围内。换句话说,当您只需要时, 和 都Person2Person1保留在范围内Person2

结果,这会消耗更多内存,从而导致内存泄漏。要解决此问题,只需声明您需要的变量并清空不需要的变量。

例如:

const func = () => {
  let Person1 = { name: "Samuel", age: 25, language: "English" };
  let Person2 = { name: "Den", age: 23, language: "Dutch" };
  Person1 = null;
  return () => Person2;
};

取消订阅观察者和事件发射器

具有较长生命周期的观察者和事件发射器可能是内存泄漏的来源,特别是如果您在不再需要它们时不取消订阅它们。

考虑以下示例。

const EventEmitter = require("events").EventEmitter;
const emitter = new EventEmitter();
 
const bigObject = {}; //Some big object
const listener = () => {
  doSomethingWith(bigObject);
};
emitter.on("event1", listener);

在这里,我们保留内存直到bigObject监听器从发射器中释放,或者发射器被垃圾回收。要解决此问题,我们需要调用removeEventListener以从发射器释放侦听器。

emitter.removeEventListener("event1", listener);

当有超过 10 个事件侦听器连接到发射器时,也可能发生内存泄漏。大多数时候,您可以通过编写更高效的代码来解决这个问题。

但是,在某些情况下,您可能需要显式设置最大事件侦听器。

例如:

emitter.setMaxListeners(n); //where n is the new maximum event listener.

包起来

在本文中,我们探讨了如何最小化堆并检测 Node.js 中的内存泄漏。

我们首先查看 Node 中的堆分配,包括堆栈和堆的工作方式。然后我们考虑了为什么跟踪您的内存使用情况和内存泄漏的原因很重要。

接下来,我们了解了如何使用 Chrome DevTools、Node 的process.memoryUsageAPI 和 AppSignal 的垃圾收集魔术仪表板查找内存泄漏。

最后,我们了解了垃圾回收的工作原理并分享了一些修复应用内存泄漏的方法。

与任何其他编程语言一样,内存管理在 JavaScript 和 Node.js 中非常重要。我希望您发现这篇介绍很有用。编码愉快!

资料来源和进一步阅读

PS 如果您喜欢这篇文章,请订阅我们的 JavaScript Sorcery 列表,每月深入了解更多神奇的 JavaScript 提示和技巧。

PPS 如果您的 Node.js 应用程序需要 APM,请查看 AppSignal APM for Node.js。

最小化 Node.js 中的堆分配-有关Node.js中的内存泄漏
标签: