在 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();
调用过程产生的栈帧变化如下(简化示意):
calculate入栈 → 栈帧包含x、y。- 调用
multiply→ 新栈帧包含a、b、result,旧栈帧暂停。 multiply返回 → 其栈帧弹出,控制权回到calculate。calculate返回 → 其栈帧弹出,栈清空。
哪些数据放在栈中?
- 基本类型值:
Undefined、Null、Boolean、Number、BigInt、String、Symbol都是固定大小、不可变的值,引擎直接将它们存在栈帧里。注意,虽然字符串技术上长度可变,但 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 的垃圾回收机制如何自动管理那些“仓库里的货”。