Bun

调试 JavaScript 内存泄漏


Jarred Sumner · 2025 年 4 月 2 日

总而言之:Bun 实现了 v8 堆快照 API,Chrome DevTools 支持比较堆快照

  • 内存泄漏:内存使用量随时间持续增加,即使工作负载保持不变,也不会趋于平稳。
  • 高内存使用量:应用程序占用大量内存,但在运行期间保持稳定。

内存泄漏的关键指标是内存使用量持续增长且永不下降。请记住,在 JavaScript 等具有垃圾回收机制的语言中,内存使用量可能会在垃圾回收器运行前有所增长。真正的泄漏会显示出在较长时期内持续的上升趋势。

V8 堆快照

Bun 实现了 V8 的堆快照 API。

import { writeHeapSnapshot } from "v8";

// Create a named heap snapshot
writeHeapSnapshot("my-application.heapsnapshot");

这会生成一个 .heapsnapshot 文件,可以将其加载到 Chrome DevTools 中。

V8 Heap Snapshot

如何加载堆快照?

堆快照比较

Chrome DevTools 支持在多个堆快照之间进行比较。

  1. 将多个 .heapsnapshot 文件上传到“Memory”选项卡
  2. 选择最新的快照并点击“Summary”
  3. 点击“Comparison”
V8 Heap Snapshot Comparison
这就是内存泄漏的样子

导致内存泄漏的示例代码

“Delta”列对于识别内存泄漏特别有用。当该数字稳步增加时(通常不是增加 10 或 100,而是非常大的数字),您可能遇到了内存泄漏。

JavaScriptCore 堆统计信息

Bun 提供对 JavaScriptCore 堆统计信息的直接访问

import { heapStats } from "bun:jsc";

// Log JSC heap stats to see memory usage details
console.log(heapStats());

这将返回一个包含详细堆信息的对象

{
  heapSize: 15485760,
  heapCapacity: 16777216,
  extraMemorySize: 2097152,
  objectCount: 42358,
  protectedObjectCount: 8274,
  globalObjectCount: 1,
  protectedGlobalObjectCount: 1,
  objectTypeCounts: {
    "Array": 8732,
    "Object": 12489,
    "Function": 6254,
    "Promise": 2453,
    // many more object types...
  },
  protectedObjectTypeCounts: {
    // protected objects by type...
  }
}

objectTypeCounts 属性显示内存中有多少特定类型的对象。特定类型对象的数量异常多可能表明存在潜在问题。例如,看到 10,000 多个 Promise 对象可能表明 Promise 永远不会解析或拒绝。

protectedObjectTypeCounts 类似,但仅计算受垃圾回收保护的对象,例如 setTimeoutsetInterval 的计时器。

测量内存使用量

使用 process.memoryUsage.rss() 测量内存使用量

console.log(process.memoryUsage.rss());

这将为您提供常驻集大小 (RSS),即为进程实际分配在 RAM 中的内存量。

RSS 与虚拟内存有何不同?

RSS 是为进程实际分配在 RAM 中的内存量。虚拟内存是进程可以使用的总内存量,包括当前不在 RAM 中的内存。在 POSIX 系统上,已请求但尚未使用的内存不包含在 RSS 中,也不会影响您的整体系统内存使用量。JavaScriptCore 利用“护城河”式的虚拟内存范围作为类型之间的屏障,以限制与内存相关的安全漏洞的影响范围。

减少内存使用量的小贴士

无论您是来调试内存泄漏,还是出于其他目的,以下是一些减少内存使用量的好方法:

使用 Blob 代替流来处理不可变数据

当您将 Uint8ArrayBufferArrayBuffer 传递给 new Response 时,我们被迫将数据克隆到一个内部缓冲区中,该缓冲区在 Response 的生命周期内都保持活动状态。

此代码片段会分配约 1024 MB 的内存

1gb.js
const ten_megabytes = 10 * 1024 * 1024;
const data = new Uint8Array(ten_megabytes).fill(123);

const responses = [];

// This allocates 1 GB of memory!
for (let i = 0; i < 100; i++) {
  const response = new Response(data);
  responses.push(response);
}

console.log((process.memoryUsage.rss() / 1024 / 1024) | 0, "MB");

当我们将 Uint8Array 更改为 Blob 时,内存使用量降至约 60 MB

60mb.js
const ten_megabytes = 10 * 1024 * 1024;
const responses = [];
const data = new Blob([new Uint8Array(ten_megabytes).fill(123)]);
const data = new Uint8Array(ten_megabytes).fill(123);

// This allocates 10 MB of memory!
for (let i = 0; i < 100; i++) {
  const response = new Response(data);
  responses.push(response);
}

console.log((process.memoryUsage.rss() / 1024 / 1024) | 0, "MB");

Web 流会创建许多小数据副本,这些副本都必须被克隆,这会加剧问题。

避免不必要地使用流
// Creates multiple copies of the data in memory
function handleInput(input) {
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(input);
      controller.close();
    },
  });

  // Each of these consumers gets a separate copy
  stream.pipeTo(consumer1);
  stream.pipeTo(consumer2);
}

