第 35 章

内存管理:分代回收、WeakRef 与 DevTools 内存分析

第35章:内存管理——分代垃圾回收、WeakRef 与 DevTools 堆分析

一个内存泄漏的 Node.js 服务在上线后第 72 小时崩溃,不是因为程序员写错了逻辑,而是因为他把一个 DOM 元素的引用放进了全局 Map,然后那个 DOM 元素从未被删除。垃圾回收器每次都认为那个元素还有人需要它。

本章核心问题:JavaScript 引擎如何追踪哪些对象是"活的"、哪些可以被回收?当垃圾回收器判断失误时,内存如何一点一点地泄漏到无法挽回?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

JavaScript 的内存不是自动"免费"的

虽然 JavaScript 有垃圾回收,但内存泄漏依然频繁发生。垃圾回收器只能回收它认为"没有人引用"的对象——如果你意外保留了对一个对象的引用,GC 就永远不会回收它。

最危险的引用保留模式

// 危险模式 1:全局变量意外持有大量数据
let cache = {};  // 在模块顶层
function process(data) {
  cache[data.id] = data;  // 每次调用都往里塞,从未清理
  return transform(data);
}

// 危险模式 2:事件监听器未移除
function setupComponent(element) {
  const handler = (event) => {
    console.log(element, event);  // handler 闭包持有 element 的引用
  };
  document.addEventListener('click', handler);
  // 如果不调用 removeEventListener,handler 和 element 都无法被回收
}

// 危险模式 3:定时器持有引用
function startPolling(heavyData) {
  setInterval(() => {
    process(heavyData);  // 闭包持有 heavyData
  }, 1000);
  // 如果 setInterval 没有被 clearInterval,heavyData 永远不会被回收
}

WeakMap 和 WeakSet 是处理"附加数据"的正确工具

// 错误:用普通 Map 关联 DOM 元素和数据
const elementData = new Map();

function attachData(element, data) {
  elementData.set(element, data);  // element 不会被 GC,因为 Map 持有强引用
}

// 当 DOM 元素从页面中移除后,它在 Map 里的条目永远残留

// 正确:用 WeakMap
const elementData = new WeakMap();

function attachData(element, data) {
  elementData.set(element, data);  // 如果 element 不再被其他地方引用,GC 可以回收它
  // WeakMap 对 key 的引用是弱引用,不阻止 GC
}

6种内存泄漏的快速识别清单

模式 症状 快速修复
全局变量积累 内存随请求数线性增长 设置 LRU 缓存上限
事件监听器未移除 单页应用路由切换后内存不降 在组件销毁时调用 removeEventListener
闭包持有大型对象 函数返回后内存不释放 检查闭包是否捕获了不必要的变量
DOM 引用在 JS 中残留 删除 DOM 后内存不降 用 WeakMap/WeakRef 代替 Map
定时器未清除 后台任务无限运行 在清理函数中 clearInterval/clearTimeout
无限增长的队列 生产者比消费者快 设置队列长度上限

用 Chrome DevTools 检测内存泄漏的基本流程

1. 打开 Chrome DevTools → Memory 标签
2. 选择 "Heap snapshot"
3. 执行一次完整操作(如:打开一个页面,然后关闭它)
4. 再拍一次快照
5. 在第二个快照中选择 "Comparison" 视图(对比两次快照)
6. 按照 "# Delta"(数量增量)排序
7. 数量只增不减的对象,就是潜在的泄漏点

Level 2 · 它是怎么运行的(3-5年经验)

V8 的分代假设与堆结构

垃圾回收理论中有一个被大量实验验证的规律:大多数对象要么很快死去,要么长期存活。短暂的对象(比如函数调用中创建的临时值)通常在创建后几毫秒内就不再被需要;长期存活的对象(比如应用状态、缓存)则往往长期存活。

V8 基于这个规律设计了分代堆

