跳到主要内容
预计阅读 35 分钟

内存管理与垃圾回收 — 为什么你的页面越用越卡

周五下午,运营同事发来一张截图:“后台管理系统开着不动,过两个小时 Chrome 标签页就崩溃了。“你打开 DevTools 的 Memory 面板,录了一段堆快照,发现内存从 50MB 一路涨到 1.2GB——典型的内存泄漏。要定位根因,你需要理解 V8 引擎如何分配、组织和回收内存。这正是本章要讲的内容。

📋 开篇自测:你已经知道多少?

  1. V8 的堆内存分为哪几个区域?新生代和老生代各用什么回收算法?
  2. 为什么闭包、脱离 DOM 树的节点、被遗忘的定时器会导致内存泄漏?
  3. 在 Chrome DevTools 中,如何用”三次快照法”定位泄漏对象?

一、JavaScript 中的内存生命周期

1.1 三个阶段

无论哪种编程语言,内存使用都遵循相同的生命周期:

内存生命周期

  分配 (Allocate)          使用 (Use)            释放 (Release)
  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │ let user =   │     │ user.name    │     │ user = null  │
  │ { name: '张' }│ ──→ │ user.age     │ ──→ │ 等待 GC 回收  │
  │              │     │ ...          │     │              │
  └──────────────┘     └──────────────┘     └──────────────┘
        ↑                                         ↑
   开发者显式完成                             GC 自动完成
   (C/C++ 也需要手动释放)                   (JavaScript 由引擎负责)

JavaScript 是自动管理内存的语言:分配和释放都由引擎完成。但”自动”不意味着”完美”——引擎只能回收确定不再被引用的对象,而开发者常常无意中保留了不必要的引用。

1.2 栈内存与堆内存

V8 内存布局概览

┌─────────────────────────────────────────────────────────┐
│                     V8 进程内存                           │
│                                                         │
│  ┌─────────────┐    ┌──────────────────────────────────┐│
│  │   栈 (Stack)  │    │           堆 (Heap)               ││
│  │             │    │                                  ││
│  │ 基本类型值   │    │  对象、数组、函数、闭包            ││
│  │ 函数调用帧   │    │  字符串(较长的)                  ││
│  │ 引用地址     │    │  正则表达式                       ││
│  │             │    │                                  ││
│  │ 特点:       │    │  特点:                            ││
│  │ · 空间小     │    │  · 空间大 (早期默认约1.4GB/64位,  ││
│  │             │    │    现代版本根据可用内存动态调整)    ││
│  │ · 自动回收   │    │  · 需要 GC 算法回收               ││
│  │ · 速度极快   │    │  · 存取速度相对慢                  ││
│  └─────────────┘    └──────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
// 栈内存 vs 堆内存
let orderId = 20240315;       // orderId 和值都在栈上
let orderInfo = {             // orderInfo(引用)在栈上
  product: '年度会员',        // 对象本身在堆上
  price: 299,
  buyer: { name: '林晓' }    // 嵌套对象也在堆上
};

let backup = orderInfo;       // 复制引用,指向同一个堆对象
backup.price = 199;
console.log(orderInfo.price); // 199 —— 因为是同一个对象

二、V8 堆的分区结构

2.1 堆空间的五个区域

V8 将堆内存划分为多个区域,不同区域采用不同的管理策略:

V8 堆内存分区

┌──────────────────────────────────────────────────────┐
│                       V8 Heap                         │
│                                                      │
│  ┌───────────────────┐  ┌──────────────────────────┐ │
│  │    新生代 (Young)   │  │     老生代 (Old)           │ │
│  │   (1~16MB 可配)    │  │    (默认约 1.4GB)         │ │
│  │                   │  │                          │ │
│  │  ┌─────┐ ┌─────┐ │  │  ┌──────────────────┐   │ │
│  │  │From │ │ To  │ │  │  │  Old Pointer Space│   │ │
│  │  │Space│ │Space│ │  │  │  (有指针的对象)    │   │ │
│  │  └─────┘ └─────┘ │  │  ├──────────────────┤   │ │
│  │                   │  │  │  Old Data Space   │   │ │
│  │  存放新创建的       │  │  │  (纯数据对象)     │   │ │
│  │  短命对象           │  │  └──────────────────┘   │ │
│  └───────────────────┘  └──────────────────────────┘ │
│                                                      │
│  ┌──────────────┐ ┌──────────┐ ┌──────────────────┐ │
│  │ Large Object │ │ Code     │ │ Map Space        │ │
│  │ Space        │ │ Space    │ │ (隐藏类/形状)     │ │
│  │ (大对象直接   │ │ (JIT编译 │ │                  │ │
│  │  分配在此)    │ │  的机器码)│ │                  │ │
│  └──────────────┘ └──────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────┘