handleInput("Passing static data to a ReadableStream costs extra memory");
优先使用 Blob 处理不可变数据
// Reuses the same memory for multiple consumers
function handleInput(input) {
  const blob = new Blob([input]);

  // These share the same underlying memory
  consumer1.process(blob);
  consumer2.process(blob);
}

完成后将引用置为 null

当对象具有大量循环引用时,您可以使用 undefinednull 来打破循环,帮助垃圾回收器更快地回收内存(或者任何其他值,undefined 在这里不是特殊的)。

async function processLargeData() {
  let largeData = loadHugeDataset();
  const result = computeResult(largeData);

  // largeData is still in memory here, even though we don't need it
  // so let's clear it.
  largeData = undefined;

  await doSomethingWithResult(result);

  return result;
}

内存泄漏的常见来源

引用父作用域中大型对象或变量的闭包会在闭包的整个生命周期内保留这些对象。

function setupProcessor() {
  // This large data structure gets captured in the closure
  const dataCache = new Array(1000000).fill(0).map(() => ({
    /* large object */
  }));

  return function process(item) {
    // References dataCache, keeping it alive as long as this function exists
    return dataCache.find((entry) => entry.id === item.id);
  };
}

// This processor function keeps the entire dataCache alive
const processor = setupProcessor();

在 JavaScriptCore(以及其他 JavaScript 引擎可能也会做类似的事情)中,当闭包引用父作用域中的变量时,它会创建一个内部对象来引用该作用域(JSLexicalScope),然后对其保持引用。这会使作用域一直存在,直到闭包被垃圾回收,从而使作用域内的对象一直存在,直到闭包被垃圾回收。

当您使用 ES 模块或 CommonJS 时,模块顶层作用域中的任何内容都可能被保留,直到模块本身生命周期结束,这通常是进程的整个生命周期。因此,请小心您放在顶层作用域中的内容。

AbortSignal 和 AbortController

活动 AbortSignal 会一直保持自身和所有已注册的回调函数存活,直到触发 abort 事件,或者直到资源不再处于活动状态。

内部如何工作?

长时间运行的 HTTP 请求,例如在使用 Server-Sent Events (SSE) 时,可能会导致长时间运行的 AbortSignal 对象,从而使大量对象保持活动状态。

function fetchData() {
  // Create a controller for this operation
  const controller = new AbortController();
  const { signal } = controller;

  // This large data is kept alive as long as the signal is active
  const processingContext = createHugeObject();

  // The abort listener keeps processingContext alive
  signal.addEventListener("abort", () => {
    cleanupProcessing(processingContext);
  });

  // The `processingContext` is kept alive as long as the signal is active
  fetch("https://api.example.com/data", { signal })
    .then((response) => processResponse(response, processingContext))
    .catch((err) => {
      if (!signal.aborted) {
        // Handle error but don't abort
      }
    });

  // Return the controller so it can be aborted externally
  return controller;
}

fetchData();

总而言之:避免在 abort 事件监听器中引用大型数据结构。

// Self-aborting after 30 seconds
const signal = AbortSignal.timeout(30_000);

fetch("https://api.example.com/data", { signal })
  .then((response) => processResponse(response))
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("Request timed out");
    }
  });

Function.prototype.bind() 导致对象保留

当您将函数绑定到 this 值时,函数和绑定值都会保留在内存中,直到绑定函数被垃圾回收。

class ResourceManager {
  constructor() {
    this.resources = new Array(10000).fill().map(() => new Resource());

    // This bound function keeps 'this' (and all its resources) alive
    this.getResourceCount = this.getResourceCount.bind(this);
    globalRegistry.registerCallback(this.getResourceCount);
  }

  getResourceCount() {
    return this.resources.length;
  }
}

这通常比引用外部作用域的变量要好,因为引用仅限于绑定时使用的特定变量(而不是创建新的 JSLexicalScope 对象),但如果使用不当,它仍然可能导致内存泄漏。

未正确清理的 EventEmitter 监听器

当监听器未被正确移除时,EventEmitter 可能导致内存泄漏。

import { EventEmitter } from "node:events";

function setupDataProcessor(emitter) {
  const largeData = loadLargeDataset(); // Potentially megabytes of data

  // This listener keeps largeData in memory as long as emitter exists
  emitter.on("process", (item) => {
    const result = processWithLargeData(item, largeData);
    emitter.emit("result", result);
  });

  // Without a corresponding emitter.removeListener call,
  // largeData will never be garbage collected until the emitter itself is garbage collected
}

const globalEmitter = new EventEmitter();
setupDataProcessor(globalEmitter);

使用 once() 处理一次性事件,或在不再需要监听器时显式移除它们。

// For one-time events
emitter.once("process", handler);

// For multiple events that need cleanup
const handler = (item) => {
  /* ... */
};
emitter.on("process", handler);

// When done with the listener
emitter.removeListener("process", handler);