• 深入理解V8 引擎的垃圾回收机制及Node.js内存监控工具
  • 发布于 2个月前
  • 164 热度
    0 评论

Node.js 作为一款高性能的 JavaScript 运行时,在处理大量并发请求时有着出色的表现。然而,内存管理和性能优化始终是后端开发中的重要环节。特别是在面对内存泄漏、堆内存溢出、垃圾回收性能瓶颈时,了解如何优化内存分配和性能变得尤为关键。


这里将深入探讨 Node.js 中的堆内存分配原理,如何监控内存使用情况,以及如何通过不同的测试方法来提升应用的性能。此外,还将介绍一些实用的调试工具和优化技巧,帮助开发者更好地管理内存和提升应用性能。


V8 垃圾回收简介
首先,简要介绍一下 V8 垃圾回收器。堆是内存分配的地方,它被划分为几个代际区域。这些区域简单地被称为代,对象在它们的生命周期中随着时间的推移属于不同的代。内存大体上可以分为新生代和老生代。新生代内存进一步可以划分为两个子代,分别是nursery(新生代对象)和intermediate(中间代对象)。随着对象经历垃圾回收,它们会加入到老生代。

基于代际假设的基本原则是,大多数对象都会早逝。V8 垃圾回收器的设计就是为了利用这一事实,只提升那些在垃圾回收中存活下来的对象。随着对象被复制到相邻区域,它们最终会进入老年代。
Node 内存消耗有三个主要领域:
1.代码 - 执行的代码所在的地方
2.调用栈 - 用于函数和具有原始类型(如数字、字符串或布尔值)的局部变量
3.堆内存

垃圾回收会导致应用程序暂停执行,这就是所谓的“Stop-the-world”现象。为了减少暂停时间,V8 引入了增量标记(Incremental Marking)等机制,将垃圾回收任务分散到多个小步执行,降低对应用的影响。
我们可以使用 --trace-gc 标志查看 GC 运行时的详细信息:
node --trace-gc app.js
今天我们主要关注的是堆内存。现在你已经对垃圾回收器有了更多的了解,是时候在堆上分配一些内存了!
function allocateMemory(size) {
  // 模拟分配字节
  const numbers = size / 8;
  const arr = [];
  arr.length = numbers;
  for (let i = 0; i < numbers; i++) {
    arr[i] = i;
  }
  return arr;
}

局部变量很快就会死去,一旦函数调用在调用栈中结束。像 numbers 这样的原始类型永远不会进入堆,而是在调用栈中分配。对象 arr 将进入堆,并可能在垃圾回收中存活下来。


堆内存有限制吗?
现在来测试一下——将 Node 进程推向最大容量,看看它在哪里耗尽堆内存:
const memoryLeakAllocations = [];
const field = 'heapUsed';
const allocationStep = 10000 * 1024; // 10MB
const TIME_INTERVAL_IN_MSEC = 40;
setInterval(() => {
  const allocation = allocateMemory(allocationStep);
  memoryLeakAllocations.push(allocation);
  const mu = process.memoryUsage();
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024;
  const gbRounded = Math.round(gbNow * 100) / 100;

  console.log(`Heap allocated ${gbRounded} GB`);
}, TIME_INTERVAL_IN_MSEC);
这段代码每 40 毫秒分配大约 10 兆字节的内存,给垃圾回收足够的时间将存活的对象提升到老年代。process.memoryUsage 是一个粗略的工具,用于收集堆利用率的指标。随着堆分配的增长,heapUsed 字段跟踪堆的大小。这个字段报告的是 RAM 中的字节数,可以转换为千兆字节。
你的结果可能会有所不同。在一台装有 32GB 内存的 Windows 10 笔记本电脑上,结果是:
Heap allocated 4 GB
Heap allocated 4.01 GB
<--- Last few GCs --->
[18820:000001A45B4680A0] 26146 ms: Mark-sweep (reduce) 4103.7 (4107.3) -> 4103.7 (4108.3) MB, 1196.5 / 0.0 ms (average mu = 0.112, current mu = 0.000) last resort GC in old space requested

<--- JS stacktrace --->
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
在这里,垃圾回收器尝试在放弃并抛出 'heap out of memory' 异常之前压缩内存。这个过程达到了 4.1GB 的限制,并花了 26.6 秒才结束。
导致这种情况的原因尚不清楚。V8 垃圾回收器最初在有严格内存限制的 32 位浏览器进程中运行。这些结果表明,内存限制可能从遗留代码中继承而来。

脚本在最新的 Node 14 版本下运行,并使用 64 位可执行文件。理论上,64 位进程应该能够分配超过 4GB 的内存,并可能增长到 16 万亿字节的地址空间。


