Node.js中的内存泄漏-如何分析分配树/根?

查找内存泄漏是一项非常困难的任务,尤其是在涉及使用许多第三方库的现代 JS 代码时。

例如,我目前在 rollup 中面临内存泄漏,涉及 babel 和自定义 babel 插件。

我正在探索几种常见的策略来追捕它们:

  1. 了解您的运行时及其内存取消分配方案,并遵循有关该方案的最佳实践。
    • 本文声称所有现代 JS 运行时实现都使用标记和清除垃圾收集器。它的主要优势之一是它可以正确处理循环引用。(这篇文章还链接了这篇非常过时的研讨会论文。不要太在意它,因为它都是关于循环引用的,这应该不再是问题了。)
    • 这篇文章深入探讨了 V8 内存管理(注意:Node 和 Chrome 都基于 V8)。
  2. 如果您发现内存或 GC 使用量超出您的预期,请分析您的堆内存配置文件以找出内存分配的位置。
    • 这个 SO answer解释了如何在 Chrome 中做到这一点,但它的链接已经过时了。这是指向相关 Chrome 文档的直接链接(截至 2021 年)。
    • 对于Node,我发现了很多过时的信息。目前,分析堆内存配置文件的最简单方法似乎是使用实验性的--heap-prof命令行参数(例如node --heap-prof node_modules/rollup/dist/bin/rollup -c分析汇总构建)。然后在 Chrome Dev Tools 中打开它,通过Memory-> Load
    • 分析后,我们可以了解大部分内存是在哪里/如何分配的;但一个关键问题尚未得到解答:
  3. 既然你知道谁是罪魁祸首(记忆猪),你怎么能找出他们为什么/在哪里仍然挥之不去?而且,更重要的是:内存占用对象的 GC 根(堆栈指针)是什么?

最后一个问题也是我在这里的问题:我们如何分析 Node(或一般的 V8)中的对象分配树?我怎样才能找出我在步骤 (2) 中识别的对象在哪里踢?

通常,这个问题的答案会告诉我们在哪里更改代码以阻止泄漏。(当然,如果您的问题是内存流失,而不是内存泄漏,那么这个问题可能不是那么重要。)

在我的例子中,我知道内存被 Babel AST 节点和路径对象占用,但我不知道它们为什么会徘徊,即我不知道它们存储在哪里。如果你只是自己运行 Babel,你可以验证它不是 Babel 泄漏内存。我目前正在尝试各种技巧来找出它们的存储位置,但仍然没有运气。

遗憾的是,到目前为止,我还没有找到任何工具来帮助解决问题 (3)。即使相关有深度的文章(比如这和这里的slidedeck)手动绘制了堆分配步骤。感觉好像没有这样的工具,还是我错了?如果没有工具,也许在某个地方有关于这个的讨论?

回答

请注意,虽然您不必在 JS 中明确地释放内存,但仍然可能出现内存泄漏。同时,Node 内存分析实用程序(几乎是犯罪)记录不足。让我们了解如何使用它们。

TLDR:跳到下面带有示例的动手部分,标题为“查找内存泄漏(带有示例)”。

JS 中的内存泄漏

由于 JS 有GC,内存泄漏只有几个可能的原因:

  • 您正在使用(“保留”)不再使用的大对象,通常在文件或全局范围内的变量中。这要么是偶然的,要么是简单(不确定)缓存方案的一部分:

    let a;
    function f() {
      a = someLargeObject;
    }
    
  • 有时对象在保留的闭包中徘徊。例如:

    let cb;
    function f() {
      const a = someLargeObject;  // `a` is retained as long as `cb`
      cb = function g() {
        eval('console.log(a)');
      };
    }
    

您可以通过从不存储到或手动清除这些变量来轻松修复此类内存泄漏。主要的困难是找到这些挥之不去的物体。

使用 Chrome 开发工具分析节点应用程序

首先,Node.js 和 Chrome 都使用相同的 JS 引擎:v8。因此,Chrome 开发工具团队添加 Node 调试和分析支持是可行的。虽然还有其他可用的工具,但 Chrome Dev Tools (CDT) 可能更成熟(并且资金可能更好),这就是我们(目前)将重点放在如何使用 Chrome Dev Tools 进行 Node 内存分析和调试的原因。

使用 CDT 分析 Node 内存的主要方法有两种:

  1. 运行您的应用程序--heap-prof以生成堆配置文件日志文件。然后在 CDT 中加载并分析日志。
  2. 使用--inspect/--inspect-brk标志运行您的应用程序,以便在 CDT 中调试您的 Node 应用程序。然后只需根据自己的喜好使用 CDT 的Memory选项卡(此处的文档)。

方法一: heap-prof

运行您的应用程序--heap-prof以生成堆配置文件日志文件。然后在 CDT 中加载并分析日志。

脚步

  1. heap-prof启用的情况下运行您的应用程序。例如:node --heap-prof app.js
    • 您可以使用其他heap-prof相关的命令行标志进一步自定义它。
  2. 查看工作目录(通常是您运行应用程序的文件夹)。有一个新文件,默认情况下名为Heap*.heapprofile.
  3. 在 Chrome 中打开一个新标签?打开 CDT ? 转到内存选项卡
  4. 在底部,按Load? 选择Heap*.heapprofile
  5. 完毕。您现在可以看到在录制结束时仍处于活动状态的内存被分配到何处。

方法 1 的注意事项

这一步允许您首先验证内存泄漏,并找出可能导致它的分配或对象类型。

我们来看看 CDT 的内存分析工具。它具有三种模式:

可悲的是,记录的日志--heap-prof仅包含模式1的数据。但是,这种模式不足以回答OP的第三个问题:如何找出分配的对象仍然存在的原因/位置(即:未使用后“保留”不再)?

如选项卡中所述:回答该问题需要第二种模式。

不知道有没有隐藏的修改Node的profile模式的方法,不过我没找到。我尝试了一些事情,包括从这个未记录的Node.js CLI 标志列表中添加。

这就是@jmrk在他的回答中提出方法(2)的原因:

方法二:inspect/inspect-brk

使用--inspect/--inspect-brk标志运行您的应用程序,以便在 CDT 中调试您的 Node 应用程序。然后只需根据自己的喜好使用 CDT 的Memory选项卡(此处的文档)。

脚步

  1. 在调试模式下运行应用程序,并在开始时停止执行: node --inspect-brk app.js
  2. chrome://inspect在 Chrome 中打开。
  3. 几秒钟后,您的应用程序应显示在列表中。选择它。
  4. CDT 已启动,您会看到执行在应用程序的入口点停止。
  5. 转到“内存”选项卡,选择第二种模式,然后按“录制”按钮
  6. 继续执行,直到记录到内存泄漏。为此,要么在某处设置断点,要么,如果泄漏一直持续到最后,就让应用程序自然退出。
  7. 返回“内存”选项卡并再次按“录制”按钮停止录制。
  8. 您现在可以分析日志(见下文)。

方法 2 的注意事项

  1. 因为您现在正在调试模式下运行整个应用程序,所以一切都慢了很多
  2. 堆模式 2 通常需要更多内存。如果内存超过你的 Node 默认内存限制(大约 2GB),它就会崩溃。监控您的内存使用情况,并可能使用类似--max-old-space-size=4096(或更大的数字)来加倍默认值。或者,更好的是,如果可能,简化您的测试用例以使用更少的内存并加快分析速度。
  3. “记录分配堆栈”选项显示分配任何对象时的调用堆栈。这类似于 Profile 模式 1 的功能。查找内存泄漏不是必需的。到目前为止我还不需要它,但是如果您需要将延迟对象映射到它们的分配,这应该会有所帮助。

查找内存泄漏(带有示例)

按照方法 2 的步骤操作后,您现在正在查看查找泄漏所需的所有信息。

让我们看一些基本的例子:

示例 1

代码

下面的代码举例说明了一个简单的内存泄漏:文件范围a永远存储数据。

完整的要点在这里。

let a;
function test1() {
  const b = [];
  addPressure(N, b);
  a = b;
  gc(); // --expose-gc
}

test1();
debugger;

笔记:

  • 我们的目标是找到“挥之不去”的对象;哪些是“不可收集”的对象;即使不再使用也已保留的对象。这就是为什么我通常会gc在分析时调用的原因。通过这种方式,我们可以确保摆脱所有可收集的引用,并明确关注“挥之不去”的对象。
    • 您需要呼叫expose-gc标志gc();例如:node --inspect-brk --expose-gc app.js

内存视图

一旦断点命中,我就停止录制并得到以下信息:

  • Constructor视图列出了所有延迟对象,按构造函数/类型分组。
    • 确保您按Shallow Size或排序Retained Size(两者都在此处解释)
  • 我们发现它string占用了大部分内存。让我们打开它。
    • 在 each 下方Constructor,您可以找到所有单个对象的列表。第一个(最大的)对象通常是罪魁祸首。选择第一个。
  • Retainers视图现在向您显示该对象仍被保留的位置。
    • 在这里,您想找到长期保留它的功能(使其“徘徊”)。

Retainers视图上的文档不是很完整。这就是我尝试导航它的方式,直到它吐出我正在寻找的代码行:

  • 选择一个对象。
    • (同样,通常最容易浏览此列表,按大小排序。)
  • 在对象的树视图条目内:打开嵌套的树视图条目。
  • 查找任何与代码行相关的内容(显示在第一列的右侧)。
  • 标有“上下文”的条目可能比其他条目更有用。

我的发现显示在此屏幕截图中:

我们看到三个函数在这个对象的挥之不去中起作用:

  • 调用的函数gc- 我不知道为什么会这样。可能与GC内部有关。可能是因为它gc会缓存对某些(如果不是全部)延迟对象的引用。
  • addPressure函数分配的对象。这也是保留它的引用的来源。
  • test1函数是我们将对象分配给文件范围的a.
    • 这才是真正的泄漏!我们可以通过不将其分配给 来修复它a,或者确保我们a在不再使用它后清除它。

结论

我希望,这可以帮助您开始寻找和消除内存泄漏的激动人心的旅程。请随时在下面询问更多信息。


以上是Node.js中的内存泄漏-如何分析分配树/根?的全部内容。
THE END
分享
二维码
< <上一篇
下一篇>>