栈空间和堆空间:数据是如何存储的|浏览器篇
# 栈空间和堆空间:数据是如何存储的
30 秒速记
JavaScript会自动管理内存,但开发者仍需理解数据在内存中的存放方式。- 内存机制直接影响对赋值行为、垃圾回收和代码执行过程的判断。
- 缺少内存模型认知,容易在实际项目中误判数据变化,并埋下性能问题。
- 当前主题只讨论数据如何存储;垃圾回收和
V8执行代码属于后续层次。
工程上可以把执行上下文和局部变量理解为由栈管理,而对象、数组等大小动态且可能共享的数据通常分配在堆中。 基本类型赋值会得到独立的值,对象赋值复制的则是引用,所以通过任一引用修改属性,其他引用也能看到变化。比如展开语法只能完成浅拷贝,新对象与原对象不再相等,但嵌套对象仍可能共享。需要注意,栈和堆只是便于推理的模型,实际布局会受闭包、逃逸分析和引擎优化影响,不能用它断言某个值的精确内存位置。
对于前端开发者来说,JavaScript 的内存机制是一个不被经常提及的概念 ,因此很容易被忽视。特别是一些非计算机专业的同学,对内存机制可能没有非常清晰的认识,甚至有些同学根本就不知道 JavaScript 的内存机制是什么
但是如果你想成为行业专家,并打造高性能前端应用,那么你就必须要搞清楚JavaScript 的内存机制了。
其实,要搞清楚 JavaScript 的内存机制并不是一件很困难的事,在接下来的三篇文章(数据在内存中的存放、JavaScript 处理垃圾回收以及 V8 执行代码)中,我们将通过内存机制的介绍,循序渐进带你走进 JavaScript 内存的世界。
今天我们讲述第一部分的内容——JavaScript 中的数据是如何存储在内存中的。虽然 JavaScript 并不需要直接去管理内存,但是在实际项目中为了能避开一些不必要的坑,你还是需要了解数据在内存中的存储方式的
原理拆解: JavaScript 语言规范定义的是值、对象和赋值语义,并未强制引擎把某类数据物理存入栈或堆。工程上通常把执行上下文、局部变量等短生命周期信息概括为栈空间,把对象、数组、函数等可共享且大小动态变化的数据概括为堆空间。变量保存基本类型时,赋值获得独立的值;变量指向对象时,赋值复制的是同一对象的引用,因此通过任一引用修改属性,其他引用都能观察到变化。对象变量本身与对象内容应分开理解:前者可随调用帧管理,后者通常由引擎在堆中分配。
最小验证: 输入一个数字和一个对象,分别执行赋值与修改,用来验证基本类型的值复制和对象的引用共享。
let scoreA = 10;
let scoreB = scoreA;
scoreB = 20;
const userA = { name: "Ada", skills: ["JavaScript"] };
const userB = userA;
userB.name = "Grace";
userB.skills.push("TypeScript");
const userC = { ...userA };
userC.name = "Lin";
userC.skills.push("CSS");
console.log(scoreA, scoreB);
console.log(userA.name, userB.name, userA === userB);
console.log(userA.skills);
console.log(userC.name, userC === userA);
数字部分预期输出 10 20,说明重新赋值 scoreB 不会改变 scoreA。userA === userB 为 true,且两者名称都变为 Grace。展开语法只创建浅拷贝,所以 userC !== userA,但嵌套的 skills 仍被共享,最终 userA.skills 也包含 CSS。
边界与排查: “基本类型一定在栈、对象一定在堆”只是便于推理的模型,字符串驻留、闭包捕获、逃逸分析和引擎优化都可能改变实际布局,不能据此判断精确内存位置。const 只禁止变量重新绑定,并不冻结对象;需要阻止顶层属性修改可使用 Object.freeze,但它同样不是递归冻结。排查意外联动时,应先检查 ===、嵌套引用和浅拷贝边界;排查占用增长可使用浏览器开发者工具的 Heap Snapshot 对比分配与保留路径,但快照只能观察堆对象,不能直接证明某个值位于调用栈。
面试官追问
追问 1代码评审中有人坚持“基本类型一定在栈、对象一定在堆”,并据此断言闭包里的字符串绝不可能受引擎优化影响,你会认可吗?
不会认可,这只是便于工程推理的常用模型,不是 ECMAScript 对物理布局的强制规定。字符串驻留、闭包捕获和逃逸分析都可能影响实际实现;分析代码行为应依据值语义与对象身份,不能由模型反推精确地址。
追问 2用户资料页执行 const draft = source 后修改 draft.name,预览区里的 source.name 也同步变化,你会怎样定位并修正设计?
应先用 draft === source 确认二者是否指向同一对象,再检查嵌套字段是否仍共享引用。若业务要求独立草稿,需要按数据结构选择浅复制或满足条件时使用 structuredClone;函数、DOM 节点等不能被当作普通结构化数据直接克隆。
追问 3表单原来只有一层标量字段,使用 {...source} 一直正常;新增嵌套的 skills 数组后,编辑副本却污染原数据,复制方案为什么失效?
展开语法只复制顶层属性,新增的 skills 数组仍由原对象和副本共同引用。应明确需要隔离的层级并复制相应嵌套结构,或在数据满足结构化克隆条件时采用 structuredClone;盲目深复制也会增加处理和内存成本。
追问 4单页应用运行数小时后占用持续增长,开发者拿一张 Heap Snapshot 断言某个局部数字必定位于调用栈且无法回收,你会如何排查?
这张快照只能帮助观察堆对象、分配情况和保留路径,不能直接证明某个值的精确物理位置。应对比多次快照,沿引用链寻找持续保留的对象,并结合闭包和共享引用检查;不要用“栈或堆”的简化模型替代证据。
追问 5状态库团队在“每次深复制整个大对象”和“直接共享并修改对象”之间争论,你会基于什么做选型?
直接共享修改成本较低,但容易产生跨模块联动;完整深复制能加强隔离,却会增加时间与空间开销。应按对象规模、嵌套深度和独立编辑需求确定复制边界,并以对象身份和实际变更路径验证,不能把任何一端当作通用答案。
# 让人疑惑的代码
30 秒速记
- 给变量赋原始值后再复制,
a与b的后续修改互不影响;示例最终分别输出2和1。 - 把对象变量
a赋给b后,二者关联的是同一个对象;通过a.name修改属性,两个变量观察到的内容都会变化。 b = a对对象执行的不是独立内容复制,因此不能把变量赋值等同于对象克隆。- 判断赋值后的联动行为,关键是区分被赋的值属于原始类型还是引用类型。
第一段最终输出 2 和 1,第二段中的 a、b 则都会看到 {name: "极客邦"}。 原始值赋值复制的是数据本身,所以修改 a 不影响 b;对象赋值复制的是引用,两者仍指向同一个对象。修改 a.name 改的是共享对象,而执行 a = {...} 是让 a 指向新对象,不会影响 b。工程中可用 === 判断是否共享对象;展开语法和 Object.assign 只做浅复制,嵌套对象仍可能共用引用。
首先,我们先看下面这两段代码:
function foo(){
var a = 1
var b = a
a = 2
console.log(a)
console.log(b)
}
foo()
function foo(){
var a = {name:" 极客时间 "}
var b = a
a.name = " 极客邦 "
console.log(a)
console.log(b)
}
foo()
若执行上述这两段代码,你知道它们输出的结果是什么吗?下面我们就来一个一个分析下。
执行第一段代码,打印出来 a 的值是 2,b 的值是 1,这没什么难以理解的。
接着,再执行第二段代码,你会发现,仅仅改变了 a 中 name 的属性值,但是最终 a 和 b 打印出来的值都是{name:“极客邦”}。这就和我们预期的不一致了,因为我们想改变的仅仅是 a 的内容,但 b 的内容也同时被改变了。
要彻底弄清楚这个问题,我们就得先从“JavaScript 是什么类型的语言”讲起。
原理拆解: 变量赋值复制的是“值”。原始类型的值就是数据本身,因此 b = a 后,a 与 b 各自保存独立的数值;随后执行 a = 2,只是让 a 绑定新值。对象变量中保存的值可理解为指向对象的引用,b = a 复制了该引用,所以两者能访问同一对象。a.name = "极客邦" 修改的是共享对象的属性,并没有改变 a 自身保存的引用。所谓“栈存原始值、堆存对象”是常用的实现模型,但 ECMAScript 规范并不要求引擎采用固定的栈、堆布局;判断代码行为应以值语义和对象身份为准。
最小验证: 输入包含原始值赋值、对象赋值和对象重新赋值,用来验证“修改共享对象”与“替换变量引用”是两种不同操作。
function verify() {
let a = 1;
let b = a;
a = 2;
console.log(a, b); // 2 1
let userA = { name: "极客时间" };
let userB = userA;
console.log(userA === userB); // true
userA.name = "极客邦";
console.log(userA.name, userB.name); // 极客邦 极客邦
userA = { name: "新对象" };
console.log(userA.name, userB.name); // 新对象 极客邦
console.log(userA === userB); // false
}
verify();
关键行 userA.name = ... 沿引用修改原对象,因此 userB 可观察到变化;userA = {...} 则创建新对象并替换 userA 的引用,不会反向修改 userB。若第二组身份比较仍为 true,通常说明测试代码没有真正执行重新赋值。
边界与排查: 展开语法 {...userA}、Object.assign({}, userA) 只能完成浅复制:顶层属性可以独立修改,但嵌套对象仍可能共享。需要深复制且数据满足结构化克隆条件时,可使用现代浏览器提供的 structuredClone;函数、DOM 节点等值不能按普通结构化数据直接克隆。const 只禁止变量重新绑定,并不冻结对象属性;若要阻止顶层属性修改,可用 Object.freeze,但它同样不是递归深冻结。工程排查时可用 === 验证两个变量是否指向同一对象,并分别测试属性修改与变量重新赋值,避免把引用共享误判为赋值异常。