扩大内存分配限制
V8 垃圾回收器有一个 --max-old-space-size 参数可供 Node 可执行文件使用:
node index.js --max-old-space-size=8000
这将最大限制设置为 8GB。这样做时要谨慎。我的笔记本电脑有 32GB 的充足空间。我建议将这个值设置为物理 RAM 可用空间的大小。一旦物理内存用完,进程就会开始通过虚拟内存消耗磁盘空间。如果设置的限制太高,你可能会找到一种损坏你的电脑的方法!这里的目标是避免机器冒烟。
有了 8GB 的空间,测试新限制:
Heap allocated 7.8 GB
Heap allocated 7.81 GB
<--- 最后几次 GC --->
[16976:000001ACB8FEB330] 45701 ms: Mark-sweep (reduce) 8000.2 (8005.3) -> 8000.2 (8006.3) MB, 1468.4 / 0.0 ms (平均 mu = 0.211, 当前 mu = 0.000) 在老空间请求的最后手段 GC
<--- 堆代码 duidaima.com --->
<--- JS 堆栈跟踪 --->
致命错误:CALL_AND_RETRY_LAST 分配失败 - JavaScript 堆内存不足

堆大小几乎达到了 8GB,但还没有。我怀疑 Node 进程在分配这么多内存时有一些开销。这次它花了 45.7 秒才结束。在生产环境中,往往不会在一分钟内就耗尽内存。这就是为什么监控和了解内存消耗很重要的原因之一。内存消耗可能随着时间的推移慢慢增长,可能需要几天时间你才会意识到有问题。如果进程不断崩溃,并且 'heap out of memory' 异常出现在日志中,代码中可能存在 内存泄漏。


如果资源消耗持续增长,可能是因为它正在处理更多的数据。如果资源消耗持续增长,可能是时候将这个单体应用拆分成微服务了。这将减少单个进程的内存压力,并允许节点水平扩展。


如何追踪 Node.js 内存泄漏
通过 heapUsed 字段的 process.memoryUsage 函数有点用处。调试内存泄漏的一种方法是将内存指标放入另一个工具中进行进一步处理。因为这种实现并不复杂,所以分析将主要是一个手动过程。
在代码中的 setInterval 调用之前放置以下代码:
const path = require('path');
const fs = require('fs');
const os = require('os');

const start = Date.now();
const LOG_FILE = path.join(__dirname, 'memory-usage.csv');

fs.writeFile(
  LOG_FILE,
  'Time Alive (secs),Memory GB' + os.EOL,
  () => {}); // 放任不管
为了避免将堆分配指标放入内存,我们选择将其写入 CSV 文件以便于数据消费。这使用了异步的 writeFile 函数和一个回调。回调被留空,以写入文件并继续而不进行任何进一步的处理。要获取逐步的内存指标,请在 console.log 之上添加此代码:
const elapsedTimeInSecs = (Date.now() - start) / 1000;
const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100;

fs.appendFile(
  LOG_FILE,
  timeRounded + ',' + gbRounded + os.EOL,
  () => {}); // 放任不管

有了这段代码,你可以在堆利用率随时间增长时调试内存泄漏。你可以看到一个短时间内内存使用量的线性增长,4.1GB 的限制。内存消耗继续增长,并没有达到平稳状态,这表明某个地方存在内存泄漏。在调试这类内存问题时,要寻找导致分配进入老年代的代码。在垃圾回收中存活下来的对象可能会一直存在,直到进程死亡。


使这段内存泄漏检测代码更具可重用性的一种方法是将其包装在自己的间隔内(因为它不必放在主循环内)。
setInterval(() => {
  const mu = process.memoryUsage();
  // # bytes / KB / MB / GB
  const gbNow = mu[field] / 1024 / 1024 / 1024;
  const gbRounded = Math.round(gbNow * 100) / 100;

  const elapsedTimeInSecs = (Date.now() - start) / 1000;
  const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100;

  fs.appendFile(
    LOG_FILE,
    timeRounded + ',' + gbRounded + os.EOL,
    () => {}); // 放任不管
}, TIME_INTERVAL_IN_MSEC);
我们可以通过实时收集内存数据,去监控我们的服务,确保服务健康稳定。除了 process.memoryUsage(),Node.js 的 v8 模块还提供了更详细的内存监控 API,例如 v8.getHeapStatistics() 和 v8.getHeapSpaceStatistics(),可以深入分析堆空间的使用情况:
const v8 = require('v8');

setInterval(() => {
  const heapStats = v8.getHeapStatistics();
  console.log('Heap Size Limit:', heapStats.heap_size_limit / 1024 / 1024, 'MB');
  console.log('Total Heap Size:', heapStats.total_heap_size / 1024 / 1024, 'MB');
  console.log('Used Heap Size:', heapStats.used_heap_size / 1024 / 1024, 'MB');
}, 1000);

这些 API 对于检测堆内存使用的增长趋势,识别可能的内存泄漏非常有用。


基准测试与压力测试

优化内存分配和垃圾回收的另一个重要手段是通过基准测试和压力测试来分析应用在高负载下的表现。Node.js 社区提供了许多基准测试工具,可以帮助我们衡量内存使用的瓶颈。


Benchmark.js 进行基准测试
Benchmark.js 是一个非常流行的基准测试工具,能够帮助我们准确地衡量代码的性能。以下是一个基于 Benchmark.js 的简单基准测试:
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;

suite.add('Array#push', function() {
  const arr = [];
  for (let i = 0; i < 1000; i++) {
    arr.push(i);
  }
}).add('Array#unshift', function() {
  const arr = [];
  for (let i = 0; i < 1000; i++) {
    arr.unshift(i);
  }
}).on('cycle', function(event) {
  console.log(String(event.target));
}).on('complete', function() {
  console.log('Fastest is ' + this.filter('fastest').map('name'));
}).run({ 'async': true });
使用 autocannon 进行压力测试

autocannon 是一个高效的 HTTP 服务器压力测试工具,适合模拟并发用户对应用进行压力测试。通过这种方式,可以观察高负载下的内存使用情况和垃圾回收性能。

npx autocannon -c 100 -d 30 http://localhost:3000

基于多线程的压力测试
利用 Node.js 的 worker_threads 模块,可以通过并行执行任务来模拟高负载场景:
const { Worker } = require('worker_threads');

