「前端进阶」JS 中的栈内存与堆内存
“基本类型在栈、引用类型在堆”是前端文章中常见的入门模型,但它不是 ECMAScript 规范规定的存储布局。本文保留这个模型用于建立直觉,同时补充现代 JavaScript 引擎的真实边界:规范描述值、对象身份、执行上下文和可达性,具体放在栈、堆、寄存器还是优化后的表示由引擎决定。
一、先区分两个“栈/堆”概念
1. 栈(stack)数据结构
栈是一种后进先出(LIFO,last-in-first-out)的数据结构,只能从栈顶访问。把乒乓球放进盒子可以帮助理解:最后放进去的球最先取出,想拿底部的球必须先移走上面的球。

图片来源:原文配图。已下载为本地图片;原图来源站点的版权和许可信息仍然适用。
2. 堆(heap)数据结构
在数据结构课程中,heap 通常指二叉堆等优先队列结构,它和“堆内存”不是同一个概念:
- 二叉堆有父子关系,常用于实现最小堆或最大堆;
- 堆内存是运行时管理动态对象的一种实现区域;
- “堆内存可以随机访问”是对动态存储的口语描述,不代表所有访问都有相同成本。
把这两个概念混为一谈,会误以为 JavaScript 的对象一定存储在某种二叉堆中,这是错误的。
二、JavaScript 的值和对象身份
ECMAScript 规定了原始值和对象值,而没有规定必须使用栈和堆保存它们。现代规范中的原始类型包括:
undefinednullBooleanNumberBigIntStringSymbol
除此之外是对象类型,包括普通对象、数组、函数、日期、正则、Map、Set、TypedArray 等。
原始值不可变,但保存原始值的变量可以重新绑定:
let value = 'hello'
value += ' world'
console.log(value) // hello world
这里不是修改了原来的字符串,而是计算出一个新字符串并让变量绑定到新值。引擎是否复用字符串、是否把某个短值直接编码在寄存器或指针中,属于实现细节。

图片来源:原文配图。
对象具有身份。两个结构相同的对象也不相等,因为它们是不同的对象值:
const a = { name: 'Ada' }
const b = { name: 'Ada' }
const c = a
console.log(a === b) // false
console.log(a === c) // true
变量复制时,复制的是对应的值;当值是对象时,这个值表现为对同一对象身份的引用:
let m = { a: 10, b: 20 }
let n = m
n.a = 15
console.log(m.a) // 15
console.log(m === n) // true

图片来源:原文配图。
三、为什么“基本类型在栈、对象在堆”只是模型
经典模型为了方便讲解,常画成:
- 变量保存原始值,原始值画在栈中;
- 变量保存对象引用,对象本体画在堆中;
- 函数调用创建栈帧,调用结束后栈帧消失;
- 堆中的对象在不再可达时等待垃圾回收。
这个模型可以帮助理解“对象赋值后会共享修改”,但以下说法不能当作规范事实:
- 所有原始值都一定放在栈内存;
- 所有对象都一定放在堆内存;
- 栈内存大小固定、堆内存一定更慢;
- 闭包变量必然被搬到堆上;
- 变量离开函数就立刻销毁。
JavaScript 引擎可能使用指针压缩、内联小整数、寄存器、逃逸分析、标量替换和优化后的对象表示。闭包捕获的变量可能被提升到引擎管理的环境记录中,也可能被优化;只要仍可观察,开发者不应依赖其物理地址。
四、变量复制的直观例子
4.1 原始值复制
let a = 20
let b = a
b = 30
console.log(a) // 20
console.log(b) // 30
这是“值相互独立”的语言层表现。不要把它简单理解为一定创建了两个固定大小的栈槽;实现可以共享不可变数据或采用立即数表示,只要可观察结果符合规范即可。

图片来源:原文配图。
4.2 对象引用复制
const first = { name: 'Ada' }
const second = first
second.name = 'Grace'
console.log(first.name) // Grace
console.log(first === second) // true
如果想创建新的外层对象,需要显式浅拷贝或深拷贝:
const source = { name: 'Ada', profile: { language: 'JavaScript' } }
const shallow = { ...source }
shallow.name = 'Grace'
shallow.profile.language = 'TypeScript'
console.log(source.name) // Ada
console.log(source.profile.language) // TypeScript,共享嵌套对象

图片来源:原文配图。
五、函数参数不是“引用传递”
ECMAScript 的函数参数是按值传递。对象参数传递的是对象引用这个值的副本:
let name = 'ConardLi'
function changeName(value) {
value = 'code秘密花园'
}
changeName(name)
console.log(name) // ConardLi
修改对象属性可以观察到外部变化,是因为两个引用值指向同一个对象:
const user = { name: 'ConardLi' }
function changeUser(value) {
value.name = 'code秘密花园'
value = { name: 'another user' }
}
changeUser(user)
console.log(user.name) // code秘密花园
函数内部重新绑定 value 不会重新绑定外部的 user。更准确的术语是“按值传递对象引用”,而不是“按引用传递变量”。
六、闭包和执行环境
原文的闭包示例存在 functionA、functionB、A、B 大小写不一致的问题。可运行示例:
function createCounter() {
let count = 0
return function readAndIncrement() {
count += 1
return count
}
}
const next = createCounter()
console.log(next()) // 1
console.log(next()) // 2
readAndIncrement 仍然可以访问 createCounter 的词法环境,所以形成闭包。调用栈中的函数调用记录可以结束,但只要闭包仍然可达,被捕获的 count 就不能被回收。
这并不意味着开发者可以精确断言“闭包变量存储在堆内存”。引擎可能使用不同的环境表示和优化;从应用角度只需要关注引用是否仍然可达,以及监听器、定时器、缓存是否意外保留闭包。
七、内存分配、回收和性能
经典文章常把栈说成效率高、堆说成效率低。实际性能取决于对象大小、分配器、缓存局部性、垃圾回收压力、引擎优化和代码形态,不能用“栈一定快、堆一定慢”作性能结论。
可以关注这些更可靠的实践:
- 不要在循环中无必要地创建大量临时对象;
- 不要通过全局数组、事件监听器、定时器或无限缓存长期保留已不用的数据;
- 使用浏览器 Performance/Memory 工具和 Node.js heap snapshot 验证泄漏;
- 不要为了“让对象进栈”而改变代码,JavaScript 不提供这样的可移植控制;
- 对需要结构复制的场景,明确使用浅拷贝、
structuredClone()或领域序列化,而不是依赖内存布局。
垃圾回收通常依据可达性判断对象是否仍可能被程序访问。对象即使不可达,也不会保证在某个固定时刻立刻释放;GC 的触发和回收时机由引擎决定。
八、总结
- 栈和堆既可以指数据结构,也可以指运行时内存区域,不能混为一谈;
- ECMAScript 规定原始值、对象身份和引用语义,没有规定值必须放在栈或堆;
- 原始值不可变,但变量可以重新绑定;对象赋值会复制对象引用值;
- 函数参数始终按值传递,对象参数只是传递了引用值的副本;
- 闭包会保留仍被使用的词法环境,但不能据此断言具体物理存储位置;
- 内存问题应从可达性、生命周期和实际快照分析,而不是只背“栈堆模型”。
参考资料: