🚀 JS内存机制深度揭秘:从变量声明到函数执行,彻底搞懂栈与堆的“爱恨情仇”!

76 阅读10分钟

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 的内存想象成一栋三层小楼:

f2686b5e027b412782cc1c4a076233d8.png

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();

这里的 ab 都是数字,直接在栈中保存的是实际的数值。当 b = a 时,是值拷贝,相当于复制了一份独立的数据。所以后续修改 a 不会影响 b

3️⃣ 堆内存(Heap Memory)—— 大平层仓库区

  • 特点:大、灵活、非连续、分配慢
  • 存放内容:复杂数据类型的实际对象

包括:

  • 对象 {name: '极客时间'}
  • 数组 [1, 2, 3]
  • 函数(作为对象存在)
  • 闭包捕获的外部变量环境

重点来了👇

当你写:

var obj = { name: '极客时间' };

JS 引擎做了两件事:

  1. 堆内存中创建这个对象,存放它的属性和值;
  2. 栈内存中为 obj 分配空间,存的是指向堆中对象的地址(指针)

也就是说,obj 本身在栈里,但它存的不是 {name: '极客时间'},而是类似 "0x1A3F" 这样的内存地址。

再看这个经典例子:

var a = { name: '极客时间' };
var b = a;
a.name = '极客邦';
console.log(b.name); // 极客邦 😯

为什么 b 也变了?因为 ab 指向的是同一个堆内存地址!它们是“同一份数据的不同名字”。

这就是所谓的引用赋值 —— 改一处,处处生效。


🔍 编译阶段:变量提升与内存预分配

JS 并不是一边读一边执行的。它有一个编译阶段(其实叫“解析阶段”更准确),发生在代码真正执行前的一瞬间。

以这段代码为例:

function foo() {
    console.log(x);      // undefined
    var x = 1;
    console.log(x);      // 1
}
foo();

虽然 x 是在后面才赋值的,但第一行却能打印出 undefined 而不是报错。这是为什么?

👉 因为 JS 在进入 foo 函数时,会先进行词法扫描,发现有 var x,于是:

  1. 在当前执行上下文的变量环境中为 x 提前分配栈空间;
  2. 初始值设为 undefined
  3. 等到执行到 x = 1 时,再把真正的值写进去。

这个过程叫做 “变量提升(Hoisting)”

✅ 注意:只有 var 会被提升并初始化为 undefinedlet/const 虽然也会被提升,但处于“暂时性死区”,访问会报错。

而对于复杂数据类型,比如:

function foo() {
    var user = { name: 'Tom', age: 25 };
}

编译阶段会发生什么?

  1. 为变量 user 在栈中分配空间;
  2. 初始值仍为 undefined
  3. 执行到 user = {...} 时:
    • 引擎在堆中开辟一块空间,存放 { name: 'Tom', age: 25 }
    • 将该对象的地址写入栈中的 user 变量。

所以最终结果是:栈中 user 存的是地址,堆中存的是真实数据


⚙️ 执行阶段:调用栈如何工作?

07562feafe3a41b2a0ceeca46f01487f.png

每当一个函数被调用,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:都是简单类型,在栈中直接存值;

c42923ba00ff4659b1e5bc120713f14b.png

  • c:栈中存地址,堆中存 {name: 极客时间} 对象。

d4719ff1d25640a0b7dcf97c65b65c8a.png

假设每个变量占 8 字节,那么整个函数上下文大约占用 32 字节(简化计算)。
foo() 执行完毕,引擎会通过移动栈顶指针(stack pointer)向下偏移 32 字节,表示这块空间已被释放。

🔥 关键点:栈的操作非常快,因为它只是指针的移动,不需要遍历或标记。


🛠️ 为什么调用栈必须放在栈内存?三大核心优势!

你可能会问:为啥不把调用栈也放堆里?反正堆空间大啊!

答案是:性能!速度!效率!

✅ 优势一:切换极快 —— 指针偏移即可完成上下文切换

调用栈的核心任务是维护程序执行的状态。每次函数调用和返回都非常频繁,尤其是在递归、事件循环中。

如果使用堆内存:

  • 需要动态申请、释放;
  • 内存不连续,访问慢;
  • GC 可能介入,造成延迟。

而栈内存:

  • 连续存储,CPU 缓存友好;
  • 栈顶指针(top pointer)只需上下移动,就能完成入栈/出栈;
  • 时间复杂度 O(1),几乎是瞬间完成!

👉 想象你在玩俄罗斯方块,新块来了直接“啪”一下叠上去;旧块走了,“唰”一下拿走 —— 干净利落!

✅ 优势二:大小固定,易于管理

栈中的每一个执行上下文,在函数编译完成后就已经知道需要多少内存空间了。

