Hey 前端小伙伴们!👋 今天我们来一场JavaScript 内存世界的沉浸式旅行。
你是不是经常听到:“闭包会内存泄漏”、“对象是引用类型”、“var会变量提升”……但总觉得这些概念像雾里看花?
别担心!这篇文章将带你 从编译开始、走到执行、再到回收,一步步揭开 JS 内存机制的神秘面纱。我们不仅要讲“是什么”,更要讲清楚“为什么”和“怎么运作”。准备好了吗?Let’s dive in!🌊
💡 JavaScript 是动态弱类型语言 —— 变量的“身份”随时可变
在深入内存之前,先明确一个基本设定:
JS 是一门动态弱类型语言。
什么意思?
- 动态:变量的数据类型在运行时才确定。
- 弱类型:不同类型之间可以自动转换。
let x = 10; // number
x = "极客时间"; // string
x = true; // boolean
不像 C/C++ 那样需要提前声明 int a; char* b;,JS 完全不管你是谁,只管你当下“扮演的角色”。
而且,你也不用像 C 一样手动调用 malloc() 分配内存、free() 释放内存。
这一切都由 JS 引擎(如 V8)自动管理,开发者只需专注逻辑,不用直面内存操作。
但这并不意味着你可以忽视内存 —— 正相反,理解内存机制,才能写出更高效、更稳定的代码!
🏗️ JS 内存空间三大区域:代码区、栈、堆
我们可以把 JS 的内存想象成一栋三层小楼:
1️⃣ 代码空间(Code Space)
所有 JS 脚本从硬盘加载进来后,就住在这里。它是程序的“剧本”,告诉引擎“该做什么”。
比如你在 <script> 标签里写的函数定义:
function foo() {
console.log("Hello");
}
这行代码本身不会立刻执行,而是先被读取并解析,存入代码空间等待调用。
2️⃣ 栈内存(Stack Memory)—— 快速通道 VIP 区
- 特点:小、快、连续、有序
- 存放内容:调用栈 + 简单数据类型的值
什么是简单数据类型?就是这八种原始值中的前七种:
undefined,null,boolean,number,string,symbol,bigint
它们的特点是:固定长度、可以直接存储值本身。
举个例子🌰:
function foo() {
var a = 1;
var b = a;
a = 2;
console.log(a); // 2
console.log(b); // 1
}
foo();
这里的 a 和 b 都是数字,直接在栈中保存的是实际的数值。当 b = a 时,是值拷贝,相当于复制了一份独立的数据。所以后续修改 a 不会影响 b。
3️⃣ 堆内存(Heap Memory)—— 大平层仓库区
- 特点:大、灵活、非连续、分配慢
- 存放内容:复杂数据类型的实际对象
包括:
- 对象
{name: '极客时间'} - 数组
[1, 2, 3] - 函数(作为对象存在)
- 闭包捕获的外部变量环境
重点来了👇
当你写:
var obj = { name: '极客时间' };
JS 引擎做了两件事:
- 在堆内存中创建这个对象,存放它的属性和值;
- 在栈内存中为
obj分配空间,存的是指向堆中对象的地址(指针)。
也就是说,obj 本身在栈里,但它存的不是 {name: '极客时间'},而是类似 "0x1A3F" 这样的内存地址。
再看这个经典例子:
var a = { name: '极客时间' };
var b = a;
a.name = '极客邦';
console.log(b.name); // 极客邦 😯
为什么 b 也变了?因为 a 和 b 指向的是同一个堆内存地址!它们是“同一份数据的不同名字”。
这就是所谓的引用赋值 —— 改一处,处处生效。
🔍 编译阶段:变量提升与内存预分配
JS 并不是一边读一边执行的。它有一个编译阶段(其实叫“解析阶段”更准确),发生在代码真正执行前的一瞬间。
以这段代码为例:
function foo() {
console.log(x); // undefined
var x = 1;
console.log(x); // 1
}
foo();
虽然 x 是在后面才赋值的,但第一行却能打印出 undefined 而不是报错。这是为什么?
👉 因为 JS 在进入 foo 函数时,会先进行词法扫描,发现有 var x,于是:
- 在当前执行上下文的变量环境中为
x提前分配栈空间; - 初始值设为
undefined; - 等到执行到
x = 1时,再把真正的值写进去。
这个过程叫做 “变量提升(Hoisting)”。
✅ 注意:只有
var会被提升并初始化为undefined;let/const虽然也会被提升,但处于“暂时性死区”,访问会报错。
而对于复杂数据类型,比如:
function foo() {
var user = { name: 'Tom', age: 25 };
}
编译阶段会发生什么?
- 为变量
user在栈中分配空间; - 初始值仍为
undefined; - 执行到
user = {...}时:- 引擎在堆中开辟一块空间,存放
{ name: 'Tom', age: 25 }; - 将该对象的地址写入栈中的
user变量。
- 引擎在堆中开辟一块空间,存放
所以最终结果是:栈中 user 存的是地址,堆中存的是真实数据。
⚙️ 执行阶段:调用栈如何工作?
每当一个函数被调用,JS 引擎就会做以下几件事:
1. 创建执行上下文(Execution Context)
每个函数执行前都会创建一个上下文,包含:
- 变量环境(Variable Environment):处理
var - 词法环境(Lexical Environment):处理
let/const - outer 指向:指向外层作用域,形成作用域链
- this 绑定
2. 压入调用栈(Call Stack)
这个上下文会被压入调用栈的顶部。调用栈是一个 LIFO(后进先出)结构,就像一摞盘子。
例如:
function bar() { console.log("bar"); }
function foo() { bar(); }
foo();
调用栈变化如下:
[全局]
→ [全局, foo] ← foo被调用
→ [全局, foo, bar] ← bar被调用
→ [全局, foo] ← bar执行完,弹出
→ [全局] ← foo执行完,弹出
3. 分配内存 & 执行代码
引擎根据函数体内声明的变量,为其在栈中分配相应空间。
比如 foo() 中有三个局部变量:
function foo() {
var a = "极客时间";
var b = a;
var c = {name:'极客时间'};// 栈内存中是地址,堆内存中是对象 引用
var d = c;
}
function bar() {
}
// ...其他函数
foo();
a,b:都是简单类型,在栈中直接存值;
c:栈中存地址,堆中存{name: 极客时间}对象。
假设每个变量占 8 字节,那么整个函数上下文大约占用 32 字节(简化计算)。
当 foo() 执行完毕,引擎会通过移动栈顶指针(stack pointer)向下偏移 32 字节,表示这块空间已被释放。
🔥 关键点:栈的操作非常快,因为它只是指针的移动,不需要遍历或标记。
🛠️ 为什么调用栈必须放在栈内存?三大核心优势!
你可能会问:为啥不把调用栈也放堆里?反正堆空间大啊!
答案是:性能!速度!效率!
✅ 优势一:切换极快 —— 指针偏移即可完成上下文切换
调用栈的核心任务是维护程序执行的状态。每次函数调用和返回都非常频繁,尤其是在递归、事件循环中。
如果使用堆内存:
- 需要动态申请、释放;
- 内存不连续,访问慢;
- GC 可能介入,造成延迟。
而栈内存:
- 连续存储,CPU 缓存友好;
- 栈顶指针(top pointer)只需上下移动,就能完成入栈/出栈;
- 时间复杂度 O(1),几乎是瞬间完成!
👉 想象你在玩俄罗斯方块,新块来了直接“啪”一下叠上去;旧块走了,“唰”一下拿走 —— 干净利落!
✅ 优势二:大小固定,易于管理
栈中的每一个执行上下文,在函数编译完成后就已经知道需要多少内存空间了。
比如上面的 foo() 函数,引擎在编译时就知道它有 4 个变量(a, b, c, obj),总共约 32 字节。
因此可以一次性分配固定大小的空间,无需像堆那样“边用边申请”。
✅ 优势三:不影响整体性能 —— 先回收栈,再清理堆
当一个函数执行结束,它的执行上下文从调用栈中弹出时,发生了什么?
- 栈内存立即释放:栈顶指针直接下移,速度快如闪电⚡;
- 堆内存异步回收:如果该函数创建的对象没有其他引用,GC 后续会在合适时机回收;
- 两者解耦:栈的释放完全不受堆的影响,保证了调用栈的高效运转。
📌 举个生活化的比喻:
栈像是“前台服务员”,负责快速接待客户(函数调用); 堆像是“后台仓库”,东西搬走了还得打扫卫生(GC回收); 但我们不能让服务员等保洁做完才服务下一位客人吧?所以服务员先退场,保洁慢慢来。
这就确保了 JS 主线程不会因为内存回收而卡顿。
🔐 闭包的本质:堆中诞生的“记忆宫殿”
现在我们来看最让人困惑的部分 —— 闭包。
function foo() {
var myName = "极客时间";
function getName() {
console.log(myName);
}
return getName;
}
var bar = foo();
bar(); // 输出 "极克时间"
foo() 已经执行完了,按理说它的局部变量应该被销毁了。但 getName 却还能访问 myName!
秘密在于:JS 引擎检测到内部函数引用了外部变量,于是把这些变量从栈中“救出”,转移到堆中专门创建的一个叫 closure(foo) 的对象里。
具体流程如下:
- 编译
foo时,扫描内部函数getName; - 发现它使用了
myName—— 一个来自外部的自由变量; - 标记这是一个闭包;
- 执行
foo()时:- 正常创建执行上下文,
myName先在栈中; - 但由于闭包存在,引擎会在堆中创建
closure(foo)对象,并把myName拷贝进去; getName函数的 outer 指向这个 closure;
- 正常创建执行上下文,
foo()执行完,其执行上下文从栈中弹出;- 但
closure(foo)依然存在于堆中,只要getName还被引用,就不会被 GC 回收。
📌 所以闭包的核心机制是:
把原本应随函数结束而消失的变量,搬到堆中长期保存,供内部函数持续访问。
这也是为什么闭包容易造成内存泄漏 —— 如果你不主动断开引用(如 bar = null),那堆里的 closure 就一直占着地盘。
♻️ 内存回收:GC 如何清理“垃圾”?
JS 有自动垃圾回收机制(Garbage Collection),主要有两种策略:
1. 引用计数(Reference Counting)
如果一个对象没有任何变量引用它,引用数为 0,就可以被回收。
⚠️ 缺陷:无法处理循环引用(A 引 B,B 引 A)
2. 标记清除(Mark-and-Sweep)—— 主流方式
- 从根对象(如全局对象)出发,标记所有可达对象;
- 清除未被标记的对象。
所以只要还有路径能访问到某个闭包变量,它就不会被回收。
📊 总结对比表:简单 vs 复杂数据类型
| 特性 | 简单数据类型(原始值) | 复杂数据类型(引用值) |
|---|---|---|
| 数据类型 | number, string, boolean 等 | object, array, function |
| 存储位置 | 栈中直接存值 | 栈中存地址,堆中存对象 |
| 赋值方式 | 值拷贝(独立副本) | 引用拷贝(共享同一对象) |
| 内存释放 | 函数出栈即释放 | 无引用后由 GC 异步回收 |
| 是否受闭包影响 | 否(除非被捕获) | 是(常驻堆中) |
🎯 给初学者的建议
- 画图理解:动手画出栈、堆、指针、closure,视觉化帮助极大;
- 多写闭包例子:理解何时变量会被保留;
- 警惕内存泄漏:及时置
null断引用; - 善用开发者工具:Chrome DevTools 的 Memory 面板可以查看堆快照。
🌟 结语:掌握内存,掌控代码
JavaScript 的内存机制,本质上是一场关于 速度与灵活性的平衡艺术:
- 栈追求极致性能,适合频繁切换的执行上下文,靠指针偏移实现毫秒级响应;
- 堆提供无限可能,承载复杂的对象世界,哪怕函数已退出,也能通过闭包延续生命;
- 两者分工明确:栈负责“快进快出”,堆负责“持久存储”,互不干扰,协同工作。
当你写下每一行代码时,请记住:
“我的变量现在在哪?它什么时候会被释放?有没有人还在引用它?”
这些问题的答案,决定了你的代码是流畅运行,还是缓慢卡顿。
🔥 愿你成为那个既能写出优雅逻辑,又能洞察内存脉络的真正前端高手!