JS 垃圾回收机制

写 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 的工作就一句话:从所有根出发,沿着引用关系一路走,能走到的都是"活的";走不到、孤零零待在堆里的,就是垃圾。看下面这张图:

flowchart TB subgraph 根 Roots R1["window / globalThis"] R2["调用栈局部变量"] R3["闭包捕获的变量"] end R1 --> cfg["全局对象 config ✓ 可达"] R2 --> data["局部对象 data ✓ 可达"] R2 --> child["被引用对象 child ✓ 可达"] data --> child R2 -. 无引用 .-> orphan["孤儿对象 orphan ✗ 垃圾"] style orphan fill:#fee2e2,stroke:#dc2626,color:#991b1b style cfg fill:#dcfce7,stroke:#16a34a,color:#166534 style data fill:#dcfce7,stroke:#16a34a,color:#166534 style child fill:#dcfce7,stroke:#16a34a,color:#166534 style R1 fill:#1e3a5f,color:#fff style R2 fill:#1e3a5f,color:#fff style R3 fill:#1e3a5f,color:#fff

判断标准:从根出发能否"摸到",而不是"有没有变量名"

注意一个反直觉的点:局部变量在函数返回后就失效了。哪怕那个对象本身还好好躺在堆里,只要没有根能摸到它,它就是垃圾。名字只是一个"指针",对象自己可不知道自己的名字。

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 → 当场回收
flowchart LR A[&#34;objA count = 1&#34;] <--> B[&#34;objB count = 1&#34;]

外部变量都断了,两个 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) 是目前所有主流引擎的根基算法,它完美解决循环引用问题------因为它从根出发做"深度优先遍历",根本不数引用次数。

三步走

