JavaScript 内存模型

深入解析 JavaScript 堆栈内存模型、垃圾回收算法(标记-清除、分代回收)、内存泄漏的常见原因,以及如何使用 Chrome DevTools 排查内存问题。

为什么内存管理是 JavaScript 的隐形战场

在日常前端开发中,你很少需要显式管理内存——JavaScript 有自动垃圾回收(Garbage Collection)。但这并不意味着你可以忽视内存管理。内存泄漏会导致页面变慢、卡顿甚至崩溃;不理解 V8 的分代回收机制,就无法解释为什么某些代码在生产环境中比本地慢 10 倍。

这篇文章让你深入理解 JavaScript 的内存模型,从堆栈分配到垃圾回收算法,以及 V8 引擎的优化策略。


一、堆与栈的本质

1.1 栈(Stack)

栈是连续内存区域,遵循后进先出(LIFO)原则。JavaScript 引擎为每个执行上下文在栈上分配固定大小的空间。

function multiply(a, b) {
  const result = a * b;
  return result;
}

function square(n) {
  return multiply(n, n);
}

const x = square(5);

栈内存分配过程:

栈生长方向(从高到低):

┌─────────────────────────┐ 高地址
│  square() 本地变量: n   │ ← square 调用
├─────────────────────────┤
│  multiply() 本地变量: a, b, result │
├─────────────────────────┤
│  全局变量: x            │
└─────────────────────────┘ 低地址

栈的特点:

  • 自动管理:函数返回时自动弹栈
  • 快速访问:通过指针偏移直接访问
  • 固定大小:默认栈大小约 1-2MB
  • 空间连续:内存布局紧凑,缓存友好

1.2 堆(Heap)

堆是非连续内存区域,用于存储引用类型的实际数据。

const obj = { name: "Alice", age: 30 }; // 对象存储在堆
const arr = [1, 2, 3, 4, 5];             // 数组存储在堆
const fn = function() {};                // 函数对象存储在堆

堆内存分配:

flowchart LR
    subgraph Stack["栈"]
        S1["变量: obj → 指针 @0x1000"]
        S2["变量: arr → 指针 @0x2000"]
    end

    subgraph Heap["堆"]
        H1["@0x1000: {name: 'Alice', age: 30}"]
        H2["@0x2000: [1, 2, 3, 4, 5]"]
    end

    S1 --> H1
    S2 --> H2

堆的特点:

  • 手动管理:需要垃圾回收器清理
  • 随机访问:通过指针间接访问
  • 大小灵活:可动态扩展(受物理内存限制)
  • 布局分散:内存碎片化

1.3 内存分配的代码示例

// 原始类型 — 栈上分配
const num = 42;           // 栈:num = 42
const str = "hello";       // 栈:str = 'hello' 的引用

// 引用类型 — 栈存指针,堆存数据
const obj = { x: 1 };     // 栈:obj = 指针;堆:{x: 1}

// 函数调用时
function createUser(name) {
  const id = Math.random(); // 栈:id
  return {
    name: name,            // 堆:{name: name, id: id}
    id: id
  };
}

const user = createUser("Alice");

二、垃圾回收算法

2.1 引用计数(Reference Counting)— 早期方案

每个对象记录被引用的次数,引用数为 0 时回收:

let obj1 = { x: 1 };  // 对象引用计数 = 1
let obj2 = obj1;      // 引用计数 = 2
obj1 = null;           // 引用计数 = 1
obj2 = null;           // 引用计数 = 0 → 可回收

致命缺陷:循环引用

function createCycle() {
  const obj1 = {};
  const obj2 = {};
  obj1.ref = obj2;  // obj2 引用计数 = 1
  obj2.ref = obj1;  // obj1 引用计数 = 1
  return "done";
}
// 函数执行完毕后,obj1 和 obj2 引用计数都是 1,
// 永远不会被回收 → 内存泄漏

现代浏览器已不再使用引用计数(除了某些特殊情况)。

2.2 标记-清除(Mark and Sweep)— 现代方案

GC 从根对象(window/global)出发,遍历所有可达对象,未被遍历到的对象被清除:

flowchart TD
    A["根对象<br/>window/global"] --> B["可到达对象<br/>Reachable"]
    A --> C["不可到达对象<br/>Unreachable"]
    B --> D["保留"]
    C --> E["标记"]
    E --> F["清除"]

    style C fill:#ff6b6b
    style E fill:#ffa500
    style F fill:#ff6b6b

标记阶段:GC 从根开始,递归遍历所有可达对象,标记存活对象。

清除阶段:GC 遍历整个堆,回收未标记对象的内存。

2.3 标记-压缩(Mark and Compact)

单纯的标记-清除会产生内存碎片。V8 使用标记-压缩策略:

flowchart LR
    subgraph Before["压缩前"]
        B1["对象A"] --- B2["空闲"]
        B2 --- B3["对象B"]
        B3 --- B4["空闲"]
        B4 --- B5["对象C"]
    end

    subgraph After["压缩后"]
        A1["对象A"] --- A2["对象B"]
        A2 --- A3["对象C"]
        A3 --- A4["空闲"]
    end

2.4 增量标记与并发标记

标记阶段需要暂停 JavaScript 执行(Stop The World),为了减少停顿,V8 使用:

  • 增量标记(Incremental Marking):将标记工作分成小段,与 JavaScript 执行交替进行
  • 并发标记(Concurrent Marking):在后台线程标记,不阻塞主线程
  • 惰性清除(Lazy Sweeping):推迟清除操作,在需要内存时才真正清除