2.2 为什么要分代

V8 的分代策略基于一个统计学事实——代际假说(Generational Hypothesis)

特征新生代对象老生代对象
生命周期短,大多很快不再使用长,被长期持有
典型场景函数内的局部变量、临时计算结果全局缓存、长驻单例、DOM 绑定
空间大小较小(1~16MB)较大(约 1.4GB)
回收频率频繁较少
回收算法Scavenge(复制算法)Mark-Sweep + Mark-Compact

统计显示,大约 70%~90% 的对象在创建后很快就不再被引用。把这些”朝生暮死”的对象单独管理,可以用更高效的算法快速回收,不必每次都扫描整个堆。


三、新生代回收:Scavenge 算法

3.1 核心思想:空间换时间

新生代使用 Scavenge 算法(基于 Cheney 算法),将空间一分为二:

Scavenge 算法执行过程

初始状态: From 空间正在使用, To 空间空闲

    From Space                To Space
  ┌──┬──┬──┬──┬──┐       ┌──┬──┬──┬──┬──┐
  │A │B │C │D │E │       │  │  │  │  │  │
  └──┴──┴──┴──┴──┘       └──┴──┴──┴──┴──┘
   ↑存活 ↑死亡 ↑存活         (空闲)
         ↑死亡 ↑存活

Step 1: 标记存活对象 (A, C, E 仍被引用)
Step 2: 将存活对象复制到 To 空间 (紧凑排列)
Step 3: 清空 From 空间
Step 4: From 和 To 角色互换

    To → 新的 From            From → 新的 To
  ┌──┬──┬──┬──┬──┐       ┌──┬──┬──┬──┬──┐
  │A │C │E │  │  │       │  │  │  │  │  │
  └──┴──┴──┴──┴──┘       └──┴──┴──┴──┴──┘
   存活对象紧凑排列           清空, 等待下次使用
   无内存碎片!

3.2 晋升到老生代

如果一个对象在新生代中”活过”了一次 Scavenge,说明它可能是长寿对象。V8 会将它**晋升(Promote)**到老生代。晋升的条件有两个(满足任一即触发):

  1. 已经历过一次 Scavenge——对象从 From 复制到 To 后被标记,下次再触发 Scavenge 时直接晋升
  2. To 空间使用率超过 25%——为了保证后续分配有足够空间,提前晋升
晋升流程

  新生代 (From Space)                老生代
  ┌────────────────────┐           ┌────────────────────┐
  │  obj_X (第2次存活)  │ ────晋升──→│  obj_X              │
  │  obj_Y (第1次存活)  │ ──复制到To │                    │
  │  obj_Z (已死亡)     │ ──回收    │  (长期存在的对象)    │
  └────────────────────┘           └────────────────────┘

四、老生代回收:Mark-Sweep 与 Mark-Compact

4.1 标记-清除(Mark-Sweep)

老生代空间大、对象多,不适合用复制算法(浪费一半空间)。V8 采用 Mark-Sweep

Mark-Sweep 过程

Step 1: 标记阶段 — 从根对象出发,遍历所有可达对象

  根 (GC Roots)

  ├──→ [A] ──→ [B]

  ├──→ [C] ──→ [D] ──→ [E]

  └──→ [F]

  堆内存: [A][G][B][H][C][D][I][E][F]
              ↑     ↑        ↑
          不可达  不可达    不可达

Step 2: 清除阶段 — 回收未标记的对象

  堆内存: [A][空][B][空][C][D][空][E][F]
              ↑     ↑        ↑
           空洞   空洞     空洞     ← 产生内存碎片!

