系列文章目录

《JavaScript 基础与进阶笔记》(前期偏基础巩固与常见面试点,后续进入闭包、异步、工程化等进阶主题)



前言

JavaScript 不能手动 free 内存,由引擎的 垃圾回收(GC) 自动处理。面试与线上排查却常问:标记清除怎么理解、V8 为何分代、闭包会不会泄漏、WeakMap 和 Map 差在哪、Chrome 堆快照怎么看。本篇从 GC 基本算法 → 分代策略 → 泄漏场景 → 弱引用 API → DevTools 思路 串讲,并与第 03 篇闭包、第 14 篇 Map/WeakMap 对照。读完后应能解释「什么对象会被回收」以及「如何减少无意常驻内存」。


一、核心概念:可达性与根

1.1 什么是「垃圾」

引擎视角:无法从根(Root)通过引用链到达的对象,即为可回收垃圾。

常见 包括:

  • 全局对象(浏览器 window / globalThis,Node global
  • 当前调用栈上的局部变量与闭包引用的外层变量
  • 引擎内部注册表(如 DOM 包装对象等,实现相关)

只要对象仍被可达,就不会被回收;与「代码里是否还有变量名指向它」不完全等同——闭包、全局属性、Map 的强引用都会延长生命周期。

1.2 强引用 vs 弱引用

类型 含义 对 GC 的影响
强引用 普通变量、Map 的键 只要存在,对象不会被回收
弱引用 WeakMap 键、WeakSet 值、WeakRef 不阻止对象被回收

二、经典算法

2.1 标记-清除(Mark-Sweep)

现代 JS 引擎的主线思路:

  1. 标记:从所有根出发,遍历引用图,标记所有可达对象。
  2. 清除:堆中未被标记的对象视为垃圾,释放内存。
  3. (可选)整理:移动存活对象,减少碎片(Mark-Compact)。

特点:能处理循环引用(A 引 B、B 引 A,若都不可达根,仍会被回收)。

2.2 引用计数(了解)

为每个对象维护被引用次数,降为 0 即回收。

缺陷循环引用永远无法降为 0(早期 IE 部分 COM 对象问题)。JS 主流引擎以标记-清除为主,引用计数仅作补充或特定场景。

// 循环引用示意(现代引擎通常仍能回收,若整体不可达)
function createCycle() {
  const a = {};
  const b = {};
  a.ref = b;
  b.ref = a;
  return a;
}
let x = createCycle();
x = null; // a、b 整体不可达后可被 GC

三、V8 分代回收(简述)

V8 将堆分为新生代老生代,按「对象存活时间」采用不同策略,降低全堆扫描成本。

区域 特点 常用算法
新生代 新创建、多数很快死亡 Scavenge(复制算法,From/To 空间互换)
老生代 经历多次 GC 仍存活 Mark-Sweep + Mark-Compact

晋升:新生代多次存活的对象移入老生代。

面试口述:「新对象在新生代用 Scavenge 快速清理;活得久的进老生代,用标记清除/整理。」不必背 V8 版本细节,说清分代动机即可。


四、内存泄漏:不是 GC 坏了,而是仍被引用

内存泄漏指:程序逻辑上不再需要的对象,仍被强引用挂着,GC 无法回收,内存占用持续上升。

4.1 常见场景

场景 原因 缓解
全局变量 挂到 window / 模块单例 避免无意全局;路由切换清理
闭包 外层变量被内层函数长期引用 用完置 null;缩小闭包粒度(第 03 篇)
** forgotten 监听器 / 定时器** DOM 移除但回调仍注册 removeEventListenerclearInterval
Map 缓存无限增长 强引用键值永不删 LRU 上限、WeakMap、定期清理
Detached DOM JS 仍引用已从文档移除的节点 移除时解绑引用
控制台引用 DevTools 选中对象会阻止回收 排查时勿长期 $0 持有

4.2 闭包与泄漏

闭包本身不是泄漏;若闭包捕获的大对象生命周期应与页面一样长永久挂在全局/单例上,才会出问题。

function leak() {
  const huge = new Array(1e6).fill("x");
  return function () {
    console.log(huge.length); // huge 随闭包常驻
  };
}

const hold = leak(); // hold 在 → huge 在
// 不再需要时:
hold = null;

4.3 Map 缓存 vs WeakMap

/* ❌ Map + 字符串键:cache 只增不减 → 泄漏风险 */
const cache = new Map();
const process = (data) => {
  const key = JSON.stringify(data);
  if (!cache.has(key)) cache.set(key, expensiveCompute(data));
  return cache.get(key);
};

/* ✅ 对象作键且随对象生命周期:WeakMap */
const weakCache = new WeakMap();
const processSafe = (obj) => {
  if (!weakCache.has(obj)) {
    weakCache.set(obj, expensiveCompute(obj));
  }
  return weakCache.get(obj);
};

WeakMap:键必须是对象,键为弱引用;对象无其他引用时可被 GC,对应条目自动消失。不可遍历、无 size

4.4 SPA 与组件卸载

路由离开、组件 unmount 时:

  • 清除 setInterval / setTimeout
  • abort 未完成请求(AbortController
  • off 事件总线 / 自定义订阅
  • 释放大数组、图表实例等对 ref 的引用

五、WeakMap / WeakSet

5.1 WeakMap

  • :仅对象;弱引用键。
  • 用途:DOM 节点元数据、对象私有附加数据、与对象生命周期绑定的缓存。
  • 限制:无 forEachkeyssize(内容可能随时被 GC)。
const wm = new WeakMap();
const el = document.createElement("div");
wm.set(el, { clickCount: 0 });
// el 从 DOM 移除且无 JS 引用后,wm 中条目可被回收

5.2 WeakSet

  • :仅对象;弱引用
  • 用途:标记「已访问 / 已处理」对象,不阻止对象被回收。
const visited = new WeakSet();
function walk(node) {
  if (visited.has(node)) return;
  visited.add(node);
  // ...
}

5.3 与 Map / Set 选型

需求 选择
任意类型键、要遍历、要 size Map / Set
键/值为对象,生命周期随对象,不需枚举 WeakMap / WeakSet

六、WeakRef 与 FinalizationRegistry(了解)

ES2021 提供更低层的弱引用能力,日常业务少用,多用于库与监控。

let obj = { id: 1 };

const ref = new WeakRef(obj);
console.log(ref.deref()); // { id: 1 } — 对象还在时

const registry = new FinalizationRegistry((id) => {
  console.log(`对象 ${id} 已被回收`);
});
registry.register(obj, obj.id);

obj = null;
// 某次 GC 后 ref.deref() 可能为 undefined;registry 回调异步、不保证时机

注意

  • deref() 可能返回 undefined(对象已回收)。
  • FinalizationRegistry 回调不保证立即或一定执行;不能当作 destructor 做关键清理。
  • 清理逻辑仍应靠 dispose / unmount 等显式生命周期。

Node.js 调试可用 node --expose-gcglobal.gc() 手动触发(仅开发环境)。


七、Chrome DevTools 内存排查思路

7.1 Performance monitor

观察 JS heap size 是否随操作只升不降(需结合业务判断是否正常缓存)。

7.2 Heap snapshot(堆快照)

  1. 打开 MemoryHeap snapshot
  2. 操作前拍 Snapshot A,重复可疑操作后拍 Snapshot B
  3. Comparison,看 # Delta 增长的对象类型((string)(closure)Detached HTMLElement 等)。
  4. Retainers 里向上查谁还在引用该对象。

7.3 口述模板

「怀疑泄漏 → 复现路径 → 前后两次快照对比 → 找增量最大的构造函数 → 沿 Retainers 找根引用 → 代码里解除监听/清缓存/缩小闭包。」

7.4 Allocation instrumentation

Allocation sampling 适合定位哪段代码在频繁分配;与快照互补。


八、实践建议

  1. 大对象用完:对不再需要的引用赋 null(帮助断开链,GC 仍异步)。
  2. 慎挂全局globalThis.xxx = hugeData 会常驻。
  3. 缓存要有界:LRU、TTL、WeakMap(键随对象走)。
  4. 闭包:只捕获必要变量;必要时拆函数减少捕获。
  5. 弱引用:DOM 元数据、对象扩展用 WeakMap;不要用 Weak 结构做「需要遍历的缓存列表」。

九、速查表

概念 要点
垃圾 从根不可达
Mark-Sweep 标记可达 → 清除未标记
分代 新生代 Scavenge;老生代 Mark-Sweep/Compact
泄漏 逻辑不要了仍被强引用
WeakMap/WeakSet 弱引用、不枚举、键/值为对象
WeakRef deref() 可能 undefined
排查 堆快照对比 + Retainers

十、易混淆点归纳

  1. 闭包 ≠ 一定泄漏长期不必要的强引用才是问题。
  2. 局部变量在函数执行完且无闭包引用时可回收;闭包会延长外层词法环境。
  3. WeakMap 不能用字符串作键;Map 的键是强引用。
  4. null 不是立刻释放内存,只是断开引用,下次 GC 才可能回收。
  5. FinalizationRegistry 不能替代 unmount 清理
  6. 深拷贝 WeakMap(第 11 篇)与 WeakMap 作缓存是不同话题。

十一、思考与练习

1. 为什么标记-清除能处理循环引用,而纯引用计数不行?

解析:标记看从根是否可达;两个互相引用但不可达根的对象,标记阶段都不会被标记,可一并清除。引用计数下两者计数互不为 0。

2. WeakMap 适合存「用户 id 字符串 → 用户信息」吗?

解析:不适合。键必须是对象;用户 id 应用 Map 并配合 TTL/LRU 限界。

3. 组件卸载时忘记 clearInterval,会导致什么?

解析:回调闭包可能持有组件状态 / DOM,定时器本身也被引用,造成泄漏或僵尸逻辑

4. 堆快照里 (closure) 增多说明什么?

解析:可能有过多闭包仍被引用;需沿 Retainers 查是全局、缓存还是未解绑监听

5. ref.deref() 返回 undefined 表示什么?

解析:原对象已被 GC;不应再使用该对象,需重新创建或走降级逻辑。


总结

  • GC:以可达性为准;主流 标记-清除;V8 分代(新生代 Scavenge、老生代 Mark-Sweep/Compact)。
  • 泄漏:对象仍被强引用(全局、闭包、监听、无界 Map、Detached DOM)。
  • 弱引用WeakMap / WeakSet 存对象关联数据;WeakRef / FinalizationRegistry 了解即可。
  • 排查:Chrome Heap snapshot 对比 + Retainers 追引用链;SPA 强调 unmount 清理

下一阶段进入 DOM 与渲染:DOM 树与节点类型、查询 API、attr vs property 等(系列第 16 篇)。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