三、V8 的分代回收(Generational GC)

3.1 为什么需要分代

JavaScript 对象的生命周期规律:

  • 大多数对象存活时间很短(临时对象)
  • 长期存活的对象往往会被长期引用

分代回收利用这个规律:新对象放入新生代,存活久的对象晋升到老生代

3.2 新生代(New Space)与老生代(Old Space)

┌─────────────────────────────────────────────┐
│                   V8 堆                      │
├─────────────────────┬───────────────────────┤
│     新生代           │        老生代          │
│   (New Space)       │     (Old Space)       │
│                     │                       │
│  ├─ From Space     │  ├─ Old Pointer Space │
│  └─ To Space       │  └─ Old Data Space    │
│                     │                       │
│  大小: 1-8 MB       │  大小: 数百 MB         │
│  回收频繁           │  回收不频繁            │
│  (Scavenge 算法)    │  (Mark-Compact)       │
└─────────────────────┴───────────────────────┘

Scavenge 算法:新生代使用半空间(semi-space)策略。每次回收时,将 From Space 中的存活对象复制到 To Space,然后交换两个空间。

flowchart LR
    subgraph Before["回收前"]
        F1["对象A (存活)"]
        F2["对象B (死亡)"]
        F3["对象C (存活)"]
    end

    subgraph After["回收后"]
        T1["对象A"]
        T2["对象C"]
        T3["空闲"]
    end

    Before -->|复制存活对象| After

3.3 对象晋升(Promotion)

满足以下条件的对象从新生代晋升到老生代:

  1. 对象在 From Space 存活一定代数(默认 2 代)
  2. 对象在 To Space 空间不足时

3.4 大对象区(Large Object Space)

超过一定大小的对象(如大数组、长字符串)不进行分代,直接分配在大对象区。


四、内存泄漏的常见原因

4.1 全局变量

// 意外创建全局变量
function foo() {
  bigData = new Array(1000000); // 隐式全局变量
}

// 解决:使用严格模式
"use strict";
function foo() {
  // bigData = ... // 现在会报错
}

4.2 闭包

function createLeaker() {
  const hugeData = new Array(1000000);

  // 这个闭包引用了 createLeaker 的作用域,
  // 导致 hugeData 无法被回收
  return function() {
    console.log(hugeData.length);
  };
}

const leak = createLeaker();
// leak 仍然引用着闭包,hugeData 不会被回收

4.3 定时器

// setInterval 未清理
function startTimer() {
  setInterval(() => {
    // 每次执行都引用大量数据
    processData(largeDataset);
  }, 1000);
}

// 解决:组件卸载时清理
function stopTimer() {
  clearInterval(timerId);
}

4.4 DOM 引用

const elements = [];

function captureElement() {
  const el = document.getElementById('large-table');
  elements.push(el); // 即使从 DOM 移除,elements 仍引用
}

// 解决:适时清理引用
elements.length = 0; // 或 elements = null

4.5 事件监听器未移除

// 组件挂载时添加监听
window.addEventListener('resize', handleResize);

// 组件卸载时忘记移除
// 导致 handleResize 和其捕获的变量无法回收
function cleanup() {
  window.removeEventListener('resize', handleResize);
}

五、Chrome DevTools Memory 面板

5.1 内存快照分析

  1. 打开 DevTools → Memory 面板
  2. 选择 Heap Snapshot
  3. 点击"Take snapshot"获取当前堆内存快照
  4. 使用 Comparison 模式对比两次快照的差异

5.2 查找内存泄漏

场景:连续操作 → 快照1 → 操作 → 快照2 → 操作 → 快照3

分析方法:
1. 快照2 vs 快照1:新增了哪些对象
2. 快照3 vs 快照2:是否继续增长(正常应回落)
3. 如果对象数量持续增长且未回收 → 内存泄漏

5.3 Allocation Timeline

使用 Allocation instrumentation on timeline 观察对象分配:

  • 蓝色竖条表示新分配且未回收的对象
  • 灰色竖条表示已回收的对象
  • 持续增长的蓝色区域表示潜在泄漏

六、面试高频问题

Q: JavaScript 的垃圾回收算法是什么?

标记-清除(Mark and Sweep)是现代 JavaScript 引擎的主流算法。GC 从根对象出发,遍历所有可达对象并标记,遍历完成后清除所有未标记对象。V8 在此基础上做了优化:采用分代回收,新生代使用 Scavenge 算法(空间换时间),老生代使用标记-压缩算法(减少内存碎片)。

Q: 什么是内存泄漏?如何排查?

内存泄漏是对象仍然被引用但已无用,却无法被垃圾回收的现象。常见原因包括:全局变量、闭包捕获大量数据、定时器未清理、DOM 引用未移除、事件监听器未注销。排查方法:使用 Chrome DevTools Memory 面板的 Heap Snapshot 对比分析,或使用 Allocation Timeline 观察对象分配。

Q: 为什么 V8 要用分代回收?

分代回收基于"大多数对象存活时间很短"的经验规律。新生代频繁回收,每次只处理小部分数据,效率高;老生代回收代价大,但回收频率低。V8 的新生代使用 Scavenge 算法,将空间分成两半,只有一半在使用,存活对象复制到另一半即可,代价很低。


延展阅读