4.2 标记-整理(Mark-Compact)

Mark-Sweep 会产生内存碎片——零散的空洞可能无法容纳新的大对象。Mark-Compact 在标记完成后增加一步:将存活对象向一端移动,然后清理边界外的内存。

Mark-Compact 过程

  标记后: [A][空][B][空][C][D][空][E][F]

  整理:   [A][B][C][D][E][F][空][空][空]
           ↑                ↑
       存活对象紧凑排列    边界之后全部释放
       无碎片!             连续可用空间!
算法优点缺点适用场景
Mark-Sweep速度快,不移动对象产生内存碎片碎片不严重时
Mark-Compact无碎片,分配高效需要移动对象,耗时更长碎片严重时

V8 在实际运行中交替使用两种算法:多数时候用 Mark-Sweep(速度快),当碎片率过高时切换到 Mark-Compact。


五、减少卡顿:增量标记与并发回收

5.1 全停顿的问题

早期 V8 执行 GC 时会触发 Stop-The-World(全停顿)——暂停 JavaScript 执行,等 GC 完成后再继续。老生代可能有数百 MB 数据,一次完整标记需要几百毫秒,用户会明显感到页面卡顿。

全停顿 (Stop-The-World)

JS执行: ═══════╗                     ╔═══════════
               ║    GC 标记 + 清除     ║
               ║   (200~500ms 停顿)   ║
               ╚═════════════════════╝
                ↑                     ↑
           页面冻结               恢复响应

5.2 增量标记(Incremental Marking)

V8 从 2012 年起引入增量标记:将标记工作拆分成许多小段(每段约 5ms),穿插在 JavaScript 执行之间:

增量标记

传统全停顿:
JS ══════╗           GC           ╔══════
         ╚═══════════════════════╝
                 200ms 卡顿

增量标记:
JS ══╦══╦══╦══╦══╦══╦══╦══╦══╦══
     ║  ║  ║  ║  ║  ║  ║  ║  ║
  GC片段 GC GC GC GC GC GC GC GC
   5ms  5ms ... 每段很短, 用户无感知

V8 使用**三色标记法(Tri-color Marking)**来支持增量标记:

三色标记法

白色: 未被访问的对象 (GC结束后仍为白色的将被回收)
灰色: 已被访问,但其引用的对象尚未全部处理
黑色: 已被访问,且其引用的对象全部已处理

标记过程:
  初始: 所有对象为白色

  Step 1: 根对象标灰
  [灰A]──→[白B]──→[白C]

    └──→[白D]

  Step 2: 处理灰色对象A → 标黑, 子对象标灰
  [黑A]──→[灰B]──→[白C]

    └──→[灰D]

  (此时可暂停, 下次从灰色对象继续)

  Step 3: 继续处理灰色对象
  [黑A]──→[黑B]──→[灰C]

    └──→[黑D]

  Step 4: 完成
  [黑A]──→[黑B]──→[黑C]    ← 存活

    └──→[黑D]              ← 存活
  [白E] [白F]              ← 回收!

5.3 并发回收与并行回收

现代 V8 还引入了**并发(Concurrent)并行(Parallel)**策略:

三种优化策略对比

并行回收 (Parallel):
主线程:  [────── GC ──────]
辅助线程: [────── GC ──────]   多个线程同时做GC, JS仍暂停
辅助线程: [────── GC ──────]   但GC本身更快

并发回收 (Concurrent):
主线程:  [──── JS执行 ────][短暂暂停][── JS执行 ──]
GC线程:  [──────── GC标记 ────────]
         GC在后台线程执行, 不阻塞JS

增量+并发+并行 (V8实际策略):
主线程:  JS [增量GC] JS [增量GC] JS [短暂暂停:最终标记+清除]
GC线程:      [并发标记......]   [并行清除......]

六、常见内存泄漏与排查

6.1 四种典型内存泄漏

// 泄漏1: 意外的全局变量
function handleUpload(fileData) {
  // 忘记写 let/const,uploadCache 成了 window.uploadCache
  uploadCache = processLargeFile(fileData); // 永远不会被回收!
}

