内存泄漏、栈溢出、栈和堆、垃圾回收看起来像四个独立问题,其实可以沿着一条线理解:JavaScript 运行代码时如何使用内存,数据为什么能够存活,以及内存使用失控后会发生什么。
一、先区分栈和堆
可以先用一个简化模型理解 JavaScript 的内存:
- 栈(Stack):主要管理函数调用、执行上下文、参数和局部变量,采用后进先出的方式。每调用一次函数,就会创建一个栈帧;函数执行结束,对应栈帧随之退出。
- 堆(Heap):主要保存对象、数组、函数等大小和生命周期不固定的数据。变量通常通过引用访问堆中的对象。
function createUser(name) {
const age = 20;
const user = { name, age };
return user;
}
const result = createUser('Alice');
执行 createUser 时会产生一个调用栈帧。age、参数以及对 user 的引用属于当前执行上下文,真正的对象数据由堆管理。函数结束后栈帧退出,但返回的对象仍被 result 引用,所以它还不能被回收。
“基本类型在栈、引用类型在堆”适合作为入门模型,但具体实现由 JavaScript 引擎决定。真正重要的是:函数调用由栈组织,动态对象由堆管理,对象能否回收取决于它是否仍然可达。
二、垃圾回收如何判断对象是否有用
JavaScript 提供自动垃圾回收,开发者通常不需要手动释放内存。现代引擎判断对象能否回收时,核心依据是可达性。
程序会从一组根对象出发,例如全局对象、当前调用栈中的变量和正在执行的闭包,然后沿引用关系继续查找。还能从这些根访问到的对象是可达对象,需要保留;无法再访问到的对象才可以被垃圾回收器清理。
let user = { name: 'Alice' };
let backup = user;
user = null; // 对象仍可通过 backup 访问
backup = null; // 对象变得不可达,可以被 GC 回收
对象之间存在循环引用也不一定泄漏。只要整组对象已经无法从根对象访问,基于可达性的垃圾回收仍然可以处理它们。现代引擎还会结合标记清除、分代回收、增量回收等策略,在回收效率和页面响应之间取平衡。
三、什么是内存泄漏
内存泄漏不是“垃圾回收没有运行”,而是程序已经不需要某些数据,它们却仍然保持可达,垃圾回收器没有理由释放。泄漏持续累积后,页面可能越来越卡、内存占用不断上升,最终崩溃。
前端项目里常见的来源有:
- 未清理的定时器持续引用组件数据。
- 事件监听器没有移除,回调及其闭包一直存活。
- DOM 已从页面删除,但 JavaScript 变量仍保存着节点引用。
- 全局变量、缓存或集合只增加不删除。
- 长生命周期闭包无意中保留了大型对象。
function mount() {
const data = new Array(100000).fill('item');
const timer = setInterval(() => {
console.log(data.length);
}, 1000);
return () => clearInterval(timer);
}
const cleanup = mount();
// 页面或组件销毁时调用
cleanup();
这里定时器回调通过闭包引用 data。只要定时器存在,这个数组就仍然可达。正确做法不是避免闭包,而是在业务生命周期结束时清理定时器、监听器、订阅和外部引用。
四、什么是栈溢出
栈溢出和内存泄漏不是一回事。内存泄漏通常发生在堆上,表现为长期占用逐渐增加;栈溢出是短时间内创建了太多没有退出的函数调用,超过调用栈上限。
function loop() {
loop();
}
loop(); // RangeError: Maximum call stack size exceeded
每次调用 loop 时,上一次调用还没有返回,新的栈帧又被压入调用栈。递归没有终止条件,栈帧越积越多,最后触发 Maximum call stack size exceeded。
正常递归必须有明确的结束条件;处理特别深的数据结构时,还可以改为显式循环和手动栈,避免递归深度受运行环境限制。
function countdown(n) {
if (n <= 0) return;
countdown(n - 1);
}
五、死循环为什么不一定栈溢出
同步死循环会一直占用主线程,导致事件、渲染和其他任务无法执行,页面看起来“卡死”。但如果循环体没有不断嵌套函数调用,它通常不会持续增加调用栈,因此不等于栈溢出。
while (true) {
// 主线程被持续占用,但调用栈没有不断增加新函数层级
}
反过来,无限递归会不断压入栈帧,所以会爆栈。两者都可能让程序不可用,但故障位置不同:死循环主要是主线程被阻塞,无限递归主要是调用栈耗尽。
六、把四个知识点串起来
可以把整个过程理解为:
- 函数执行进入调用栈,对象等动态数据由堆管理。
- 垃圾回收器根据可达性判断堆中的对象是否还能使用。
- 不需要的数据如果仍被引用,就可能形成内存泄漏。
- 函数调用层级如果持续增长且无法返回,就会形成栈溢出。
排查问题时,我会先看现象:内存曲线是否持续上涨、对象是否被意外引用,还是控制台直接出现调用栈超限;如果页面只是无响应,还要判断是不是同步死循环或长任务阻塞了主线程。把栈、堆、引用和生命周期放在一起看,这几个概念就不再是零散的定义。