js堆栈

 人参与 | 时间:2026-10-17 07:27:16

打开控制台,堆栈敲下 let a = 1,堆栈再敲 let b = a,堆栈然后改 a = 2。堆栈打印 b,堆栈结果是堆栈 1。这背后就是堆栈栈在干活——原始类型的值直接存在栈里,复制一份就是堆栈真复制。

换成对象试试。堆栈 let obj1 = { n: 1},堆栈let obj2 = obj1,堆栈改 obj1.n = 2,堆栈obj2.n 也跟着变成 2。堆栈变量里存的堆栈其实是个地址,指向堆里的堆栈同一个对象。栈里放的是引用,堆里放的才是实体。

栈:快,但小

栈内存由操作系统直接管理,分配和回收就是移动一个指针,速度极快。函数调用时,每进一层就压一个栈帧,里面装着局部变量、参数、返回地址。函数执行完,栈帧弹出,内存自动释放。

但栈的空间有限。Chrome 里大概 1MB 出头。写个无限递归:

function f() {  f() } f() 

控制台立刻甩你一个 RangeError: Maximum call stack size exceeded。栈溢出了。每一层调用都往栈上压东西,压到装不下为止。

有个细节值得注意:栈是后进先出。函数 A 调用 B,B 调用 C,C 先执行完弹出,再轮到 B,最后才是 A。这个顺序决定了闭包能捕获外层变量——因为外层栈帧还没弹出去。

堆:大,但慢

对象、数组、函数这些大小不固定的东西都扔在堆里。堆内存大得多,但分配和回收需要垃圾回收器介入,速度比不上栈。V8 把堆分成新生代和老生代,新生代用 Scavenge 算法,老生代用标记-清除和标记-整理。这些机制直接影响页面性能——频繁创建大对象,GC 就会频繁跑,主线程卡顿。

一个实际例子:在循环里拼接字符串,早期 JS 引擎会不断在堆上分配新字符串,GC 压力巨大。后来引擎做了优化,但思路没变——堆操作的成本远高于栈操作。

闭包:栈帧的“幽灵”

正常来说函数执行完,栈帧就该销毁。但闭包让这事情变复杂了:

function outer() {  let count = 0 return function() {  count++ return count } } const fn = outer() fn() // 1 fn() // 2 

outer 已经执行完了,count 却还活着。因为返回的函数持有对 count 的引用,V8 把本该在栈上的变量挪到了堆里,或者做了“上下文提升”。栈帧没了,变量在堆上继续存活。这也是闭包可能导致内存泄漏的原因——你以为函数结束了,其实引用链还在。

深浅拷贝的坑,根源也在堆栈

let arr1 = [1, 2, { x: 3}],let arr2 = [...arr1]。改 arr2[2].x = 4,arr1[2].x 也变成 4。展开运算符只复制了栈上的引用,堆里的嵌套对象还是同一个。要彻底断开,得用 structuredClone 或者手写递归,把堆里的东西也复制一份。

我见过一个 bug:组件里用 slice() 复制了一份数组传给子组件,子组件改了数组里对象的属性,父组件的状态跟着变了。查了半天才发现是浅拷贝。这种问题在 React 里尤其常见,因为大家都以为 slice 是“安全”的。

写代码时怎么用这个知识

原始类型随便复制,不心疼。对象和数组,心里要清楚你拿到的是引用还是副本。函数参数传对象,函数内部改了,外面也会变——除非你明确要这个效果,否则就是个隐患。

递归太深就改成迭代,用循环加显式栈来模拟,或者用 setTimeout 拆成异步任务,让调用栈有机会清空。处理大数组时, for 循环比 map / filter 链式调用更省内存,因为后者每一步都创建新数组,堆上多出好几份临时数据。

栈和堆不是抽象概念,它们决定了变量怎么存、函数怎么跑、内存什么时候回收。理解这些,调试内存泄漏和性能问题时,方向会清楚很多。

顶: 837踩: 12251