前言
兄弟们,有没有在面试时被问到“闭包”支支吾吾说不清楚?有没有写代码时遇到过“改了这个变量,怎么那个变量也变了”的灵异事件?
其实,这一切的幕后黑手就是 JavaScript 的内存机制。
很多前端同学觉得:“JS 不需要像 C++ 那样手动 malloc 和 free,内存这种底层东西,管它干嘛?”
错!大错特错!不懂内存,你永远只是 API 的搬运工;懂了内存,你才是操纵数据的“上帝”。今天,咱们就扒开 V8 引擎的外衣,看看它肚子里到底卖的什么药。
一、JavaScript的内存大厦:栈与堆
我们可以将JavaScript运行时所使用的内存比作一座功能完备的大厦,它主要由几个核心功能区构成。
根据下图,我们可以看到JavaScript运行时最核心的三个内存空间:
• 代码空间 (Code Space): 顾名思义,这里存放着我们编写的所有JavaScript代码。
• 栈空间 (Stack Space): 这是执行代码的“主舞台”,负责管理函数的调用顺序和存储原始数据类型。它是本文的重点。
• 堆空间 (Heap Space): 这是一个灵活的“大仓库”,用于存放所有复杂数据类型(如对象)。
为了更好地理解这两个核心区域,我们可以通过一个表格来清晰地对比它们:
| 栈内存 (Stack) | 堆内存 (Heap) | |
|---|---|---|
| 存储内容 | 原始数据类型(如 Number, String, Boolean 等) | 复杂数据类型(如 Object) |
| 核心优势 | 速度快,管理高效,空间连续 | 空间大,存储灵活,可存放大小不一的数据 |
| 特性 | 大小固定,后进先出(LIFO),由系统自动分配和释放 | 大小不固定,动态分配,分配和回收较耗时 |
关键洞察: 这种设计是为了极致的效率:用快速的栈来管理函数的执行流程,用灵活的堆来存储大小不一的复杂数据,各司其职。
快速的栈:
“栈是快速的”这一说法,源于其内存布局、分配方式和硬件支持的多重优势。在 JavaScript(以及几乎所有编程语言)中,栈之所以被用于管理函数调用和局部变量,正是因为它的速度极快、开销极低。下面我们从多个维度解释为什么栈如此高效:
✅ 1. 连续内存 + 固定增长方向
- 栈是一块预先分配好的、连续的内存区域(通常由操作系统为线程分配)。
- 所有操作(压栈/弹栈)都发生在同一端(栈顶) ,向一个方向(通常是低地址)增长。
- 这种局部性 极好,CPU 缓存命中率高。
对比堆:堆是分散的、碎片化的,分配时需搜索空闲块,效率低。
✅ 2. 分配/释放 = 仅移动指针(O(1))
- 在栈上“分配”内存:只需将栈顶指针(SP)向下偏移 N 字节。
- “释放”内存:只需将 SP 向上回移 N 字节。
- 不需要调用内存管理器、不需要记录元数据、不需要遍历链表。
分配前: SP = 0x1000
分配 16 字节后: SP = 0x1000 - 16 = 0x0FF0 ← 一次减法!
释放时: SP = 0x0FF0 + 16 = 0x1000 ← 一次加法!
⚡ 时间复杂度:O(1) ,且是单条 CPU 指令。
📌 对比堆:
malloc/free或 JS 的new Object()需要:
- 查找合适大小的空闲块
- 更新内存管理结构(如空闲链表)
- 可能触发垃圾回收(GC)
- 开销大得多。
内存地址与栈的增长方向
- 内存地址的方向:通常,内存地址是从低到高排列的(即从0x0000向0xFFFF增长)。然而,栈的生长方向通常是相反的,即从高地址向低地址生长。
- 栈顶指针(SP, Stack Pointer) :栈顶指针指向栈中的最新添加的元素的位置。当有新的元素加入栈时,如果栈是从高地址向低地址增长的,则栈顶指针需要减小(即向更低的地址移动),以指向新添加的元素位置。
这是很多初学者对栈顶指针都会存在的一个误区!
示例说明
假设我们有一个初始的栈顶指针指向地址0x7FF0,当一个新的元素被压入栈时:
- 栈顶指针首先减去所需的空间大小(比如4个字节),指向新的位置
0x7FEC。 - 新元素被放置在地址
0x7FEC处。 - 现在,栈顶指针
SP指向0x7FEC,这就是最新的栈顶位置。
虽然从逻辑上看,新元素成为了栈顶,但是由于栈的生长方向是从高地址向低地址,所以栈顶指针实际上是向更低的地址值移动了。这也就是为什么我们会说栈顶指针“向下”移动的原因。
✅ 3. 硬件级优化支持
- CPU 专门设有 栈指针寄存器(Stack Pointer Register) ,如 x86 的
RSP。 push/pop是原生汇编指令,执行速度极快。- 函数调用(
call)和返回(ret)指令直接操作栈,与调用栈模型天然契合。
💡 JS 引擎(如 V8)虽用 C++ 编写,但底层仍利用这些硬件特性来模拟或加速执行上下文管理。
✅ 4. 自动生命周期管理
- 栈上数据的生命周期 = 函数作用域。
- 函数返回 → 整个栈帧“逻辑消失”,无需手动释放,也不会内存泄漏。
- 引擎无需跟踪每个变量,极大简化内存管理。
📌 对比堆:堆对象需依赖垃圾回收器(GC) 判断是否可达,带来不确定的暂停(stop-the-world)和性能开销。
✅ 5. 适合存储小而确定的数据
- 原始类型(
number,boolean,string(小字符串可能内联))、指针、函数参数等大小固定、生命周期短的数据,非常适合放栈上。 - JS 中虽然对象总在堆上,但对对象的引用(指针) 存在栈上,依然享受栈的速度。
❗ 注意:JS 的“栈”是逻辑模型
- JavaScript 引擎(如 V8)不一定直接使用系统栈来存储所有执行上下文(尤其在优化或异步场景中可能在堆上模拟栈)。
- 但逻辑行为完全一致:LIFO、快速 push/pop、自动清理。
- 所以我们说“栈快”,是指这种模型带来的语义效率,即使底层实现略有不同。
✅ 总结:为什么栈快?
| 原因 | 说明 |
|---|---|
| 指针操作 | 分配/释放 = 移动栈顶指针(加/减),O(1) |
| 连续内存 | 高缓存命中率,无碎片 |
| 硬件支持 | CPU 有专用寄存器和指令 |
| 自动管理 | 无需 GC,无内存泄漏风险 |
| 作用域匹配 | 完美契合函数调用的生命周期 |
🚀 正因如此,调用栈成为 JS(及几乎所有语言)实现函数调用和局部变量管理的首选机制。
JavaScript 引擎(例如 V8)采用栈内存和堆内存(栈空间和堆空间)分工协作的机制来高效管理内存和程序执行流程,充分利用了它们各自的优势。
二、JS 的“人格分裂”:动态弱类型
在深入内存之前,我们得先认清 JS 的“人设”。
// JS是动态弱类型语言
var bar
console.log(typeof bar) // undefined
bar = 12
console.log(typeof bar) // number
bar = '极客时间'
console.log(typeof bar) // string
bar = true
console.log(typeof bar) // boolean
JS 是个“渣男”(动态弱类型):
- 静态类型:变量的类型在代码运行前(编译时)就已确定并检查。
- 动态类型:变量的类型在程序运行时才根据所赋的值动态确定。
- 强类型:语言不允许或极少进行隐式的、不安全的类型转换,强调类型安全。
- 弱类型:语言会自动进行大量隐式类型转换,即使逻辑上可能不合理。
JS的弱类型体现在其在初始化定义的时候不需要指定其类型,动态型体现在其赋值后可以直接更改其变量类型
var a //undefined
var obj // undefined
var arr //undefined
只有在赋值的时候才会确定其具体类型
a=1 //number
obj={name:'张三'} // object
arr=[1,2,3,4] // object 在JS中,所有复杂数据类型都是对象
冷知识插播:
bar = null
console.log(typeof bar) // object
这是 JS 诞生之初的一个 Bug,因为在底层二进制中,对象的前三位是 000,而 null 全是零,于是被误判为了 object。这个 Bug 年代久远,为了兼容性就一直保留至今了。
但是可以通过Object原型对象上的toString方法得到其准确值
bar = null
Object.prototype.toString.call(null) // [object Null]
三、JS执行流程:调用栈与执行上下文
JS的执行流程主要为以下六步:
- 代码解析:将源码解析成抽象语法树(AST)。
- 变量与函数提升:
var和函数声明被提升到作用域顶部(let/const有暂时性死区)。 - 创建执行上下文:包括全局上下文和后续的函数上下文,确定变量、作用域链和
this。 - 压入调用栈执行:同步代码按调用顺序在调用栈中执行。
- 处理异步任务:通过事件循环,先清空微任务队列(如 Promise),再处理宏任务(如 setTimeout)。
- 垃圾回收:自动回收不再使用的内存。
执行上下文:
我们提到的“执行上下文”,是函数执行时的内部环境。它是一个复杂的内部数据结构,包含了如变量环境 (Variable Environment) 、词法环境 (Lexical Environment) 等多个重要组件,用于存储函数内部定义的变量、函数声明等信息。
每个执行上下文(Execution Context)包含以下三个核心组件:
1. 词法环境
-
用于解析标识符(变量、函数等)的引用。
-
结构包含:
- Environment Record(环境记录) :存储变量、函数绑定(如
let、const、函数参数等)。 - Outer(外部词法环境指针) :指向外层作用域的词法环境(形成作用域链)。
- Environment Record(环境记录) :存储变量、函数绑定(如
⚠️ 注意:
let/const声明的变量会放入词法环境的环境记录中,并受暂时性死区(TDZ) 限制。
2. 变量环境
- 与词法环境类似,也是一个包含 Environment Record + Outer 的结构。
- 关键区别:它专门用于保存由
var声明的变量和函数声明(它们会被“提升”并初始化为undefined或函数体)。 - 在执行上下文创建时,VariableEnvironment 初始值 = LexicalEnvironment,但在执行过程中可能因
var声明而产生差异。
💡 实际上,在大多数情况下(尤其是没有
eval或with时),两者内容一致;但规范为了区分var提升行为而保留两个字段。
3.this 绑定
-
this的具体值不是在函数定义时确定的,而是在执行上下文(进入阶段) -
在全局执行上下文中:
- 非严格模式:
this指向全局对象(浏览器中是window,Node.js 中是global)。 - 严格模式:
this为undefined。
- 非严格模式:
-
在函数执行上下文中,
this的值由函数的调用方式决定,例如:fn()→ 默认绑定(非严格模式下为全局对象,严格模式下为undefined)obj.fn()→ 隐式绑定,this指向objfn.call(obj)/fn.apply(obj)→ 显式绑定,this指向传入的对象new Fn()→this指向新创建的实例对象
-
箭头函数没有自己的
this,它会词法继承外层作用域的this值(即在创建时就确定,不随调用方式改变)。
💡 关键点:
this是动态绑定的(除箭头函数外),其值取决于调用时的上下文,而非定义位置。
调用栈 (Call Stack)
是一个遵循“后进先出”(LIFO, Last-In, First-Out)原则的数据结构。它的核心作用是追踪和管理函数的调用顺序。每当一个函数被调用,一个为它量身定制的“执行环境”就会被创建并推入栈顶;当函数执行结束,这个环境就会被弹出。
让我们结合下图,看看foo函数从被调用到执行结束的完整生命周期:
1. 函数调用:当代码执行到调用foo函数时,JavaScript引擎会为它创建一个“foo函数执行上下文”。这个上下文包含了函数执行所需的所有信息,并被推入调用栈的顶部,成为“当前执行上下文”。
2. 函数结束:当foo函数内部的代码全部执行完毕后,它的执行上下文就会从调用栈中弹出并被标记为等待回收。程序的控制权交还给了栈中的下一个上下文(在这里是全局执行上下文),同时,“当前执行上下文”指针也移回,指向全局执行上下文。
我们已经看到执行上下文是如何进出栈的,但它内部的“变量环境”究竟是如何存储我们定义的变量的呢?让我们一探究竟。
四、变量的安身之处:原始类型与引用类型
JavaScript共有八种数据类型,它们可以被分为两大类:原始数据类型和复杂数据类型。它们的根本区别在于存储位置:
• 原始数据类型:其值直接存储在栈内存中。
• 复杂数据类型:其数据本身存储在堆内存中,而在栈内存中仅存储一个指向堆内存地址的“门牌号”。
function foo() {
var a = "极客时间";
var b = a;
var c = { name: "极客时间" }; // 栈内存中是地址 堆内存中才是对象 引用
var d = c;
}
foo();
下图精确展示了foo函数执行上下文内部的“变量环境”,其内容如下表所示:
在这个例子中,像变量a和b存储的字符串"极客时间"就属于原始数据类型。它们的值被完整地、直接地存储在位于栈内存的变量环境中。
现在,情况变得有趣起来。如果我们将一个对象(复杂数据类型)赋值给变量c
var c = { name: "极客时间" }
内存状态会发生显著变化,如下图所示:
请注意这里的关键变化:
1. 一个新的对象{name: "极客时间"}在堆内存中被创建,并被分配了一个地址(例如1003)。
2. 在栈内存的foo函数执行上下文中,变量c的值不再是具体的数据,而仅仅是那个地址——1003。
这就像一个快捷方式:栈上的变量c告诉JavaScript引擎:“别在这里找数据,去堆内存的1003房间去找真正的数据!”
理解了栈和堆的分工后,我们终于可以来破解JavaScript中最迷人也最令人困惑的概念之一:闭包。它正是利用了堆内存的特性才得以实现。
五、闭包的魔法:永不消逝的堆内存
先看一段代码:
function foo() {
var myName = "极客时间";
let test1 = 1;
const test2 = 2;
var innerBar = {
setName: function (newName) {
myName = newName;
},
getName: function () {
console.log(test1);
return myName;
},
};
return innerBar;
}
var bar = foo();
bar.setName("极客邦");
bar.getName();
console.log(bar.getName());
我们先来思考一个问题:通常情况下,当foo函数执行完,它在调用栈中的执行上下文就应被销毁,里面的一切变量也随之消失。那么,如果foo内部定义的另一个函数,在foo执行完毕后才被调用,它又是如何访问到本该消失的foo内部变量的呢?
这便是闭包的“魔法”所在。其背后的秘密机制,是引擎一项精准而高效的优化,分为两步:
1. 编译时预判:在代码执行前的编译阶段,JavaScript引擎会进行一次快速的词法扫描。当它发现一个内部函数引用了其外部函数的变量时,引擎就会判定:“这里存在一个闭包!”
2. 选择性数据迁徙:这是闭包机制的核心。引擎并不会把外部函数(foo)的所有变量都固化。它会进行精确判断:只有那些被内部函数引用的变量,才会被“迁移”到堆内存中。其他未被引用的变量,则仍然留在栈内存的变量环境中,随着函数的执行结束而被正常销毁。
下图完美地展示了闭包形成后的内存状态:
请仔细观察这张图,它揭示了所有秘密:
• 选择性迁移:在foo函数中,变量myName和test1因为被内部函数引用,所以它们被打包放入了堆地址1003的一个特殊对象中。而变量test2因为没有被任何内部函数引用,所以它被留在了foo函数执行上下文的栈内存里,最终会随着foo的结束而被回收。
• 建立连接:为了让内部函数能找到这些迁移到堆上的变量,引擎在foo的执行上下文中创建了一个名为closure(foo)的内部数据结构。在调用栈中,我们看到一个特殊的内部引用(在图中被标记为clourse(foo)),它就像一个指针,指向了堆内存地址1003。这个closure(foo)对象就像一个“背包”,装着所有幸存下来的变量。
这意味着,即使foo函数的执行上下文早已从调用栈中弹出,但由于闭包的存在,它内部被引用的变量被安全地“备份”到了堆内存中。只要有代码(例如返回的内部函数)持有对这个“背包”的引用,这些数据就能得以“幸存”,从而实现了跨作用域的访问。
闭包的本质,就是让本该随函数调用结束而被销毁的栈内存中的信息,通过被内部函数引用的方式,被‘备份’到了永恒的堆内存中,从而延长了其生命周期。
五、 总结与面试必杀技
通过这次内存世界的探秘之旅,我们一步步揭开了JavaScript执行流程和闭包的底层原理。让我们快速回顾一下本次学习的核心收获:
1. 内存分工:JavaScript通过其内存模型平衡了速度与灵活性:使用可预测、高效率的栈来管理执行流程,同时使用动态、灵活的堆来存储复杂数据。
2. 执行流程:代码的执行是通过调用栈对一个个“执行上下文”的入栈和出栈来管理的,这是一个严谨的“后进先出”的过程。
3. 闭包原理:闭包是JavaScript内存模型的直接产物。它利用堆内存为那些本应随栈帧一同销毁的变量赋予了更长的生命周期,从而实现了数据封装和状态保持等强大的编程模式。
知识点大合影
| 特性 | 栈空间 (Stack) | 堆空间 (Heap) |
|---|---|---|
| 存储内容 | 基本数据类型、引用类型的地址 | 引用数据类型(对象、数组等) |
| 空间大小 | 小、连续 | 大、不连续 |
| 分配速度 | 极快 | 较慢 |
| 回收机制 | 系统自动回收(出栈即焚) | 垃圾回收器 (GC) 标记清除/引用计数 |
| 主要角色 | 执行上下文、程序运行状态 | 存储数据 |
面试官如果问...
Q1: const 定义的对象属性可以改吗?为什么?
A: 可以!const 锁住的是栈内存里的那个值。
- 对于基本类型,值就在栈里,所以不能改。
- 对于对象,栈里存的是地址。const 保证地址不许变(不能让它指向另一个对象),但它不管地址对应的堆内存里存了啥。你把房子拆了重装修都行,只要门牌号没变。
Q2: 闭包会不会造成内存泄漏?
A: 会,但也没那么可怕。
- 只要外部一直持有闭包返回的函数引用(比如上面的 var bar = foo()),堆里的 Closure(foo) 就无法被垃圾回收(GC)。
- 解决办法:用完之后,手动 bar = null。断开引用链,GC 就能把那块堆内存扫地出门了。
Q3: V8 为什么不把所有变量都存在栈里?
A: 还是那个词——切换上下文。栈必须保持极高的效率来维护函数调用链。如果塞进大对象,栈指针移动变慢,整个 JS 运行效率就会崩盘。
结语
JavaScript 的内存世界并不神秘,无非就是“小钱放口袋(栈),大钱存银行(堆),重要信物(闭包)专门存保险柜”。
理解了这些,下次写代码时,脑海里就能浮现出数据在内存中流动的画面。这不仅仅是八股文,更是写出高性能、无 Bug 代码的内功心法!