写 C 的时候,你要亲手 malloc、再亲手 free:漏了就是内存泄漏,早了就是悬垂指针,崩起来连现场都找不到。JS 从出生那天起就把内存管理替你包圆了------你只管 new,回收的事交给垃圾回收器(Garbage Collector,简称 GC)。听起来很省心,可一旦线上出现"内存只涨不降、页面越来越卡",你就必须搞清楚 GC 到底怎么干活、什么情况下它收不掉。
01 内存的借与还:生命周期
任何语言的内存,都逃不过这三步:
arduino
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 分配内存 │ ───▶ │ 使用内存 │ ───▶ │ 释放内存 │
│ 声明变量 / new / 调用 │ │ 读写变量、调用函数 │ │ GC 判定"没人要"后收走 │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
▲ │
└────────────────────────────────────────────────────────────────────────┘
释放后内存归还堆,供后续分配复用
前两步你天天在写,第三步全靠 GC 自觉。问题就来了:GC 凭什么判断一块内存"没人要"了?
类比一下: 内存像图书馆的座位。你坐下(分配)、看书(使用),走的时候忘了告诉管理员(不释放),管理员就要定期巡逻------发现空着没人坐的位子,就收走你的东西。这个"管理员",就是 GC。它判断"没人坐"的标准,就是下文的核心概念。
02 一切从"可达性"说起
现代 JS 引擎(V8、SpiderMonkey、JavaScriptCore)判断一个对象生死,用的不是"有没有人引用它",而是从根出发能不能摸到它 。这套标准叫可达性(Reachability)。
什么是"根(Roots)"?你随手一数,无非这么几类:
| 根 | 说明 |
|---|---|
| ① 全局对象 | window / globalThis 上的属性,以及全局声明的变量。浏览器一开,它们就是"常驻根"。 |
| ② 当前调用栈 | 正在执行的函数里的局部变量和参数。函数一返回,栈帧弹出,这些根就没了。 |
| ③ 闭包链 | 被闭包捕获的外部变量,只要闭包还活着,它们就算根。 |
| ④ 活动对象 | 微任务队列、事件循环里待处理的任务、Promise 的 pending 状态等,都算临时根。 |
GC 的工作就一句话:从所有根出发,沿着引用关系一路走,能走到的都是"活的";走不到、孤零零待在堆里的,就是垃圾。看下面这张图:
判断标准:从根出发能否"摸到",而不是"有没有变量名"
注意一个反直觉的点:局部变量在函数返回后就失效了。哪怕那个对象本身还好好躺在堆里,只要没有根能摸到它,它就是垃圾。名字只是一个"指针",对象自己可不知道自己的名字。
js
function doSomething() {
const big = new Array(1e6); // 分配了一块大内存
return 42; // big 再也不被任何人引用
} // 函数一返回,big 立刻从"可达"变成"垃圾"
doSomething(); // 那 100 万个元素的数组,就等 GC 来收了
函数返回后,局部变量连同它引用的对象一起"断根"
03 引用计数:教科书里的老古董
你翻开任何一本《JavaScript 高级程序设计》,都绕不开一个概念:引用计数(Reference Counting)。它是最早一批浏览器(Netscape 2.0/3.0 时代)采用的方案,现在基本只在教科书里活着,但它的思想值得搞明白------因为"循环引用"这个词就是从它这儿来的。
原理:一人一把计数牌
每个对象身上挂一个计数器,记录"我被多少个变量引用着"。引用数归零,立刻销毁。
- 变量指向对象 → 计数 +1
- 变量不再指向对象 → 计数 -1
- 计数到 0 → 当场回收
外部变量都断了,两个 count 却互保,谁也不归零 → 永远回收不了。这就是经典循环引用。
致命伤:循环引用
看下面这段代码。两个对象互相咬着不放,外部变量已经全断了,理论上这俩早该是垃圾------但引用计数一看,count 分别是 1 和 1,永远到不了 0。于是它们俩手拉手,在内存里永远住下去。
js
function build() {
const a = { name: 'A' };
const b = { name: 'B' };
a.partner = b; // b 的 count = 1
b.partner = a; // a 的 count = 1
return; // a、b 局部变量失效
} // 但 a ↔ b 互相引用,计数永不为 0
// 引用计数方案下:内存泄漏
build();
历史惨案: IE6/7 的经典泄漏,就是 JS 对象和 DOM 元素互相引用。比如给 DOM 元素挂了个自定义属性指向 JS 对象,对象又存了 DOM 引用,二者循环 → 关掉页面标签,内存都吐不出来。当年"IE 内存泄漏"是前端招聘必问题。
还有一个更致命的问题:引用计数做不到全量追踪。它只关心"谁指向我",从不关心"我从哪来",所以它天生没法回答"从根出发能不能摸到我"这个问题。现代引擎统一改用下面的方案。
04 标记-清除:现在浏览器的主流玩法
标记-清除(Mark-and-Sweep) 是目前所有主流引擎的根基算法,它完美解决循环引用问题------因为它从根出发做"深度优先遍历",根本不数引用次数。
三步走
清除之后的空闲内存是零散的 ------像沙盘被挖得到处是坑。再分配一个大对象时可能"空间足够但连不起来",这就是内存碎片 。所以很多引擎会补一个整理(或叫压缩)阶段,把活对象往一边挪,腾出连续空间。这一步在 V8 里专门叫 Evacuation。
一个完整的例子
咱们手动模拟一次"标记-清除"的过程,感受一下引擎的心路:
js
// ========== 初始状态 ==========
let root = { name: '根对象' };
let mid = { name: '中间层' };
let leaf = { name: '叶子' };
root.next = mid; // root → mid
mid.next = leaf; // mid → leaf
// ========== 中途断链 ==========
mid.next = null; // leaf 失去所有入度,但它没意识到
root.next = null; // mid 也失去入度
// 此刻堆里躺着 root / mid / leaf 三个对象,
// 只有 root 还活着,mid 和 leaf 与根完全断开。
// ========== GC 触发 ==========
// ① 标记:从根出发 DFS
// root ✓ 可达 → 它的属性 next 为 null,链结束
// mid ✗ 摸不到,leaf ✗ 摸不到
// ② 清除:mid 和 leaf 的内存被回收,空闲链表 +2
// ③ root 继续被代码引用,安然无恙
注意:整个过程中没有任何"计数",leaf 被回收只因为它"摸不到了"
所以循环引用在标记-清除下根本不是事: 那些互咬的objA / objB,只要没有根摸到它们,DFS 从根出发根本走不到它们头上,照样整团回收。IE6 的循环引用泄漏,在标记-清除引擎里天然免疫。
05 V8 的分代策略:新生代和老生代
理论说完,落地到 Chrome / Node 用的 V8 。V8 没有对整个堆一视同仁地"标记-清除",而是先分代。依据是统计学规律:大部分对象"朝生暮死"------函数里 new 出来的临时对象,往往活不过一次 GC。
生产环境默认堆上限约 1.5~2GB(64 位),Node 可用
--max-old-space-size调整
新生代:Scavenge 半区复制
新生代只有两个"半区"(Semi-space):From 和 To。对象出生在 From。Minor GC 一来:
- 从根出发(还得算上记住集------老生代里指向新生代的引用,不然会误杀)标记新生代里的活对象;
- 把活对象原样复制到 To 区,每搬一次年龄 +1;
- 复制完,
From / To身份互换------原来的 From 整片清空,变成新的 To。
这招的妙处:回收只看存活对象,不用扫描全部。新生代里 90% 对象都死了,扫描+复制活着的那 10% 是性价比极高的买卖。
js
// 晋升的两种常见触发条件
// ① 年龄达标:对象躲过 2 次 Minor GC 还没死 → 搬进老生代
// ② 空间告急:To Space 快满了(或对象太大)→ 提前塞进老生代
let heavy;
function loop() {
const tmp = { data: new Array(1000) }; // 出生在新生代
heavy = tmp; // 被全局变量持有 → 每次 GC 都活着
} // 躲过几次 Minor GC 后 → 晋升老生代
老生代:Major GC
老生代的对象又大又长寿,用复制算法就亏了(搬一块 100MB 的对象成本太高)。所以老生代回归标记-清除 ,必要时配合标记-整理把碎片清一遍。老生代里还包括代码空间、Map 空间(隐藏类)、大对象空间等,各有各的回收策略。
面试高频题: "V8 为什么分代?"------因为绝大多数对象活不过新生代,在新生代里用复制法只处理存活对象,代价与存活对象数量成正比;直接对整个堆做全量标记-清除,代价与堆大小成正比,浪费在那些必死的临时对象上。分代 = 把精力花在刀刃上。
06 让 GC 别卡住页面:增量、并发、并行
标记-清除有个大毛病:全停顿(Stop The World)。GC 干活的时候 JS 必须暂停------毕竟标记过程中对象引用被改写,结果就不准了。堆越大,停顿越长。老式浏览器里,堆一上去就是几百毫秒的白屏卡顿,滚动页面像放 PPT。
V8 的应对叫 Orinoco(一个内部项目代号),核心思路:把 GC 的活儿拆碎,插进 JS 执行的空隙里,再拉上辅助线程一起干。
diff
朴素全停顿(老式引擎):
|======== JS 执行 ========|==== GC 全停顿 100~300ms ====|========= JS 执行 =========|
增量标记(Incremental):把停顿打碎,每次只停几毫秒
|JS|标记|JS|标记|JS|标记|JS|标记|JS|标记|JS|标记|JS| ...
并发标记(Concurrent):后台线程做标记,主线程几乎无感
|================== JS 一直执行 ==================|
|==== 后台辅助线程做标记(与 JS 并行)====| 极短的收尾停顿
现在的 V8 是组合拳:新生代 Minor GC 走并行 (多线程一起复制,停顿几毫秒);老生代的标记走并发 + 增量;清除/整理阶段部分交给后台线程,主线程只做收尾。于是现代浏览器里,GC 停顿从几百毫秒压到了个位数毫秒,肉眼基本无感。
07 最常见的六个泄漏现场
算法再先进,架不住业务代码作妖。GC 的原则是"摸不到才回收",那泄漏的本质就一句话:让对象明明没用了,却仍然被某个根摸得到。下面是真实的六个翻车现场。
① 意外挂到全局的变量
js
function buildConfig() {
config = { theme: 'dark', plugins: [] }; // 忘了写 const!
return config;
}
buildConfig();
// config 没被声明,非严格模式下自动挂到 window 上
// window.config 是根 → 它指向的对象永远回收不了
// 修复:const config = {...};或全程开启 'use strict'(会直接报错)
② 定时器只开不关
js
function startPolling() {
const heavy = loadBigData(); // 被下面闭包持有
setInterval(() => {
refresh(heavy); // 回调闭包捕获 heavy
}, 1000);
}
// 组件/页面都销毁了,setInterval 还在后台跑
// 回调、回调闭包、heavy ------ 全被全局定时器根持有
// 修复:组件销毁时 clearInterval;或用 setTimeout 递归 + 取消标志
真实事故: 接手过一个后台管理系统,页面挂机一晚上,Chrome 任务管理器里内存从 300MB 爬到 1.6GB。查到最后是表格组件销毁时没解绑轮询定时器,20 个页面 tab 共享同一个轮询回调。改一行
clearInterval的事,拖垮了整个运维值班电脑。
③ 事件监听器只挂不摘
js
class Uploader {
constructor(btn) {
btn.addEventListener('click', this.onClick); // 匿名处理不了,命名也白搭
}
}
// 按钮从 DOM 里移除后,如果还挂着监听,
// 浏览器会"连坐":按钮、监听器、Uploader 实例互相保活。
// 修复:
// destroy() { this.btn.removeEventListener('click', this.onClick); }
// 或现代写法:{ signal: this.ac.signal },destroy 时 ac.abort() 一把梭
④ 闭包无意识持有大对象
你以为闭包只"用了一点",其实整个词法环境都被保活。V8 按词法作用域整体保存,不是你"用到哪段留哪段"。
js
function createLogger() {
const bigData = new Array(1e6).fill('没用的'); // 约 8MB
return function log() {
console.log('hi'); // 从头到尾没碰 bigData!
};
}
const logger = createLogger();
// logger 活着 → 它的词法环境活着 → bigData 活着
// 修复:不用就置空 / 别在闭包外层声明大对象 / 把大对象挪进用到它的函数内部
⑤ JS 数组里躺着已经删除的 DOM
js
const cards = [];
document.querySelectorAll('.card').forEach(el => {
cards.push(el); // 把 DOM 引用存进数组
});
// 之后 ul 里的 .card 被 removeChild 全删了
// 但 cards 数组还攥着这些节点的引用(根可达)
// 删除 DOM ≠ 释放 DOM 内存,要连 JS 引用一起清
// 修复:处理完 cards.length = 0,或改用 WeakSet/WeakMap 存
⑥ 只进不出的缓存
js
const cache = new Map();
function process(id, obj) {
cache.set(id, obj); // 永远不 delete,永远不清空
}
// 长跑的服务里,这就是慢性死亡
// 修复:设置淘汰策略(LRU)、上限校验、定期清理;
// 或者键用对象时换成 WeakMap(见下一节)
08 WeakMap / WeakSet:给 GC 开绿灯
上面的坑 ⑤ 和 ⑥ 有个共同痛点:我既想"捎带手"记一下某个对象,又不想因此把它保活 。这时候就该 WeakMap 上场了。它的键必须是对象,且是弱引用------不增加对象的引用计数、不影响可达性判断。
Map 握紧了键的引用 → node 就算从 DOM 移除,也回收不掉;WeakMap 弱引用不保活 → node 被删后,键和值一起被 GC 收走。所以 WeakMap 没有
size / keys / values,因为内容随时可能消失。
js
// 反例:Map 存 DOM 元数据
const meta = new Map();
const btn = document.querySelector('#btn');
meta.set(btn, { clicks: 0 });
btn.remove(); // 从页面删掉
// btn 及其元数据:被 meta 保活 → 泄漏
// 正例:WeakMap
const meta = new WeakMap();
const btn = document.querySelector('#btn');
meta.set(btn, { clicks: 0 });
btn.remove(); // 不再被页面引用
// 下一轮 GC:btn 和它的元数据一起回收,不用你手动清理
典型用法: 给 DOM 节点挂私有数据、给对象缓存计算结果(memoize)、Vue/React 内部给组件实例挂状态。WeakSet 同理,适合存"我已经处理过这个对象"之类的标记集合。
09 实战:用 DevTools 抓一次真实泄漏
理论滚瓜烂熟,不如亲手抓一次。下面这套流程是排查"内存只涨不降"的标准动作:
csharp
DevTools → Memory 面板
│
├── Heap snapshot(堆快照)
│ ├── #1 操作前(基线快照)
│ ├── (回页面重复触发疑似泄漏的操作,比如开关 20 次弹窗)
│ ├── 先手动点一次 GC(垃圾桶图标),再拍 #2
│ ├── 选 #2,顶部视图切 Comparison → 对比 #1
│ │ # New 列新增对象 → 按 Retained Size 降序
│ │ → 点开看引用链(Retainers),谁攥着它一目了然
│ └── 定位到泄漏点
│
└── Allocation instrumentation on timeline(分配时间线)
录制一遍操作,看蓝色柱子(分配)一茬接一茬
水位只涨不降 → 泄漏实锤
操作步骤
- 打开 DevTools → Memory 面板,点 Heap snapshot,先拍一张基线快照(#1)。
- 回页面重复触发疑似泄漏的操作(比如开关 20 次弹窗、切 20 次 Tab)。
- 再拍一张快照(#2)。记得先手动触发一次 GC------点 Memory 面板里的垃圾桶图标,把"临死没死透"的对象清理掉,再拍,不然全是噪音。
- 选 #2,顶部视图切成 Comparison,对比 #1。# New 列新增的对象就是这两轮操作留下的。
- 按 Retained Size 降序排,找最大的几个可疑对象,点开看引用链(Retainers)------是谁一直攥着它,一眼就能定位到泄漏点。
- 换 Allocation instrumentation on timeline 再录一遍操作:如果蓝色柱子(分配)一茬接一茬,但每次录制后内存水位只升不降,泄漏实锤。
坑提醒: DevTools 的快照对比要控制变量------同一页面、同一操作序列、同一时间节奏。另外浏览器扩展(比如某个翻译插件)也会塞对象进页面,嫌疑对象看着像泄漏其实是扩展的,先无痕模式排查一轮。
10 一张表总结 + 顺口溜
| 维度 | 引用计数 | 标记-清除 | V8 分代回收 |
|---|---|---|---|
| 判断生死 | 引用数为 0 | 从根出发能否到达 | 标记-清除 + 半区复制 |
| 循环引用 | ❌ 泄漏(致命) | ✅ 免疫 | ✅ 免疫 |
| 主要开销 | 每次引用变化都要改计数 | 全堆扫描标记 | 只处理存活对象(新生代) |
| 停顿 | 无停顿(实时) | 全停顿 | 并行/并发/增量,毫秒级 |
| 现状 | ❌ 已淘汰 | 理论基础 | ✅ 现代引擎实况 |
顺口溜:
从根出发走一遍, 摸得到的都活着,摸不到的皆可收。
变量名不重要,引用链才重要; 该断的引用不断,GC 想救也救不了。
最后给点实操忠告
- 定时器、事件监听、订阅(EventEmitter / 发布订阅)、WebSocket------凡是"成对"的 API,销毁时记得"成对"解除。这是泄漏第一大户。
- 闭包省着点用:外层大对象如果内层函数用不到,就别让它出现在外层作用域里。
- 缓存加上限、加淘汰,或者直接用 WeakMap。
- 长任务 / 大循环里产生的临时大对象,尽量让它"用完即失联"(不要塞进外层变量)。
- 上生产前用
--max-old-space-size定好 Node 堆上限,别让进程无限制吃内存直到 OOM。