// 泄漏2: 被遗忘的定时器
function startPolling(endpoint) {
  const hugePayload = new ArrayBuffer(10 * 1024 * 1024); // 10MB
  setInterval(() => {
    fetch(endpoint, { body: hugePayload });
  }, 5000);
  // 即使组件销毁了,定时器仍在运行,hugePayload 无法回收
}

// 泄漏3: 闭包持有外部大对象
function createProcessor() {
  const rawDataset = loadGiantDataset(); // 100MB 的原始数据
  return function summarize() {
    // summarize 只需要 rawDataset.length
    // 但闭包持有整个 rawDataset 引用,100MB 无法回收
    return rawDataset.length;
  };
}

// 泄漏4: 脱离DOM树但仍被引用的节点
let detachedNodes = [];
function replaceWidget() {
  const oldWidget = document.getElementById('dashboard-widget');
  detachedNodes.push(oldWidget); // JS 仍引用旧节点
  const newWidget = document.createElement('div');
  oldWidget.parentNode.replaceChild(newWidget, oldWidget);
  // oldWidget 已从 DOM 树移除,但 detachedNodes 数组持有引用
  // 旧节点及其所有子节点都无法被 GC 回收
}

6.2 修复策略

// 修复1: 使用严格模式或 lint 规则
'use strict';
function handleUpload(fileData) {
  const uploadCache = processLargeFile(fileData); // 局部变量,函数结束即可回收
}

// 修复2: 保存定时器ID,在恰当时机清除
function startPolling(endpoint) {
  const hugePayload = new ArrayBuffer(10 * 1024 * 1024);
  const timerId = setInterval(() => {
    fetch(endpoint, { body: hugePayload });
  }, 5000);
  // 返回清理函数 (适用于 React useEffect / Vue onUnmounted)
  return () => clearInterval(timerId);
}

// 修复3: 只保留需要的数据
function createProcessor() {
  const rawDataset = loadGiantDataset();
  const dataLength = rawDataset.length; // 只提取需要的值
  // rawDataset 不再被闭包引用,可被 GC 回收
  return function summarize() {
    return dataLength;
  };
}

// 修复4: 使用 WeakRef 或及时清除引用
let widgetRegistry = new WeakSet();
function replaceWidget() {
  const oldWidget = document.getElementById('dashboard-widget');
  widgetRegistry.add(oldWidget); // WeakSet 不阻止 GC
  const newWidget = document.createElement('div');
  oldWidget.parentNode.replaceChild(newWidget, oldWidget);
  // oldWidget 从 DOM 移除后,WeakSet 中的引用不阻止回收
}

6.3 用 DevTools 定位泄漏:三次快照法

三次快照法

Step 1: 打开 DevTools → Memory → Take heap snapshot
        (记录初始状态)

Step 2: 执行可能泄漏的操作 (如打开/关闭弹窗)

Step 3: 手动触发一次 GC (点击 Memory 面板的垃圾桶图标)

Step 4: 再次 Take heap snapshot

Step 5: 重复 Step 2~4

Step 6: 对比三次快照

  快照1         快照2         快照3
  50MB          65MB          80MB
                 ↑             ↑
            增长15MB       又增长15MB
            ← 稳定增长意味着泄漏! →

在快照3中选择 "Comparison" 视图, 对比快照2:
  - 找到 Delta 列为正数的 Constructor
  - 展开查看哪些对象在增长
  - 点击对象查看 Retainers (是谁持有了这个引用)

七、WeakRef 与 FinalizationRegistry

7.1 弱引用

ES2021 引入了 WeakRef,允许持有对象的弱引用——不阻止 GC 回收目标对象:

// 场景: 图片预览缓存, 内存不足时自动释放
class ThumbnailCache {
  #cache = new Map();

  set(photoId, imageData) {
    this.#cache.set(photoId, new WeakRef(imageData));
  }

  get(photoId) {
    const ref = this.#cache.get(photoId);
    if (!ref) return null;

    const imageData = ref.deref(); // 获取原始对象, 若已被GC则返回undefined
    if (!imageData) {
      this.#cache.delete(photoId); // 清理失效条目
      return null;
    }
    return imageData;
  }
}

