人人都会AI编程

7.2 内存空间结构:栈内存、堆内存、队列

更新时间:2026-07-11

在 7.1 节我们了解了 V8 引擎的整体编译执行流程,这一节我们将深入 JavaScript 运行时的内存空间结构。理解内存如何组织,是掌握垃圾回收、分析内存泄漏、写出高性能代码的前提。JavaScript 的内存空间主要分为三大块:栈内存(Stack)堆内存(Heap)队列(Queue)

7.2.1 栈内存:有序且快速的临时舞台

栈内存是 JavaScript 执行模型中最基础的空间,用来管理执行上下文基本类型值。它的特点可以概括为“先进后出、自动管理、存取极快”。

栈的结构与运行

当你调用一个函数时,JavaScript 引擎会在调用栈上压入一个新的栈帧(Stack Frame),这个栈帧包含了该函数的执行上下文(局部变量、参数、返回地址等)。函数执行完毕返回时,对应的栈帧被弹出销毁,里面的一切数据随之消失。

function multiply(a, b) {
  const result = a * b;   // result 存储在栈中(基本类型)
  return result;
}

function calculate() {
  const x = 10;
  const y = 20;
  const product = multiply(x, y); // 调用 multiply,压入新栈帧
  return product;
}

calculate();

调用过程产生的栈帧变化如下(简化示意):

  1. calculate 入栈 → 栈帧包含 xy
  2. 调用 multiply → 新栈帧包含 abresult,旧栈帧暂停。
  3. multiply 返回 → 其栈帧弹出,控制权回到 calculate
  4. calculate 返回 → 其栈帧弹出,栈清空。

哪些数据放在栈中?

  • 基本类型值UndefinedNullBooleanNumberBigIntStringSymbol 都是固定大小、不可变的值,引擎直接将它们存在栈帧里。注意,虽然字符串技术上长度可变,但 JavaScript 中的字符串是不可变的,通常也被优化在栈中或通过特定的字符串存储机制优化(V8 内部有字符串池),但从逻辑上你可以认为基本类型的值就在栈里。
  • 引用(指针):对于引用类型(Object、Array、Function 等),栈内存放的不是对象本身,而是一个指向堆内存中实际对象的地址
const age = 25;       // 基本类型,age 的真实值 25 直接存栈
const name = "Alice"; // 基本类型字符串,值 "Alice" 存栈
const user = {        // 引用类型,user 存的是堆中对象的地址
  age: 25,
  name: "Alice"
};

栈的大小是有限的,通常为几 MB。如果递归过深或无限递归,调用栈会溢出,抛出 RangeError: Maximum call stack size exceeded。这也是单线程模型最直观的体现:调用栈就是当前程序“正在做”的事情的一张快照。

7.2.2 堆内存:动态且灵活的数据仓库

如果栈内存像一个整齐的储物柜,每个格子大小固定且只能按顺序存取,那么堆内存就像一个巨大的、结构不规则的仓库,专门存放大小不确定、生命周期不定的复杂数据

堆的用途

所有引用类型的实体(对象、数组、函数、Map、Set 等)都分配在堆内存中。因为这些数据的规模可能在运行时动态变化(例如向数组 push 元素、给对象增加属性),无法在编译期确定,所以需要一片能自由分配和释放的内存区域。

const list = [];              // list 本身在栈中(一个引用地址),空数组实体在堆中
list.push(1, 2, 3);          // 堆中的数组被扩展

const person = { name: "Bob" };
person.age = 30;             // 堆中的对象增加了新属性

堆与栈的协作

当访问一个引用类型变量时,引擎首先从栈帧中得到堆内存地址,然后根据这个地址去堆中读取实际的对象数据。这个过程相比直接读栈多了一步间接寻址,但赋予了程序极高的灵活性。

function createUser() {
  const user = { id: 1, name: "Eve" }; // 栈中 user 存地址,堆中分配对象
  return user;
}

const u1 = createUser(); // 函数结束时栈帧销毁,但堆中的对象还活着(因为 u1 持有引用)
const u2 = u1;           // 复制引用,两个变量指向同一个堆对象
u2.name = "Alice";       // 修改的是堆中的数据
console.log(u1.name);    // "Alice",通过不同引用指向同一实体

堆的垃圾回收

堆内存不会像栈那样自动随函数执行结束而回收,因为引用可能被传递出去、保存在其他地方。因此需要垃圾回收机制来判断堆中的哪些数据不再被任何变量引用,然后释放它们占用的内存。这部分将在 7.3 节详细展开。堆内存泄漏的本质,往往是意料之外的引用长期持有对象,导致 GC 无法回收——比如声明了全局变量、未清除的定时器、遗留的事件监听器等。

7.2.3 队列:异步任务的调度中转站

严格来说,队列(Queue) 并非传统意义上的“内存空间”,而是任务调度结构,存储的是等待执行的回调函数。它是 JavaScript 异步编程的核心组件,位于调用栈和 Web API 之间,由事件循环统一协调。

队列的分类

根据 6.2 节对事件循环的阐述,队列主要分为两类:

  • 宏任务队列(Macrotask Queue / Task Queue):存放 setTimeout 回调、setInterval 回调、I/O 事件回调、UI 渲染任务等。
  • 微任务队列(Microtask Queue):存放 Promise.then/catch/finally 回调、MutationObserver 回调、queueMicrotask 注册的任务等。

队列如何工作

当一个异步操作完成时(例如定时器到期、网络请求返回),其相应的回调并不会被直接插入调用栈,而是先放入对应的队列。事件循环在每次同步代码执行完毕、调用栈清空后,就会优先检查并清空微任务队列,然后从宏任务队列中取出一个任务执行。如此循环往复。

console.log("1");                      // 同步,立即执行

setTimeout(() => console.log("2"), 0); // 宏任务,回调进宏任务队列

Promise.resolve().then(() => console.log("3")); // 微任务,回调进微任务队列

console.log("4");                      // 同步

// 输出顺序:1, 4, 3, 2
// 解释:同步代码先跑,打印 1 和 4;
//       微任务在同步后立即清空,打印 3;
//       最后取一个宏任务执行,打印 2。

内存视角下的队列

这些回调函数本身是堆内存中的函数对象(由对应的 Web API 或 Promise 内部持有),队列中实际存放的是它们的引用。当事件循环执行到该回调时,它会被推入调用栈作为一个新的栈帧运行。执行完毕后,如果该回调没有被其他地方引用,相关的函数对象最终会被垃圾回收。

7.2.4 三者的协同与边界

用一个生活中的比喻来总结三者的协作关系:

  • 就像你此刻正在专注处理的桌面:只放当前要用的工具和材料,用完立刻收走。它高效但面积极小。
  • 是你身后的储物仓库:所有大型、复杂、可能需要长期保留的东西都存那里。拿取需要多走一步(寻址),但可以自由扩展。
  • 队列是你的待办清单:你想做的事(回调)先记下来,等手头的事干完一件再取下一件,确保不会在忙的时候被打断。

理解这三者的结构与职责,是后续学习内存泄漏排查、垃圾回收算法、以及深入掌握异步编程执行顺序的基石。在 7.3 节,我们将聚焦堆内存,详细拆解 V8 的垃圾回收机制如何自动管理那些“仓库里的货”。