1. 从“Maximum call stack size exceeded”说起:栈到底是什么
如果你写过前端,大概率见过这么一条报错:
code复制RangeError: Maximum call stack size exceeded
我第一次看到的时候,第一反应是“我代码里也没写栈啊,怎么就说栈溢出了?”后来做了几年开发,回头看这个问题,发现很多初学者对栈的理解都停留在“数据结构教材里的那个先进后出的东西”,但实际工程里,栈的身影几乎无处不在——函数调用、浏览器渲染、编辑器撤销、异步任务调度、算法题里的单调栈,甚至你简历上写的“技术栈”,都是它。
先说结论:栈(Stack)是一种受限的线性表,只允许在同一端——也就是栈顶——进行插入和删除操作。这个限制听起来像是“功能阉割”,但恰恰是这种限制,让它成了计算机世界里最基础、最高效的结构之一。
打个比方:栈就像你办公桌上的一摞文件,你总是从最上面那份开始处理,新来的文件也只能放在最上面。你要拿最底下的那份,得把上面所有文件都搬开。这就是“后进先出”——Last In, First Out,简称LIFO。
这个特性在什么场景下特别有用?答案是:但凡涉及“先做的事后做完,后做的事先完成”的逻辑,栈都是最佳选择。比如你打开一个网页,浏览器要依次执行各个阶段的初始化逻辑;你调用一个函数,函数里又调用了另一个函数;你写一个递归遍历目录的程序,目录套目录——这些场景,系统都需要记住“当前走到哪一层了,待会还要回到哪一层”,栈就是用来记这笔账的。
所以“栈和stack”这个话题,看起来是数据结构里的基础概念,实际上串联了从底层内存管理到高阶前端架构、从算法竞赛到工程开发的全链路知识。这篇文章我不打算像教科书那样列定义、画示意图,而是想从实际工程里那些和栈相关的现象出发,把它的原理、应用、坑,一次讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 调用栈的工作方式:函数嵌套时内存里发生了什么
2.1 栈帧:一次函数调用的“现场记录”
很多初学者会好奇一个问题:程序执行到函数A里,调用函数B,B执行完了,CPU凭什么知道该回到A的哪一行继续执行?答案就是栈帧。
每次函数调用,系统会把当前函数的返回地址、局部变量、参数、临时计算值等信息打包成一个“栈帧”,压入调用栈。当函数执行完毕,系统把这个栈帧弹出,根据里面记录的返回地址,回到调用者继续执行。
我用一个很简单但也足够典型的例子演示:
javascript复制function greet(name) {
const message = buildMessage(name);
console.log(message);
}
function buildMessage(name) {
return `Hello, ${name}!`;
}
greet('zhangsan');
这段代码的执行过程是这样的:
- 程序从顶层代码开始,遇到
greet('zhangsan'),把顶层代码的后续指令地址、函数参数等信息打包,压入调用栈。 - 进入
greet函数体,发现要调用buildMessage(name),先把当前作用域里的name、下一行要执行的console.log(message)指令位置等打包成新栈帧,压入调用栈。 buildMessage执行完毕后,它的栈帧被弹出,控制权回到greet中刚才记录的指令位置。greet执行完毕,栈帧弹出,控制权回到顶层代码。
这里的核心思想是:每个正在执行的函数,都在调用栈里占着一个栈帧;栈帧的压入和弹出,天然对应了函数调用的开始和结束。因为栈帧的压入和弹出高度对称,内存的分配和释放顺序也完全可控,所以函数调用机制选用了栈结构,而不是队列或别的什么结构。
2.2 堆与栈的分工:不止是“一个向上一个向下”
说到调用栈,就不可避免地要聊堆(Heap)。很多面试新手会把“堆栈”当成一个词,但实际上堆和栈是两种完全不同的内存区域。
栈的特点是:内存分配和释放的次序完全确定——函数调用就分配,函数返回就释放。所以它的分配速度极快,只需移动一个栈顶指针就行。代价是生命周期必须遵循后进先出,你不能在一个函数返回后,还想着它里面的局部