7.2 FinalizationRegistry

FinalizationRegistry 可以在对象被 GC 回收时收到通知:

const registry = new FinalizationRegistry((metadata) => {
  console.log(`对象已被回收, 元数据: ${metadata}`);
  // 在这里做资源清理,如关闭文件句柄、释放 WebGL 纹理
});

function loadTexture(textureId) {
  const gpuBuffer = allocateGPUBuffer(textureId);
  registry.register(gpuBuffer, textureId); // 注册回调
  return gpuBuffer;
}
// 当 gpuBuffer 被 GC 回收时,registry 的回调会自动触发
WeakRef / WeakMap / WeakSet 对比

┌───────────────┬──────────────────────────────────────────────┐
│    API        │ 特点                                         │
├───────────────┼──────────────────────────────────────────────┤
│ WeakRef       │ 持有单个对象的弱引用, deref()获取             │
│ WeakMap       │ 键必须是对象且为弱引用, 值为强引用             │
│ WeakSet       │ 只存对象, 全部为弱引用                        │
│ Finalization  │ 对象被GC时触发回调, 用于清理外部资源            │
│ Registry      │                                              │
└───────────────┴──────────────────────────────────────────────┘

注意: WeakRef 和 FinalizationRegistry 的回调时机不可预测,
     不应在业务逻辑中依赖它们的执行顺序。

八、本章知识脉络总结

内存管理与垃圾回收知识地图

内存管理
├── 内存模型
│   ├── 栈: 基本类型、调用帧、引用地址
│   └── 堆: 对象、数组、闭包、字符串

├── V8 堆分区
│   ├── 新生代 (Young Generation)
│   │   ├── From Space / To Space
│   │   └── Scavenge 算法 (复制 + 角色互换)
│   │
│   ├── 老生代 (Old Generation)
│   │   ├── Mark-Sweep (标记-清除)
│   │   └── Mark-Compact (标记-整理)
│   │
│   └── 大对象区 / 代码区 / Map区

├── GC 优化
│   ├── 增量标记: 三色标记法, 分段执行
│   ├── 并发回收: GC线程与JS线程同时工作
│   ├── 并行回收: 多个GC线程协同
│   └── 代际假说: 大多数对象朝生暮死

├── 内存泄漏
│   ├── 意外全局变量
│   ├── 被遗忘的定时器/回调
│   ├── 闭包持有大对象
│   ├── 脱离DOM的节点引用
│   └── 排查: 三次快照法 + Retainers

└── 弱引用
    ├── WeakRef: 不阻止GC的引用
    ├── WeakMap / WeakSet: 弱引用容器
    └── FinalizationRegistry: GC回收通知

思考题

  1. 在 React 的单页应用中,路由切换后旧页面的组件已经卸载,但如果旧组件中注册了 window.addEventListener('resize', handler) 却没有在卸载时移除,会发生什么?从 GC 的角度解释为什么 handler 函数及其闭包引用的数据无法被回收。

  2. WeakMap 的键是弱引用,值是强引用。假设你用 WeakMap 缓存 DOM 节点对应的计算结果:cache.set(domNode, expensiveResult)。当 domNode 从 DOM 树移除并且没有其他引用时,expensiveResult 会被回收吗?为什么?


📝 结尾自测:检验你的学习成果

  1. V8 的新生代使用什么算法回收垃圾?为什么这种算法适合新生代?
  2. Mark-Sweep 和 Mark-Compact 的区别是什么?V8 在什么情况下使用 Mark-Compact?
  3. 增量标记使用的三色标记法中,灰色对象代表什么?为什么需要灰色?
  4. 说出至少三种常见的内存泄漏场景,并给出对应的修复方法。
  5. 在 Chrome DevTools 中如何用三次快照法定位内存泄漏?

下一章预告:理解了浏览器如何管理内存之后,我们进入实战篇。下一章将从一个真实的性能监控报警出发,系统地拆解 CLS、LCP、FID 等核心 Web 指标,从加载优化到渲染优化,带你建立一套完整的页面性能治理方案。

购买课程解锁全部内容

前端进阶第一课:11 章掌握浏览器核心

¥29.90