function runWorker() {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js');
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

async function runWorkers() {
  const numWorkers = 10;
  const promises = [];
  for (let i = 0; i < numWorkers; i++) {
    promises.push(runWorker());
  }
  await Promise.all(promises);
  console.log('All workers completed');
}

runWorkers();
第三方工具的使用
除了 Node.js 自带的工具外,许多第三方工具也可以帮助我们监控和优化内存。
使用 clinic.js 进行性能诊断
clinic.js 是一个强大的性能诊断工具,集成了多种性能测试和内存监控功能。通过 clinic,可以轻松捕获内存泄漏和高 CPU 使用率的场景。
heapdump 进行堆快照分析
heapdump 是一个专门用于捕获和分析堆快照的工具。通过分析堆快照,可以精确定位内存泄漏的来源。
const heapdump = require('heapdump');
heapdump.writeSnapshot((err, filename) => {
  if (err) console.error(err);
  else console.log('Heap snapshot written to', filename);
});
通过 Chrome DevTools 可以加载生成的 .heapsnapshot 文件,并进行详细分析。
--inspect 和 Chrome DevTools 的使用
Node.js 提供了 --inspect 标志,可以通过 Chrome DevTools 分析内存使用情况。在运行应用时启用 --inspect 选项:
node --inspect app.js

然后可以在 Chrome 浏览器中打开 chrome://inspect,连接并分析应用的内存使用情况。


内存泄漏的检测与解决
内存泄漏是 Node.js 开发中常见的问题,通常是由于对象没有被及时释放导致的。以下是一些检测内存泄漏的常见方法:
memwatch-next 是一个能够监控内存泄漏的工具。它可以检测堆内存的异常增长情况,并发出警告。
const memwatch = require('memwatch-next');

memwatch.on('leak', (info) => {
  console.error('Memory leak detected:', info);
});

通过 memwatch 的监控,可以发现堆内存是否存在不正常的增长趋势,进而优化代码中的内存管理。减少内存占用的一个常见方法是使用对象池。对象池技术可以避免频繁创建和销毁对象,尤其是在高负载的场景中,有效降低内存消耗。


此外,使用弱引用(WeakMap 和 WeakSet)存储不需要持久保存的对象引用,确保它们可以在垃圾回收时被释放,也是优化内存的一个好方法。在 Node.js 中,异步 I/O 操作(例如文件读写、数据库查询、网络请求)是十分常见的操作。由于这些操作不会阻塞主线程,因此能够有效提高应用的性能。然而,如果处理不当,异步 I/O 可能会导致内存泄漏或内存过度增长。


一些优化异步 I/O 的内存使用的策略包括:
避免无限制的异步操作并发:当同时进行大量异步请求时,内存消耗可能急剧增加,特别是在处理大规模数据时。我们可以通过限制并发操作的数量来优化内存。例如,使用 async 库的 queue 函数来管理并发数量。

及时清理未使用的引用:当异步操作完成后,及时清理对结果的引用,可以确保内存被正确回收。例如,在 HTTP 请求结束后清理缓存的响应数据:
let responseCache = {};

function fetchData(url) {
  return new Promise((resolve, reject) => {
    // 执行异步请求
    setTimeout(() => {
      responseCache[url] = `Response from ${url}`;
      resolve(responseCache[url]);
    }, 1000);
  });
}

async function handleRequest(url) {
  const data = await fetchData(url);
  console.log(data);
  // 堆代码 duidaima.com
  // 请求完成后清除缓存,释放内存
  delete responseCache[url];
}

handleRequest('http://example.com');
数据流处理的内存管理:使用流(streams)来处理大数据文件或长时间的数据传输,可以减少内存占用。例如,当我们从文件中读取数据时,最好采用 stream 方式逐块读取,而不是一次性将整个文件加载到内存中:
const fs = require('fs');

const readable = fs.createReadStream('largefile.txt');
readable.on('data', (chunk) => {
  console.log(`Received ${chunk.length} bytes of data.`);
});

readable.on('end', () => {
  console.log('No more data.');
});

这种方式可以有效减少内存占用,并避免因为加载大文件导致的堆内存溢出。在进行性能调优时,光靠简单的监控工具还不足以捕捉所有的性能瓶颈。高级的性能测试工具和方法可以帮助开发者全面掌握应用的内存使用和性能表现。火焰图(Flamegraph)是一种可视化工具,能够直观地展示应用程序中的函数调用关系及其 CPU 占用情况。通过生成火焰图,可以帮助开发者快速定位应用中最耗时的代码段。


借助 clinic 工具中的 clinic flame,可以生成和分析火焰图:
npx clinic flame -- node app.js
当应用运行结束时,clinic 将生成一份火焰图报告,开发者可以通过火焰图找出哪些函数消耗了最多的 CPU 时间,并进一步优化这些代码。

pprof 是 Google 开源的性能分析工具,支持捕获应用在运行时的 CPU 和内存性能数据。通过 pprof,可以对内存的分配情况、CPU 的使用情况进行深度分析,从而发现潜在的性能问题。


Node.js 的 pprof 模块集成了该功能,可以通过如下方式使用:
node --cpu-prof --heap-prof app.js

然后可以使用 pprof 工具对生成的性能文件进行分析,查看内存和 CPU 的详细使用情况。在生产环境中,我们可以通过定时触发内存和性能分析操作,或者借助云平台提供的性能监控服务(如 AWS CloudWatch,Google Stackdriver)来实时追踪内存和性能表现。


生产环境的最佳实践
当我们在生产环境中运行 Node.js 应用时,内存管理和性能优化变得尤为重要。以下是一些在生产环境中确保内存高效使用的最佳实践:
1. 使用正确的内存限制
Node.js 默认的堆内存限制对于大多数应用是足够的,但对于一些内存密集型的应用(如处理大型数据集或图像),可以通过 --max-old-space-size 参数调整最大堆内存大小:
bash 代码解读复制代码node --max-old-space-size=4096 app.js

这种方式可以确保 Node.js 应用有足够的堆内存,而不会因为内存不足导致的崩溃。
2. 定期重启服务
对于长时间运行的 Node.js 服务,特别是那些处理大量并发请求的服务,定期重启服务可以有效避免内存泄漏带来的问题。通过 PM2 等进程管理工具,可以配置应用的定时重启:
bash 代码解读复制代码pm2 start app.js --max-memory-restart 300M

当应用的内存使用超过 300MB 时,PM2 会自动重启应用,以避免潜在的内存泄漏。
3. 监控工具与报警系统

在生产环境中,实时监控应用的内存使用情况是必不可少的。借助 Prometheus 和 Grafana 等工具,我们可以设置内存使用的报警阈值,当应用的内存使用达到指定阈值时,系统会发出警报,从而及时发现并处理内存问题。


总结

Node.js 内存管理和性能优化涉及多个层面,从理解 V8 引擎的垃圾回收机制,到掌握不同的内存监控工具,再到实际的基准测试和生产环境中的调优,每一步都至关重要。通过深入分析堆内存的使用情况,合理优化内存分配,并结合第三方工具进行高效测试,我们可以有效提升 Node.js 应用的稳定性和性能。


无论是在开发阶段还是生产环境中,持续的性能监控和内存管理优化都是确保 Node.js 应用高效运行的关键。在实践中,定期检查内存使用情况、使用合适的调试工具、设定合理的内存阈值,将帮助开发者避免内存泄漏和堆内存溢出问题,确保应用在高负载下依然稳定可靠。

用户评论