比如上面的 foo() 函数,引擎在编译时就知道它有 4 个变量(a, b, c, obj),总共约 32 字节。

因此可以一次性分配固定大小的空间,无需像堆那样“边用边申请”。

✅ 优势三:不影响整体性能 —— 先回收栈,再清理堆

当一个函数执行结束,它的执行上下文从调用栈中弹出时,发生了什么?

  1. 栈内存立即释放:栈顶指针直接下移,速度快如闪电⚡;
  2. 堆内存异步回收:如果该函数创建的对象没有其他引用,GC 后续会在合适时机回收;
  3. 两者解耦:栈的释放完全不受堆的影响,保证了调用栈的高效运转。

📌 举个生活化的比喻:

栈像是“前台服务员”,负责快速接待客户(函数调用); 堆像是“后台仓库”,东西搬走了还得打扫卫生(GC回收); 但我们不能让服务员等保洁做完才服务下一位客人吧?所以服务员先退场,保洁慢慢来。

这就确保了 JS 主线程不会因为内存回收而卡顿。


🔐 闭包的本质:堆中诞生的“记忆宫殿”

现在我们来看最让人困惑的部分 —— 闭包

function foo() {
    var myName = "极客时间";
    function getName() {
        console.log(myName);
    }
    return getName;
}
var bar = foo();
bar(); // 输出 "极克时间"

foo() 已经执行完了,按理说它的局部变量应该被销毁了。但 getName 却还能访问 myName

秘密在于:JS 引擎检测到内部函数引用了外部变量,于是把这些变量从栈中“救出”,转移到堆中专门创建的一个叫 closure(foo) 的对象里

具体流程如下:

  1. 编译 foo 时,扫描内部函数 getName
  2. 发现它使用了 myName —— 一个来自外部的自由变量;
  3. 标记这是一个闭包;
  4. 执行 foo() 时:
    • 正常创建执行上下文,myName 先在栈中;
    • 但由于闭包存在,引擎会在堆中创建 closure(foo) 对象,并把 myName 拷贝进去;
    • getName 函数的 outer 指向这个 closure;
  5. foo() 执行完,其执行上下文从栈中弹出;
  6. closure(foo) 依然存在于堆中,只要 getName 还被引用,就不会被 GC 回收。

📌 所以闭包的核心机制是:

把原本应随函数结束而消失的变量,搬到堆中长期保存,供内部函数持续访问。

这也是为什么闭包容易造成内存泄漏 —— 如果你不主动断开引用(如 bar = null),那堆里的 closure 就一直占着地盘。

5e61411546724844866b542a202add8f.png

♻️ 内存回收:GC 如何清理“垃圾”?

JS 有自动垃圾回收机制(Garbage Collection),主要有两种策略:

1. 引用计数(Reference Counting)

如果一个对象没有任何变量引用它,引用数为 0,就可以被回收。

⚠️ 缺陷:无法处理循环引用(A 引 B,B 引 A)

2. 标记清除(Mark-and-Sweep)—— 主流方式

  • 从根对象(如全局对象)出发,标记所有可达对象;
  • 清除未被标记的对象。

所以只要还有路径能访问到某个闭包变量,它就不会被回收。


📊 总结对比表:简单 vs 复杂数据类型

特性简单数据类型(原始值)复杂数据类型(引用值)
数据类型number, string, boolean 等object, array, function
存储位置栈中直接存值栈中存地址,堆中存对象
赋值方式值拷贝(独立副本)引用拷贝(共享同一对象)
内存释放函数出栈即释放无引用后由 GC 异步回收
是否受闭包影响否(除非被捕获)是(常驻堆中)

🎯 给初学者的建议

  1. 画图理解:动手画出栈、堆、指针、closure,视觉化帮助极大;
  2. 多写闭包例子:理解何时变量会被保留;
  3. 警惕内存泄漏:及时置 null 断引用;
  4. 善用开发者工具:Chrome DevTools 的 Memory 面板可以查看堆快照。

🌟 结语:掌握内存,掌控代码

JavaScript 的内存机制,本质上是一场关于 速度与灵活性的平衡艺术

  • 栈追求极致性能,适合频繁切换的执行上下文,靠指针偏移实现毫秒级响应;
  • 堆提供无限可能,承载复杂的对象世界,哪怕函数已退出,也能通过闭包延续生命;
  • 两者分工明确:栈负责“快进快出”,堆负责“持久存储”,互不干扰,协同工作。

当你写下每一行代码时,请记住:

“我的变量现在在哪?它什么时候会被释放?有没有人还在引用它?”

这些问题的答案,决定了你的代码是流畅运行,还是缓慢卡顿。

🔥 愿你成为那个既能写出优雅逻辑,又能洞察内存脉络的真正前端高手!