垃圾回收与内存
系列文章目录
《JavaScript 基础与进阶笔记》(前期偏基础巩固与常见面试点,后续进入闭包、异步、工程化等进阶主题)
- 第 01 篇:数据类型与类型判断
- 第 02 篇:变量声明与作用域
- 第 03 篇:闭包与高阶函数
- 第 04 篇:函数工厂
- 第 05 篇:this 指向与绑定
- 第 06 篇:原型与原型链
- 第 07 篇:类与继承
- 第 08 篇:JS 执行机制与异步队列
- 第 09 篇:数组常用方法
- 第 10 篇:字符串算法
- 第 11 篇:常见手写题合集(上)
- 第 12 篇:常见手写题合集(下)
- 第 13 篇:Promise 与 async/await
- 第 14 篇:数据结构基础
- 第 15 篇:垃圾回收与内存(本文)
文章目录
前言
JavaScript 不能手动 free 内存,由引擎的 垃圾回收(GC) 自动处理。面试与线上排查却常问:标记清除怎么理解、V8 为何分代、闭包会不会泄漏、WeakMap 和 Map 差在哪、Chrome 堆快照怎么看。本篇从 GC 基本算法 → 分代策略 → 泄漏场景 → 弱引用 API → DevTools 思路 串讲,并与第 03 篇闭包、第 14 篇 Map/WeakMap 对照。读完后应能解释「什么对象会被回收」以及「如何减少无意常驻内存」。
一、核心概念:可达性与根
1.1 什么是「垃圾」
引擎视角:无法从根(Root)通过引用链到达的对象,即为可回收垃圾。
常见 根 包括:
- 全局对象(浏览器
window/globalThis,Nodeglobal) - 当前调用栈上的局部变量与闭包引用的外层变量
- 引擎内部注册表(如 DOM 包装对象等,实现相关)
只要对象仍被可达,就不会被回收;与「代码里是否还有变量名指向它」不完全等同——闭包、全局属性、Map 的强引用都会延长生命周期。
1.2 强引用 vs 弱引用
| 类型 | 含义 | 对 GC 的影响 |
|---|---|---|
| 强引用 | 普通变量、Map 的键 |
只要存在,对象不会被回收 |
| 弱引用 | WeakMap 键、WeakSet 值、WeakRef |
不阻止对象被回收 |
二、经典算法
2.1 标记-清除(Mark-Sweep)
现代 JS 引擎的主线思路:
- 标记:从所有根出发,遍历引用图,标记所有可达对象。
- 清除:堆中未被标记的对象视为垃圾,释放内存。
- (可选)整理:移动存活对象,减少碎片(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 移除但回调仍注册 | removeEventListener、clearInterval |
| 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 节点元数据、对象私有附加数据、与对象生命周期绑定的缓存。
- 限制:无
forEach、keys、size(内容可能随时被 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-gc 后 global.gc() 手动触发(仅开发环境)。
七、Chrome DevTools 内存排查思路
7.1 Performance monitor
观察 JS heap size 是否随操作只升不降(需结合业务判断是否正常缓存)。
7.2 Heap snapshot(堆快照)
- 打开 Memory → Heap snapshot。
- 操作前拍 Snapshot A,重复可疑操作后拍 Snapshot B。
- 选 Comparison,看 # Delta 增长的对象类型(
(string)、(closure)、Detached HTMLElement等)。 - 在 Retainers 里向上查谁还在引用该对象。
7.3 口述模板
「怀疑泄漏 → 复现路径 → 前后两次快照对比 → 找增量最大的构造函数 → 沿 Retainers 找根引用 → 代码里解除监听/清缓存/缩小闭包。」
7.4 Allocation instrumentation
Allocation sampling 适合定位哪段代码在频繁分配;与快照互补。
八、实践建议
- 大对象用完:对不再需要的引用赋
null(帮助断开链,GC 仍异步)。 - 慎挂全局:
globalThis.xxx = hugeData会常驻。 - 缓存要有界:LRU、TTL、WeakMap(键随对象走)。
- 闭包:只捕获必要变量;必要时拆函数减少捕获。
- 弱引用:DOM 元数据、对象扩展用 WeakMap;不要用 Weak 结构做「需要遍历的缓存列表」。
九、速查表
| 概念 | 要点 |
|---|---|
| 垃圾 | 从根不可达 |
| Mark-Sweep | 标记可达 → 清除未标记 |
| 分代 | 新生代 Scavenge;老生代 Mark-Sweep/Compact |
| 泄漏 | 逻辑不要了仍被强引用 |
| WeakMap/WeakSet | 弱引用、不枚举、键/值为对象 |
| WeakRef | deref() 可能 undefined |
| 排查 | 堆快照对比 + Retainers |
十、易混淆点归纳
- 闭包 ≠ 一定泄漏;长期不必要的强引用才是问题。
- 局部变量在函数执行完且无闭包引用时可回收;闭包会延长外层词法环境。
- WeakMap 不能用字符串作键;Map 的键是强引用。
- 置
null不是立刻释放内存,只是断开引用,下次 GC 才可能回收。 - FinalizationRegistry 不能替代
unmount清理。 - 深拷贝 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 篇)。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)