提取heap snapshot

在 Node.js 的本地运行环境中,开发者可以利用多种工具来提取堆(heap)转储,以分析内存使用情况,检测内存泄漏,优化性能。以下是一些常用且有效的工具:

heapdump 模块

heapdump 是一个流行的 Node.js 模块,允许在运行时生成 V8 引擎的堆快照。通过这些快照,开发者可以深入分析应用程序的内存使用情况,识别潜在的内存泄漏。

安装与使用:

1
npm install heapdump

在应用程序中引入并使用:

1
2
3
4
5
6
7
8
9
10
const heapdump = require('heapdump');

// 在需要生成堆快照的地方调用
heapdump.writeSnapshot((err, filename) => {
if (err) {
console.error('Heap snapshot failed:', err);
} else {
console.log('Heap snapshot written to', filename);
}
});

生成的 .heapsnapshot 文件可以在 Chrome DevTools 的 Memory 面板中加载和分析。

注意事项:

  • 生成堆快照是同步操作,可能会导致主线程暂停,尤其是在堆内存较大时。
  • 在生产环境中使用时,应谨慎操作,避免对服务造成影响。

v8 模块的 writeHeapSnapshot 方法

自 Node.js v11.13.0 起,v8 模块提供了 writeHeapSnapshot 方法,允许直接生成堆快照,无需额外的第三方模块。

使用示例:

1
2
3
4
5
6
const v8 = require('v8');
const fs = require('fs');