flowchart LR subgraph ① 标记阶段 Mark direction TB M1[&#34;活&#34;] M2[&#34;活&#34;] M3[&#34;活&#34;] M4[&#34;?&#34;] end subgraph ② 清除阶段 Sweep direction TB S1[&#34;活&#34;] S2[&#34;活&#34;] S3[&#34;活&#34;] S4[&#34;垃圾&#34;] end subgraph ③ 整理 Compact direction TB C1[&#34;活&#34;] C2[&#34;活&#34;] C3[&#34;活&#34;] C4[&#34;连续空闲&#34;] end A[&#34;从根 DFS,可达的打上『活』标记&#34;] --> B[&#34;没标记的整块回收,内存归入空闲链表&#34;] --> C[&#34;把存活对象挪拢,碎片合成大块连续空间&#34;] style M4 fill:#e5e7eb,stroke:#9ca3af,color:#6b7280 style S4 fill:#fee2e2,stroke:#dc2626,color:#991b1b style C4 fill:#f1f5f9,stroke:#94a3b8,color:#64748b style M1 fill:#16a34a,color:#fff style M2 fill:#16a34a,color:#fff style M3 fill:#16a34a,color:#fff style S1 fill:#16a34a,color:#fff style S2 fill:#16a34a,color:#fff style S3 fill:#16a34a,color:#fff style C1 fill:#16a34a,color:#fff style C2 fill:#16a34a,color:#fff style C3 fill:#16a34a,color:#fff

清除之后的空闲内存是零散的 ------像沙盘被挖得到处是坑。再分配一个大对象时可能"空间足够但连不起来",这就是内存碎片 。所以很多引擎会补一个整理(或叫压缩)阶段,把活对象往一边挪,腾出连续空间。这一步在 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。

flowchart LR subgraph 新生代 Young Space direction LR F[&#34;From Space<br/>对象在这里出生&#34;] -->|&#34;Minor GC 复制存活对象&#34;| T[&#34;To Space<br/>空置,等复制&#34;] end subgraph 老生代 Old Space direction LR O[&#34;标记-清除 + 标记-整理<br/>闭包、模块、全局对象、长期缓存...<br/>大对象直接进 Large Object Space&#34;] end T -->|&#34;晋升:躲过 2 次 GC / 空间告急&#34;| O style F fill:#c7d2fe,stroke:#6366f1,color:#312e81 style T fill:#e0e7ff,stroke:#a5b4fc,color:#4338ca style O fill:#bbf7d0,stroke:#16a34a,color:#14532d

生产环境默认堆上限约 1.5~2GB(64 位),Node 可用 --max-old-space-size 调整

新生代:Scavenge 半区复制

新生代只有两个"半区"(Semi-space):FromTo。对象出生在 From。Minor GC 一来:

  1. 从根出发(还得算上记住集------老生代里指向新生代的引用,不然会误杀)标记新生代里的活对象;
  2. 把活对象原样复制到 To 区,每搬一次年龄 +1;
  3. 复制完,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() 一把梭

④ 闭包无意识持有大对象

flowchart LR G[&#34;全局变量 logger&#34;] --> C[&#34;闭包 inner<br/>只打印了函数本身...&#34;] --> B[&#34;bigData 1e6 项<br/>被闭包链保活,收不掉&#34;] style G fill:#1e3a5f,color:#fff style C fill:#fee2e2,stroke:#dc2626,color:#991b1b style B fill:#fef3e2,stroke:#f97316,color:#c2410c

你以为闭包只"用了一点",其实整个词法环境都被保活。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 上场了。它的键必须是对象,且是弱引用------不增加对象的引用计数、不影响可达性判断。

flowchart LR subgraph Map 强引用 M1[&#34;Map 键 node&#34;] ==>|&#34;强引用,保活&#34;| M2[&#34;元数据对象&#34;] end subgraph WeakMap 弱引用 W1[&#34;WeakMap 键 node&#34;] -.->|&#34;弱引用,不保活&#34;| W2[&#34;元数据对象&#34;] end style M1 fill:#c7d2fe,stroke:#6366f1,color:#312e81 style M2 fill:#c7d2fe,stroke:#6366f1,color:#312e81 style W1 fill:#bbf7d0,stroke:#16a34a,color:#14532d style W2 fill:#bbf7d0,stroke:#16a34a,color:#14532d

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(分配时间线)
    录制一遍操作,看蓝色柱子(分配)一茬接一茬
    水位只涨不降 → 泄漏实锤

操作步骤

  1. 打开 DevTools → Memory 面板,点 Heap snapshot,先拍一张基线快照(#1)。
  2. 回页面重复触发疑似泄漏的操作(比如开关 20 次弹窗、切 20 次 Tab)。
  3. 再拍一张快照(#2)。记得先手动触发一次 GC------点 Memory 面板里的垃圾桶图标,把"临死没死透"的对象清理掉,再拍,不然全是噪音。
  4. 选 #2,顶部视图切成 Comparison,对比 #1。# New 列新增的对象就是这两轮操作留下的。
  5. Retained Size 降序排,找最大的几个可疑对象,点开看引用链(Retainers)------是谁一直攥着它,一眼就能定位到泄漏点。
  6. Allocation instrumentation on timeline 再录一遍操作:如果蓝色柱子(分配)一茬接一茬,但每次录制后内存水位只升不降,泄漏实锤。

坑提醒: DevTools 的快照对比要控制变量------同一页面、同一操作序列、同一时间节奏。另外浏览器扩展(比如某个翻译插件)也会塞对象进页面,嫌疑对象看着像泄漏其实是扩展的,先无痕模式排查一轮。


10 一张表总结 + 顺口溜

维度 引用计数 标记-清除 V8 分代回收
判断生死 引用数为 0 从根出发能否到达 标记-清除 + 半区复制
循环引用 ❌ 泄漏(致命) ✅ 免疫 ✅ 免疫
主要开销 每次引用变化都要改计数 全堆扫描标记 只处理存活对象(新生代)
停顿 无停顿(实时) 全停顿 并行/并发/增量,毫秒级
现状 ❌ 已淘汰 理论基础 ✅ 现代引擎实况

顺口溜:

从根出发走一遍, 摸得到的都活着,摸不到的皆可收。

变量名不重要,引用链才重要; 该断的引用不断,GC 想救也救不了。

最后给点实操忠告

  • 定时器、事件监听、订阅(EventEmitter / 发布订阅)、WebSocket------凡是"成对"的 API,销毁时记得"成对"解除。这是泄漏第一大户。
  • 闭包省着点用:外层大对象如果内层函数用不到,就别让它出现在外层作用域里。
  • 缓存加上限、加淘汰,或者直接用 WeakMap。
  • 长任务 / 大循环里产生的临时大对象,尽量让它"用完即失联"(不要塞进外层变量)。
  • 上生产前用 --max-old-space-size 定好 Node 堆上限,别让进程无限制吃内存直到 OOM。
相关推荐
小KK_1 小时前
JS 事件循环从小白视角入门:宏任务、微任务与 async/await 一网打尽
前端·javascript
aloha_1 小时前
Linux服务器上指定目录的文件下载
前端
szp20052 小时前
为了在浏览器里跑多线程 ONNX 推理,我把自己的支付浮层弄挂了
前端·webassembly
kisshyshy3 小时前
从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
前端·javascript·react.js
fatcoder3 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
Data_Journal3 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
计算机魔术师4 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha13144 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
李高钢4 小时前
C# WPF Prism 进阶(二):区域(Region)与模块化(Module)
java·前端·数据库