首页 > 解决方案 > Node.js 中的内存泄漏 - 如何分析分配树/根?

问题描述

查找内存泄漏是一项非常困难的任务,尤其是涉及到使用许多第三方库的现代 JS 代码时。例如,我目前面临汇总中的内存泄漏,涉及 babel 和自定义 babel 插件。我正在探索几种常见的策略来追捕它们:

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

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

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

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

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

标签: javascriptnode.jsmemory-leaksprofilingv8

解决方案


请注意,虽然您不必在 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 开发工具团队添加节点调试和分析支持是可行的。虽然还有其他可用的工具,但 Chrome 开发工具 (CDT) 可能更成熟(并且可能有更好的资金),这就是为什么我们将(目前)专注于如何使用 Chrome 开发工具进行节点内存分析和调试。

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

  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
  2. 查看工作目录(通常是运行应用程序的文件夹)。有一个新文件,默认情况下名为Heap*.heapprofile.
  3. 在 Chrome 中打开一个新选项卡 → 打开 CDT → 转到内存选项卡
  4. 在底部,按Load→ 选择Heap*.heapprofile
  5. 完毕。您现在可以看到在录制结束时仍然存在的内存被分配到了哪里。

方法 1 的注意事项

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

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

cdt 内存选择

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

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

我不知道是否有隐藏的方法可以更改 Node 的配置文件模式,但我没有找到它。我尝试了一些东西,包括从这个未记录的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函数是我们将对象分配给 file-scoped 的地方a
    • 这才是真正的泄露!我们可以通过不将其分配给 来修复它a,或者确保a在不再使用它之后清除它。

结论

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


推荐阅读