const fileName = `${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(fileName);
console.log('Heap snapshot written to', fileName);

优点:

  • 无需安装额外模块,减少了依赖。
  • 提供了与 heapdump 类似的功能,适用于需要内置解决方案的场景。

使用 Chrome DevTools 的内存分析工具

在本地开发环境中,开发者可以利用 Chrome DevTools 的内存分析工具,直接连接到运行中的 Node.js 进程,生成和分析堆快照。

步骤:

  1. 启动 Node.js 应用时,添加 --inspect 标志:

    1
    node --inspect your_app.js
  2. 在 Chrome 浏览器中,访问 chrome://inspect,找到对应的 Node.js 进程,点击 inspect

  3. 在打开的 DevTools 窗口中,导航到 Memory 面板,选择 Heap snapshot,然后点击 Take snapshot

优点:

  • 提供了直观的图形界面,便于分析。
  • 无需在代码中添加额外的逻辑。

注意事项:

  • 适用于本地开发和调试环境。
  • 在生产环境中使用时,可能需要考虑安全性和性能影响。

使用 --heapsnapshot-signal 标志

自 Node.js v12.0.0 起,提供了 --heapsnapshot-signal 标志,允许在接收到特定信号时生成堆快照。

使用示例:

1
node --heapsnapshot-signal=SIGUSR2 your_app.js

然后,在运行时发送 SIGUSR2 信号:

1
kill -USR2 <pid>

优点:

  • 无需修改应用代码。
  • 适用于需要在特定时刻生成堆快照的场景。

注意事项:

  • 需要确保发送信号的权限。
  • 在生产环境中使用时,应评估对服务的影响。

node-oom-heapdump 模块

node-oom-heapdump 是一个用于在发生内存溢出(Out of Memory)时自动生成堆快照的模块,帮助开发者分析导致内存溢出的原因。

安装与使用:

1
npm install node-oom-heapdump

在应用程序中引入并配置:

1
2
3
4
require('node-oom-heapdump')({
path: '/path/to/dump',
heapdumpOnOOM: true
});

优点:

  • 自动捕获内存溢出时的堆快照,便于事后分析。
  • 提供了与 heapdump 类似的功能,但专注于内存溢出场景。

注意事项:

  • 适用于需要监控内存溢出的应用程序。
  • 在生产环境中使用时,应确保存储空间充足,以保存生成的堆快照。

heapdump-analyser 工具

heapdump-analyser 是一个用于分析堆快照的命令行工具,帮助开发者查找内存泄漏和分析内存使用情况。

安装与使用:

1
npm install -g heapdump-analyser

使用示例:

1
heapdump-analyser dump.heapsnapshot

优点:

  • 提供了命令行界面,便于在终端中分析堆快照。
  • 支持查找特定的类或闭包,帮助定位内存泄漏。

注意事项:

  • 需要与其他工具配合使用,如 heapdumpv8.writeHeapSnapshot,以生成堆快照。
  • 在分析大型堆快照时,可能需要较长时间。

使用 --heapsnapshot-near-heap-limit 标志

自 Node.js v14.18.0 起,提供了 --heapsnapshot-near-heap-limit 标志,允许在接近堆内存限制时自动生成堆快照。

使用示例:

1
node --max_old_space_size=500 --heapsnapshot-near-heap-limit=1 your_app.js

优点:

  • 自动捕获接近内存限制时的堆快照,便于分析内存使用情况。
  • 无需修改应用代码,减少了维护成本。

注意事项:

  • 需要根据应用的内存使用情况,合理设置 --max_old_space_size--heapsnapshot-near-heap-limit 的值。
  • 在生产环境中使用时,应评估对服务的影响。

总结

在 Node.js 的本地运行环境中,开发者可以根据具体需求和场景,选择合适的工具来提取和分析堆快照。无论是使用内置的 v8.writeHeapSnapshot 方法,还是第三方模块如 heapdump,都可以帮助深入了解应用的内存使用情况,检测内存泄漏,优化性能

分析快照

借助chrome devtool

浏览器开发者工具 -> Memory -> 上传快照

Memlab

https://www.npmjs.com/package/memlab

1
npm install -g memlab

Memlab是什么

Memlab is a memory testing framework for JavaScript。

Analyzes JavaScript heap and finds memory leaks in browser and node.js。

Memlab是一个JavaScript内存测试框架,可用于在浏览器、Node环境中分析JavaScript堆内存并检测内存泄露。它通过自定义测试场景,与SPA应用交互(使用 Puppeteer API),然后自动完成内存泄漏检查。

它的工作原理如下:

  • 与浏览器交互并获取 JavaScript 堆快照
  • 分析堆快照并识别内存泄漏
  • 对内存泄漏进行聚合、分组
  • 生成可Debug的分析结果

Memlab的特点

  • 面向对象的堆遍历 API:支持自定义内存泄露检测器,支持基于Chromium内核的应用(浏览器、Node环境、Electron、Hermes)
  • Memory CLI 工具箱:内置 CLI 工具箱和 API
  • Node环境下支持内存断言:可以对单元测试或运行中的Node应用保存堆快照,执行内存检查和内存断言

如何安装

1
npm install -g memlab
  • 运行环境要求:Node.js 16+
  • 需要科学上网:Memlab内部依赖种包含Puppeteer,正常情况下安装会报错。
  • 针对第2点,官方建议设置环境变量 PUPPETEER_SKIP_DOWNLOAD 先忽略浏览器的下载。手动安装好Puppeteer后,再手动下载Chromium文件,解压并放到Puppeteer的默认读取目录下。

如何使用

MemLab是在基于Chromium内核的浏览器中,运行预定义的测试场景并对 JavaScript heap snapshots 进行差异分析,从而发现内存泄漏,步骤如下:

  • 导航到目标页面并返回
  • 查找未释放的对象
  • 显示泄露追踪结果

创建一个测试场景

将该测试场景保存为/memlab/scenario.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// 测试场景的初始url
function url() {
return "https://www.baidu.com";
}
// 定义可能出现内存泄露的交互
async function action(page) {
await page.click('[id="target"]');
}
// 指定回到交互前状态的方式
async function back(page) {
await page.click('[id="back"]');
}
module.exports = { action, back, url };

运行测试场景

1
$ memlab run --scenario /memlab/scenario.js

运行结果分析

第一部分,MemLab 会实时生成一个面包屑,显示与目标网页交互的进度,对每个步骤的解读如下:

  • page-load[6.5MB](baseline)[s1]
    • 测试试场景起点
    • 初始页面加载时,JavaScript 堆内存大小为 6.5MB。
    • baseline内存快照将作为 s1.heapsnapshot 保存在磁盘上。
  • action-on-page[6.6MB](target)[s2]
    • 执行交互操作
    • 内存大小增加到 6.6MB
    • target内存快照将作为 s2.heapsnapshot 保存在磁盘上。
  • revert[7MB](final)[s3]
    • 执行回退/反向操作
    • 网页内存达到7MB,
    • final内存快照将作为 s3.heapsnapshot 保存在磁盘上。

第二部分,对检测到的内存泄露进行汇总

  • 内存泄露节点数
  • 泄露内存占用大小

第三部分,按照内存泄露类型的相似性,对每个种类提取一个代表性的内存泄露节点进行展示,图中是创建1024个分离的DOM节点的内存泄露检测结果分析。

1
2
3
4
window.leakedObjects = [];
for (let i = 0; i < 1024; i++) {
window.leakedObjects.push(document.createElement('div'));)
}
  • map这是被访问对象的 V8 HiddenClass(V8 在内部使用它来存储对象结构信息和对其原型的引用) - 在大多数情况下,这是 V8 实现的细节,可以忽略。
  • prototypewindows实例
  • leakedObjects表明leakedObjects是Window的属性,大小为148.5KB,指向Array对象
  • 0分离的 HTMLDIVElement元素,被存储为 leakedObjects 数组的第一个元素(Memlab 只打印一个具有代表性的内存泄露)
1
2
[window](object) -> leakedObjects(property) -> [Array](object)
-> 0(element) -> [Detached HTMLDIVElement](native)

扩展应用

检测未释放的超大对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 未清空EventListener,无法释放eventHandler函数,eventHandler函数保存了对bigArray的引用

import { Component, Vue } from 'vue-property-decorator'

@Component
export default class OversizedObject extends Vue {
public bigArray = Array(1024 * 1024 * 2).fill(0);

eventHandler () {
console.log('Using hugeObject', bigArray);
};

mounted () {
window.addEventListener('custom-click', eventHandler)
}
}

上述示例在运行测试场景时,无法检测到内存泄露。因为Memlab的泄漏检测器仅将满足以下所有条件的对象视为内存泄漏:

  • 对象在触发action时分配 内存
  • 在触发back后,对象 内存 没有被释放
  • 该对象是一个分离的 DOM 元素或一个未挂载的 React Fiber 节点

这种情况下,可以用LeakFilter来自定义规则过滤内存泄露对象(如内存占用大小),LeakFilter会对每一个由action触发内存分配,但在back后未释放内存的对象进行调用。

1
2
3
4
5
6
// ...
function leakFilter(node, _snapshot, _leakedNodeIds) {
return node.retainedSize > 1000 * 1000;
}

module.exports = {action, back, leakFilter, url};

另外也可以创建一个单独的leakFilter.js文件

1
2
3
4
5
6
function leakFilter(node, _snapshot, _leakedNodeIds) {
return node.retainedSize > 1000 * 1000;
}

module.exports = {leakFilter};
$ memlab find-leaks --leak-filter /memlab/leak-filter.js

检测所有的内存泄露

默认情况下,Memlab只报告准确度高的内存泄漏(由其内置的内存泄漏检测器进行判断)。可能会存在一些内存泄漏,Memlab不会报告。

使用如下命令可以检测所有的 内存 泄露(确保测试场景不包含LeakFilter):

1
2
$ memlab run --scenario /memlab/scenarios.js
$ memlab find-leaks --trace-all-objects

直接分析内存快照

通常情况下,Memlab的内存分析数据来源于Memlab对Puppeteer API的调用。

通过Memlab内置的Memlab API,可以使用Memlab直接分析基于从Chrome或任何基于Chromium内核的应用中获取的单个JavaScript堆快照,检测内存问题

1
$ memlab view-heap --snapshot <PATH TO .heapsnapshot FILE>

自动化内存泄露检测

Memlab支持自动化内存泄露检测,配置步骤如下:

  1. 准备覆盖关键交互的测试场景
  2. 通过Memlab CLIMemlab API触发测试场景运行
  3. 收集结果

在CLI中运行

1
2
$ memlab run --scenario /path/to/test/scenario/file.js \
--work-dir /path/to/save/memlab/run/results/

在Node.js中运行

1
2
3
4
5
6
7
8
9
const {run} = require('@memlab/api');
const scenario = require('/path/to/test/scenario/file.js');
const fs = require('fs-extra');

(async function () {
const workDir = '/path/to/save/memlab/run/results/';
fs.mkdirsSync(workDir);
const result = await run({scenario, workDir});
})();

Memlab 运行完成后,所有结果和数据将保存在指定的工作目录中(workDir),可以使用内置的结果分析器BrowserInteractionResultReader对结果进行读取并输出。

1
2
3
4
5
6
7
8
9
10
11
const {BrowserInteractionResultReader} = require('@memlab/api');

const workDir = '/path/to/save/memlab/run/results/';
const result = BrowserInteractionResultReader.from(workDir);
// 获取内存快照文件
const files = result.getSnapshotFiles();
// 对结果进行打印
const metaInfo = result.getRunMetaInfo();
console.log(metaInfo.browserInfo._consoleMessages.join('\n'));
// 清除结果
result.cleanup();

Node.js 进程中常见的泄漏路径锚点

常见的泄漏路径锚点:

  • (global property): 全局变量或模块级的变量。
  • (closure): 闭包,通常是事件监听器或定时器(setTimeout, setInterval)中的引用。
  • (array)(map): 可能是未清理的缓存结构。
  • (native): 在 Node.js 场景中,这意味着 C++ 或 Buffer 引用的堆外内存。你需要向上追踪,找到持有这个 Native 块引用的 JavaScript 对象(通常是 BufferArrayBuffer 实例)。

全局变量 (Global Variables)

任何直接或间接挂载在全局对象(globalprocess)上的对象,除非显式设为 null,否则永远不会被垃圾回收。

锚点类型引用路径中的表现常见代码模式
Global(GC Root)(global property)LeakyCache不安全的缓存或单例: 将大型缓存对象、日志对象或配置对象直接挂在 global.cacheprocess.leaks 上。
Module(GC Root)(system / Context)(module)privateData模块级作用域的缓存: 在文件顶层作用域(Module Scope)声明了一个对象,并在应用生命周期内不断向其中添加数据,但从未清理。
Require Cache(GC Root)(module)exportsLargeArray模块导出错误: 模块导出的对象被全局引用,而该对象又在不断增长。

泄露根源:定时器和事件(Timeouts & Event Emitters)

这是最常见的泄漏类型之一。未清除的定时器或事件监听器会持有其回调函数(Closure),进而持有该回调函数作用域内的所有变量,使其无法被回收。

锚点类型引用路径中的表现常见代码模式
Timeout / Immediate(GC Root)TimersListTimeoutClosureLeakedObject未清除的定时器: 忘记调用 clearInterval()clearTimeout()。即使定时器只执行一次,如果它引用的对象很大,也会造成短暂泄漏。
Listener(GC Root)EventEmitterListenerClosureLeakedObject未移除的事件监听器: 在对象销毁时,忘记调用 emitter.removeListener()emitter.off()。常见的场景是请求结束时未清理的 Socket 事件或自定义事件。

泄露根源:HTTP 请求和 Context 泄漏

在 Koa/Express 等框架中,最危险的是请求级变量被意外地提升到全局作用域闭包中。

锚点类型引用路径中的表现常见代码模式
Koa Context(GC Root)ClosureKoaMiddlewareRequestObject未销毁的请求上下文: 中间件中的闭包意外捕获了某个请求的 ctxreq 对象,导致请求结束后本应回收的对象被保留。
Promise / AsyncHook(GC Root)PromiseClosureHeavyData未完成的 Promise 链: Promise 链条没有正确关闭或抛出错误,导致 Promise 内部状态和其捕获的变量长时间存在。

泄露根源:堆外内存(Buffer/Native)

这是你当前遇到的问题,其核心在于 JavaScript 对象阻止了底层 C++ 内存的释放。

锚点类型引用路径中的表现常见代码模式
Buffer / NativeJS Object → Buffer→ NativeBuffer 缓存未清理: 使用 BufferArrayBuffer 存储大量数据(例如文件内容、加密结果),但该 Buffer 对象被一个未清理的缓存 Map 或全局对象引用。
Native Hook(GC Root)C++ AddonNative DataBufferC++ 插件错误: 使用了 Node.js 原生模块(如数据库驱动、图像处理库),但 C++ 代码没有正确释放底层内存,或者 C++ 对象被 JavaScript 对象错误地引用。