V8 堆结构
┌────────────────────────────────────────────────────────────┐
│                        V8 Heap                              │
│                                                             │
│  ┌─────────────────────────┐  ┌────────────────────────┐  │
│  │      New Space(新生代)  │  │   Old Space(老生代)   │  │
│  │   ~1-8 MB(可配置)      │  │   几百 MB 到几 GB      │  │
│  │                         │  │                        │  │
│  │  ┌──────────┐           │  │  包含:                │  │
│  │  │ From     │           │  │  • 长期存活对象         │  │
│  │  │ Space    │           │  │  • 大对象空间(>512KB) │  │
│  │  └──────────┘           │  │  • Code Space(JIT码)  │  │
│  │  ┌──────────┐           │  │  • Map Space(HC对象)  │  │
│  │  │ To       │           │  │                        │  │
│  │  │ Space    │           │  │                        │  │
│  │  └──────────┘           │  │                        │  │
│  └─────────────────────────┘  └────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

新生代:Scavenge 算法

新生代使用 Scavenge(也叫 Semi-space Copying GC)算法,基本思路:

初始状态:
From Space [A, B, C, D, E]   To Space [空]

执行 GC:
1. 从根对象(栈变量、全局变量)开始,标记所有可达对象
   可达:A, C, E
   不可达:B, D

2. 将存活对象复制到 To Space:
   To Space [A', C', E']

3. 清空 From Space,然后交换两个 Space 的角色:
   From Space ← 原来的 To Space [A', C', E']
   To Space ← 原来的 From Space [空,待下次使用]

结果:B 和 D 已被回收,内存整理完毕(无碎片化)

Scavenge 的特点

对象计数与晋升:每个对象在新生代中都有一个"存活次数"计数器。如果一个对象在两次 Scavenge 中都幸存下来(计数 >= 2),它就会被晋升(Promote)到老生代。

// 哪些对象会被晋升到老生代?
// 1. 在新生代中经历了 ≥ 2 次 GC 还存活的对象
// 2. 对象大小超过 To Space 剩余容量的对象
// 3. 直接分配超过 512KB 的对象

老生代:Mark-Sweep-Compact 算法

老生代使用传统的标记-清除-整理算法:

阶段 1:标记(Mark)
从根对象开始,深度优先遍历所有引用链
标记所有可达对象(在对象头部设置一个比特位)

根对象 → A → B → C
              ↓
              D
A、B、C、D 都被标记
未被标记的对象视为垃圾

阶段 2:清除(Sweep)
扫描整个老生代堆,释放未标记的对象
被释放的内存块加入空闲列表

释放前:[A][garbage][B][C][garbage][garbage][D]
释放后:[A][free   ][B][C][free   ][free   ][D]
空闲列表:{addr: 0x100, size: 16}, {addr: 0x130, size: 32}

阶段 3:整理(Compact)[可选,按需触发]
将存活对象移动到堆的一端,消除内存碎片
整理后:[A][B][C][D][      free      ]

Mark-Sweep 的问题:会产生停顿(Stop-The-World)——在标记阶段,JavaScript 执行线程必须暂停,等待 GC 完成扫描。在大型应用中,这个停顿可能达到 100ms 以上,导致明显的页面卡顿。

增量标记(Incremental Marking):从 100ms 到 5ms

V8 通过增量标记解决停顿问题:

传统全量标记:
JS执行 ──────────────── PAUSE(100ms GC)──────────────── JS执行

增量标记:
JS执行 ── Pause(5ms) ── JS执行 ── Pause(5ms) ── JS执行 ── Pause(5ms)

总 GC 时间相似,但分散在多个小停顿中,用户感知不到

增量标记的挑战:写屏障(Write Barrier)

增量标记期间,JavaScript 继续执行,可能创建新对象或修改现有引用。这带来了一致性问题:如果 GC 已经扫描了对象 A,然后 JavaScript 把一个未扫描的对象 B 的引用赋给了 A,GC 就会错误地认为 B 不可达。

V8 用写屏障解决这个问题:每次修改对象引用时,写屏障代码自动运行,将被修改的对象加入一个"重新扫描"列表。

// 写屏障的伪代码表示
function writeBarrier(object, field, newValue) {
  object[field] = newValue;
  // 写屏障:
  if (GC.isRunning && GC.alreadyScanned(object)) {
    GC.greyList.add(object);  // 把 object 加入待重新扫描列表
  }
}

写屏障有性能代价:每次属性写入都多一次额外的检查。V8 会在不同的场景下选择不同的写屏障策略(Minor GC 和 Major GC 使用不同的屏障),以最小化开销。

并发标记(Concurrent Marking):进一步消除停顿

V8 在 2018 年(Chrome 64)引入并发标记:

主线程(JS 执行):
JS ──────────────────────────────────────── JS

后台 GC 线程:
         ← 并发标记(不阻塞主线程)→
                                         最终停顿(1-2ms)

并发标记在单独的 GC 线程上进行,主线程可以继续执行 JavaScript。只有在最终确认"哪些对象存活"时,需要一次极短暂的停顿(通常 1-2ms)。

实际性能数据

六种经典内存泄漏场景的深度分析

场景 1:闭包捕获大型中间变量

function processLargeData() {
  const hugeArray = new Array(1000000).fill(0);  // 约 8MB

  // 内层函数只用到 result,但闭包捕获了整个 processLargeData 的词法环境
  // 这意味着 hugeArray 也被闭包"顺带"持有了
  const result = hugeArray.reduce((sum, x) => sum + x, 0);

  return function getResult() {  // 这个函数被返回并长期持有
    return result;
  };
}

const getResult = processLargeData();
// hugeArray(8MB)无法被回收,即使 getResult 只需要 result(一个数字)

// 修复:在返回闭包之前,解除对大型中间变量的引用
function processLargeData() {
  let hugeArray = new Array(1000000).fill(0);
  const result = hugeArray.reduce((sum, x) => sum + x, 0);
  hugeArray = null;  // ← 显式断开引用,允许 GC 回收

  return function getResult() {
    return result;
  };
}

场景 2:DOM 引用在 JavaScript Map 中残留

// 常见的"增强 DOM 元素"模式
const elementCache = new Map();

function enhanceButton(btn) {
  elementCache.set(btn, {
    clickCount: 0,
    handler: () => elementCache.get(btn).clickCount++
  });
  btn.addEventListener('click', elementCache.get(btn).handler);
}

// 当按钮从 DOM 中移除时:
document.body.removeChild(btn);
// btn 在 DOM 树中消失了,但 elementCache 中还有强引用
// 结果:btn 节点(及其所有子树)都无法被 GC 回收

// 修复:用 WeakMap(对 key 的引用是弱引用)
const elementCache = new WeakMap();
// 当 btn 不再被其他地方引用时,WeakMap 不阻止 GC 回收它
// 对应的 entry 会被自动清除

场景 3:事件监听器的匿名函数问题

class VideoPlayer {
  constructor(element) {
    this.element = element;
    this.data = new Array(100000);  // 大型数据

    // 每次调用都创建一个新的匿名函数
    // 无法通过 removeEventListener 移除(因为引用不同)
    this.element.addEventListener('play', () => {
      this.onPlay();  // 箭头函数捕获了 this(整个 VideoPlayer 实例)
    });
  }

  onPlay() {
    console.log('playing');
  }

  destroy() {
    // 这行无效!传入的匿名函数与注册时的不是同一个引用
    this.element.removeEventListener('play', () => { this.onPlay(); });
  }
}

// 修复:保存监听器引用
class VideoPlayer {
  constructor(element) {
    this.element = element;
    this.data = new Array(100000);

    // 绑定并保存引用
    this._onPlay = () => this.onPlay();
    this.element.addEventListener('play', this._onPlay);
  }

  onPlay() {
    console.log('playing');
  }

  destroy() {
    this.element.removeEventListener('play', this._onPlay);  // 正确移除
    this._onPlay = null;
    this.data = null;  // 主动释放大型数据
  }
}

场景 4:Map/Set 中的对象引用积累

// 实时数据处理中的常见模式
const processedRequests = new Map();

function handleRequest(request) {
  processedRequests.set(request.id, {
    data: request.body,  // 可能很大
    timestamp: Date.now(),
  });
  // ...处理逻辑...
}

// 问题:processedRequests 随请求数无限增长
// 没有清理机制

// 修复方案 A:设置 LRU 缓存上限
const MAX_CACHE_SIZE = 1000;
const cache = new Map();

function setCached(key, value) {
  if (cache.size >= MAX_CACHE_SIZE) {
    // 删除最老的条目(Map 按插入顺序迭代)
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}

// 修复方案 B:设置 TTL(生存时间)
const timedCache = new Map();

function setWithTTL(key, value, ttlMs) {
  timedCache.set(key, {
    value,
    expiresAt: Date.now() + ttlMs,
  });
}

function getFromCache(key) {
  const entry = timedCache.get(key);
  if (!entry) return undefined;
  if (Date.now() > entry.expiresAt) {
    timedCache.delete(key);  // 惰性过期
    return undefined;
  }
  return entry.value;
}

场景 5:定时器闭包造成的循环引用

// 在单页应用中频繁出现的问题
function createWidget(container) {
  const heavyData = loadHeavyData();

  const timerId = setInterval(() => {
    updateUI(container, heavyData);  // 闭包同时持有 container 和 heavyData
  }, 1000);

  // 返回一个控制器
  return {
    stop() {
      clearInterval(timerId);  // 正确地停止了定时器
    }
  };
}

// 如果 stop() 从未被调用(路由切换时忘记调用),
// container(DOM 节点)和 heavyData(大型数据)都不会被 GC

// 更安全的模式:使用 AbortController 或 WeakRef
function createWidget(container) {
  const heavyData = loadHeavyData();
  const containerRef = new WeakRef(container);  // 弱引用,不阻止 container 被 GC

  const timerId = setInterval(() => {
    const el = containerRef.deref();
    if (!el || !document.contains(el)) {
      clearInterval(timerId);  // 容器已消失,自动停止
      return;
    }
    updateUI(el, heavyData);
  }, 1000);
}

场景 6:Promise 链上的引用积累

// 在高并发服务器中,未处理的 Promise rejection 会导致内存泄漏
const pendingOperations = [];

async function doOperation(data) {
  const promise = fetch('/api', { body: JSON.stringify(data) });
  pendingOperations.push(promise);  // 为了能取消,保存了引用
  return await promise;
}

// 问题:pendingOperations 不断增长,已完成的 Promise 不会被移除

// 修复:完成后自动移除
async function doOperation(data) {
  const promise = fetch('/api', { body: JSON.stringify(data) });
  pendingOperations.push(promise);

  try {
    return await promise;
  } finally {
    const idx = pendingOperations.indexOf(promise);
    if (idx !== -1) pendingOperations.splice(idx, 1);
  }
}

Level 3 · 规范怎么定义的(资深开发者)

ECMAScript 规范的"不规定"哲学

ECMAScript 规范对内存管理的态度,可以用一句话总结:规范定义对象何时应该可以被回收,但不定义如何回收,也不定义什么时候回收。

规范第 9.10 节(Invariants of the Essential Internal Methods)明确了:

The following invariants must be maintained by all ordinary and exotic objects accessible to ECMAScript code:

  • A non-extensible object must have a stable set of own properties. Once a property is defined, it cannot be removed, and its non-configurable status cannot change.

但对内存回收,规范没有任何专门的章节。垃圾回收在规范中以两种方式出现:

方式 1:通过对象可达性的隐含定义

规范使用"可达"(reachable)这个概念,但不提供正式定义。在第 9.1 节的注释中:

An agent's executing thread executes a job on the agent's call stack. Once the job has completed, the thread removes that job from the stack and executes the next job. ... When an agent's call stack is empty and the current agent is not currently executing a job, the agent may execute a pending job from its Job Queue.

这暗示了:当一个对象不再从"活跃的调用栈"可达时,它的回收是允许的——但具体时机完全由实现决定。

方式 2:通过弱引用的规范定义

ECMAScript 规范第 26.1 节定义了 WeakRef:

26.1.1 The WeakRef Constructor

WeakRef objects let you hold a weak reference to another object, without preventing that object from getting garbage-collected.

The target of a WeakRef is called its target or referent. A WeakRef is said to be live if its target has not been collected by the garbage collector.

这是规范中少数几处直接提及垃圾回收的地方。规范在此处的精确表述是:

26.1.3.2 WeakRef.prototype.deref()

  1. Let weakRef be the this value.
  2. Perform ? RequireInternalSlot(weakRef, [[WeakRefTarget]]).
  3. Return WeakRefDeref(weakRef).

9.12.3 WeakRefDeref(weakRef)

  1. Let target be weakRef.[[WeakRefTarget]].
  2. If WeakRefKey(weakRef) is in the [[KeptAlive]] List of the current agent, return target.
  3. If target is not empty, then a. Perform AddToKeptObjects(target). b. Return target.
  4. Return undefined.

这段规范揭示了一个关键机制:deref() 的结果不仅取决于对象是否存活,还取决于 [[KeptAlive]] 列表——规范保证在当前"工作"(job,即当前微任务检查点之前)期间,deref() 返回非 undefined 的对象不会被回收。这就是为什么 WeakRef 的使用需要"在同一个 job 中处理 deref() 的结果"。

FinalizationRegistry 的规范定义

规范第 26.2 节定义了 FinalizationRegistry:

26.2.1 The FinalizationRegistry Constructor

FinalizationRegistry objects let you request a callback when a value becomes unreachable.

26.2.2.1 new FinalizationRegistry(cleanupCallback)

  1. If IsCallable(cleanupCallback) is false, throw a TypeError exception.
  2. Let finalizationRegistry be ? OrdinaryCreateFromConstructor(NewTarget, "%FinalizationRegistry.prototype%", « [[Realm]], [[CleanupCallback]], [[Cells]] »).
  3. Let fn be the active function object.
  4. Set finalizationRegistry.[[Realm]] to fn.[[Realm]].
  5. Set finalizationRegistry.[[CleanupCallback]] to cleanupCallback.
  6. Set finalizationRegistry.[[Cells]] to a new empty List.
  7. Return finalizationRegistry.

规范在第 9.12.4 节的 CleanupFinalizationRegistry 定义中,明确指出回调的调用时机:

9.12.4 CleanupFinalizationRegistry(finalizationRegistry)

This abstract operation is called by the host environment after an object is garbage-collected. It queues a microtask to invoke the cleanup callback.

...

NOTE: Cleanup callbacks are called as microtasks. The timing of when a cleanup callback is called is non-deterministic. It is possible for a cleanup callback to never be called.

这段话包含了关于 FinalizationRegistry 最重要的事实:清理回调可能永远不会被调用。规范明确说明这一点,意味着任何依赖 FinalizationRegistry 执行必要清理操作(如关闭文件描述符、释放外部资源)的代码都是错误的。

WeakMap 和 WeakSet 的规范语义

规范第 24.3 和 24.4 节分别定义 WeakMap 和 WeakSet。两者的关键属性在第 24.3.1.1 节中:

The keys of a WeakMap are object values and registered symbols. A key is live if the key is reachable. An entry is live if its key is live.

Entries in a WeakMap may be collected if the key (for a WeakMap) or value (for a WeakSet) is not strongly referenced from anywhere.

这段规范告诉我们:WeakMap 的键使用弱引用,如果键对象不再被其他地方强引用,GC 可以回收键,对应的 entry 也会消失。但规范没有规定这个 entry "何时"消失——可能在键对象被回收的同一个 GC 周期,也可能在下一个。

规范特别注意到一个关键约束(Section 24.3.4 note):

WeakMap objects are not enumerable. There is no mechanism for iterating over the keys or values of a WeakMap. If a WeakMap provided introspection methods, it would be possible to observe the garbage collector's behavior and the order in which finalizers are executed. Exposing this information would be a security vulnerability.

这解释了为什么 WeakMap 没有 size 属性、没有 keys() 方法、没有 forEach 方法——任何遍历功能都会暴露 GC 的内部状态,规范明确禁止这种情况。

规范对"活跃引用"的精确定义

规范附录 B(《ECMAScript Language: Additional ECMAScript Features for Web Browsers》)以及主规范中,对"强引用"和"弱引用"做了隐含的区分:

在普通对象属性、变量绑定(Variable Binding)、环境记录(Environment Record)中存储的引用都是强引用。强引用会阻止 GC 回收目标对象。

WeakRef、WeakMap 的键、WeakSet 的值存储的引用是弱引用。弱引用不阻止 GC 回收目标对象。

规范 9.12 节(Managing the interaction between the garbage collector and WeakRef-like objects)给出了正式的"保活"(keep-alive)语义:

9.12.1 AddToKeptObjects(object)

Once an object has been added to [[KeptAlive]], it will not be collected until after the currently executing Job completes.

这说明:在一个同步任务(Job)执行期间,即使某个 WeakRef 的目标在理论上已经不可达,它也不会在本次任务中被回收。这保证了 deref() 的结果在同一个同步块内是稳定的。


Level 4 · 边界与陷阱(全体适用)

陷阱 1:WeakRef.deref() 的结果必须立即使用

const registry = new FinalizationRegistry((key) => {
  console.log(`${key} was collected`);
});

let target = { data: new Array(100000) };
const ref = new WeakRef(target);
registry.register(target, 'myTarget');

target = null;  // 移除强引用,允许 GC 回收

// 错误用法:跨异步边界使用 deref 的结果
async function processIfAlive() {
  const obj = ref.deref();  // 在异步函数开始时 deref

  await someAsyncOperation();  // ← 在这个 await 之后,GC 可能运行

  if (obj) {
    // obj 可能已经是一个被回收了的对象的引用
    // 规范不保证 obj 在 await 后还有效(虽然在 V8 实现中通常还有效,但规范不保证)
    console.log(obj.data.length);  // 不安全
  }
}

// 正确用法:在同一个同步上下文中检查并使用
function processIfAlive() {
  const obj = ref.deref();
  if (obj) {
    // 在同一个同步块内使用,规范保证不会在此期间被 GC
    return obj.data.length;
  }
  return 0;
}

为什么 await 后不安全await 会把后续代码放入微任务队列,在微任务执行之前,GC 有机会运行。规范明确说明 deref() 的"保活"保证只在当前 Job(微任务检查点之间的同步执行块)内有效。

陷阱 2:FinalizationRegistry 不是资源清理的保证机制

// 严重错误:依赖 FinalizationRegistry 关闭文件描述符
class FileHandle {
  constructor(path) {
    this.fd = openFile(path);  // 打开文件,获取文件描述符

    this._registry = new FinalizationRegistry(() => {
      closeFile(this.fd);  // 依靠 GC 来关闭文件
    });
    this._registry.register(this, 'cleanup');
  }
}

// 问题:
// 1. 如果程序正常退出,FinalizationRegistry 回调可能根本不会被调用
// 2. 在 Node.js 中,进程退出时不保证运行 FinalizationRegistry 回调
// 3. 在长时间运行的程序中,GC 可能很长时间不发生,文件描述符耗尽
// 4. 规范明确说明回调"可能永远不会被调用"

// 正确做法:显式资源管理(ES2023 的 using 声明)
class FileHandle {
  constructor(path) {
    this.fd = openFile(path);
  }

  [Symbol.dispose]() {
    if (this.fd !== null) {
      closeFile(this.fd);
      this.fd = null;
    }
  }
}

// 使用 ES2023 的 using 声明(自动调用 Symbol.dispose)
{
  using handle = new FileHandle('/path/to/file');
  // ... 使用 handle ...
}  // 块结束时自动调用 handle[Symbol.dispose]()

规范中明确的不确定性:规范第 26.2.1 节的注释说:

NOTE: Registered objects are held weakly by the registry, so they may be collected even while the FinalizationRegistry object itself is still alive.

这意味着:即使 FinalizationRegistry 对象本身还存活,注册的对象也可能在任何时候被回收——但回调不一定立即执行。

陷阱 3:WeakMap 的 key 类型限制与 Symbol.for 的陷阱

// WeakMap 的 key 必须是对象或注册的 Symbol
const wm = new WeakMap();

// 正确:对象作为 key
wm.set({}, 'value');  // OK

// 正确:Symbol() 创建的唯一 Symbol 作为 key(ES2023+)
const sym = Symbol('my-symbol');
wm.set(sym, 'value');  // OK(V8 v12+,Node.js 20+)

// 错误:字符串/数字/null 作为 key
wm.set('key', 'value');  // TypeError: Invalid value used as weak map key
wm.set(42, 'value');     // TypeError

// 致命陷阱:Symbol.for 创建的全局 Symbol 不能作为 WeakMap 的 key
const globalSym = Symbol.for('shared');
wm.set(globalSym, 'value');  // TypeError: Invalid value used as weak map key

// 为什么?Symbol.for 创建的是全局注册的 Symbol,
// 它的生命周期与应用相同(无法被 GC 回收),
// 用它作为 WeakMap 的 key 毫无意义(永远不会被自动清理)
// 规范明确禁止这种用法

实际案例:一个框架的插件系统用 Symbol.for('plugin:' + name) 作为 WeakMap 的 key 来存储插件状态,结果在生产环境触发了 TypeError: Invalid value used as weak map key 错误,因为 Symbol.for 返回的是全局 Symbol。

陷阱 4:不理解"晋升"导致的性能问题

// 高频创建短命对象的模式——这是正确的,新生代 GC 会高效处理
function processItem(item) {
  const temp = { value: item.value * 2, timestamp: Date.now() };  // 短命对象
  return transform(temp);  // 函数返回后 temp 立即死亡
}

// 危险模式:把应该短命的对象"意外"延长生命周期,导致晋升到老生代
const recentResults = [];  // 全局数组

function processItem(item) {
  const temp = { value: item.value * 2, timestamp: Date.now() };
  recentResults.push(temp);  // temp 被全局数组持有,无法被新生代 GC 回收
  if (recentResults.length > 100) recentResults.shift();  // 才清理
  return transform(temp);
}

晋升到老生代的代价:老生代 GC(Mark-Sweep-Compact)的运行周期远比新生代 Scavenge 长,且可能产生更长的停顿。频繁创建"意外长命"的对象会加速老生代 GC 的触发频率,在高并发服务器上表现为每隔几十秒就有一次 10-50ms 的停顿。

诊断:用 node --trace-gc 观察 GC 类型和频率:

node --trace-gc script.js
# 输出:
# [GC] Scavenge 8.0 -> 7.9 MB, 0.8 ms          ← 新生代 GC,很快
# [GC] Mark-sweep 200.1 -> 150.0 MB, 45.2 ms   ← 老生代 GC,较慢

如果你看到老生代 GC(Mark-sweep)非常频繁,说明大量对象被意外晋升到老生代。

陷阱 5:performance.measureUserAgentSpecificMemory() 的限制

// 这个 API 在以下条件下才能工作:
// 1. 页面必须是跨域隔离的(Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp)
// 2. 只能在 https 下使用
// 3. 结果是近似值,不是精确内存使用

async function measureMemory() {
  if (!performance.measureUserAgentSpecificMemory) {
    console.log('Not supported or not in cross-origin isolated context');
    return;
  }

  const result = await performance.measureUserAgentSpecificMemory();
  console.log(result);
  // {
  //   bytes: 1234567,  // 粗略估计,不精确
  //   breakdown: [
  //     { bytes: 123456, attribution: [...], types: ['Window'] },
  //     { bytes: 234567, attribution: [...], types: ['Worker'] },
  //   ]
  // }
}

// 用 Node.js 的 process.memoryUsage() 代替(更精确)
function getMemoryUsage() {
  const usage = process.memoryUsage();
  return {
    rss: usage.rss,            // 操作系统分配的总内存(包含 C++ 部分)
    heapTotal: usage.heapTotal, // V8 堆的总大小(已分配)
    heapUsed: usage.heapUsed,   // V8 堆的实际使用量(这个最有用)
    external: usage.external,   // C++ 绑定对象使用的内存(如 Buffer)
    arrayBuffers: usage.arrayBuffers, // ArrayBuffer 使用的内存
  };
}

DevTools 堆快照工作流:找到真实泄漏

场景:一个单页应用在导航时内存持续增长,用户反映浏览几分钟后页面变卡。

工作流步骤:

步骤 1:建立基线
- 打开 DevTools → Memory → Heap snapshot
- 点击 "Take snapshot" → 这是快照 1(Snapshot 1)

步骤 2:触发疑似泄漏的操作
- 在应用中导航:进入一个页面,再退出,重复 5 次

步骤 3:拍第二个快照
- 再次点击 "Take snapshot" → 这是快照 2(Snapshot 2)

步骤 4:对比分析
- 选择快照 2
- 在下拉菜单中选择 "Comparison"(对比模式)
- 选择对比基准为快照 1

步骤 5:排查泄漏对象
- 按 "# Delta"(数量差)排序,数量只增不减的就是泄漏候选
- 按 "Size Delta"(大小差)排序,找到占用最多新增内存的对象
- 点击具体类型查看实例列表
- 点击一个实例,查看 "Retainers"(持有者链)

步骤 6:溯源持有者链
- Retainers 链显示了"谁持有这个对象"
- 顺着链往上找,直到找到一个不应该存活的根引用

典型的 Retainers 链:
EventListener → Anonymous function → Closure → YourComponent → HTMLDivElement
说明:EventListener 持有了一个匿名函数,这个函数的闭包持有了 YourComponent,
      而 YourComponent 持有了一个 DOM 节点,但这个节点本应已经被销毁了

实战代码:配合 DevTools 的泄漏检测脚本

// 在 Node.js 中使用 --inspect 和 v8.writeHeapSnapshot()
const v8 = require('v8');
const path = require('path');

function takeSnapshot(label) {
  const filename = path.join('/tmp', `heap-${label}-${Date.now()}.heapsnapshot`);
  v8.writeHeapSnapshot(filename);
  console.log(`Heap snapshot written: ${filename}`);
  return filename;
}

// 模拟泄漏检测流程
async function detectLeak(operation, iterations = 10) {
  // 预热,排除初始化内存
  await operation();

  // 强制 GC(需要 --expose-gc 标志)
  if (global.gc) {
    global.gc();
    global.gc();
  }

  const before = process.memoryUsage().heapUsed;
  const snapshot1 = takeSnapshot('before');

  for (let i = 0; i < iterations; i++) {
    await operation();
  }

  if (global.gc) {
    global.gc();
    global.gc();
  }

  const after = process.memoryUsage().heapUsed;
  const snapshot2 = takeSnapshot('after');

  const growth = after - before;
  const perOp = growth / iterations;

  console.log(`Memory growth: ${(growth / 1024 / 1024).toFixed(2)} MB`);
  console.log(`Per operation: ${(perOp / 1024).toFixed(2)} KB`);
  console.log(`Compare snapshots: ${snapshot1} vs ${snapshot2}`);

  // 如果 perOp 持续增长(而不是趋近于0),说明有泄漏
  return { growth, perOp, snapshot1, snapshot2 };
}

// 运行:node --expose-gc --inspect leak_detector.js

配置 V8 堆大小

# 默认老生代最大约 1.4GB(64位系统)
# 如果服务内存不够,可以调整
node --max-old-space-size=4096 server.js  # 4GB

# 如果想更早触发 GC(降低 GC 触发阈值,减少峰值内存)
node --max-old-space-size=512 server.js   # 512MB,更频繁 GC,更低峰值

# 新生代大小(影响 GC 频率和对象晋升阈值)
node --max-semi-space-size=64 server.js   # 默认约 16MB,增大减少新生代 GC 频率

本章小结

  1. 垃圾回收的基础是可达性,而非引用计数——V8 从"根"(栈变量、全局对象)出发,追踪所有可达对象。任何意外保留的强引用,都会让 GC 误判对象为"活的",这是所有内存泄漏的根本原因。ECMAScript 规范不定义 GC 算法,只定义弱引用的语义边界。

  2. 分代 GC 的性能秘诀是"大多数对象很快死去"——新生代(Scavenge,< 1ms)处理短命对象,老生代(Mark-Sweep-Compact,1-50ms)处理长期存活对象。将短命对象意外"晋升"到老生代(通过保留不必要的引用)会加速老生代 GC 触发,是高并发服务器停顿的常见原因。

  3. 增量标记和并发标记把 GC 停顿从 400ms 压到了 5ms 以内——但这不是免费的:写屏障给每次属性写入带来了额外开销,并发标记需要额外的 CPU 核心。理解这些代价有助于判断什么场景值得为 GC 性能做额外优化。

  4. WeakRef 和 FinalizationRegistry 是规范化的弱引用机制,但有严格限制——deref() 的结果只在当前同步任务中保证稳定;FinalizationRegistry 的回调不保证被调用;两者都不适合作为关键资源清理的主要机制。ES2023 的 using 声明(Symbol.dispose)才是资源管理的正确工具。

  5. DevTools 堆快照的"Comparison + Retainers"工作流是找到泄漏根源的最可靠方法——拍两次快照(操作前后),对比 Delta 找到不减反增的对象,沿 Retainers 链溯源找到不该存活的根引用。结合 node --expose-gc 和手动 GC 触发,可以排除正常的"尚未 GC 的内存"干扰,精确定位真实泄漏。

本章评分
4.8  / 5  (3 评分)

💬 留言讨论