深入剖析 JavaScript 内存机制:从栈到堆,揭秘闭包的底层秘密
大家好,今天我们来聊聊 JavaScript 的内存机制。这东西听起来有点抽象,但它却是 JS 运行的核心“发动机”。想象一下,你在写代码时,变量、函数、对象到处飞舞,它们到底是怎么被存储和管理的?如果不懂内存机制,就容易踩坑,比如内存泄漏、闭包迷惑人等。本文将结合实际代码、图解扩展重要知识点一步步带你拆解 JS 的内存世界。
最后再以几个细节知识点收尾,搞透内存机制底层逻辑
JS 是什么样的语言?先从类型说起
JavaScript 是一门动态弱类型语言。啥意思?动态是指变量类型在运行时才确定,不像静态语言(如 C++)那样编译时就固定;弱类型则意味着类型转换超级灵活,比如数字可以轻松转字符串,但这也埋下了隐患。
来看个例子:
var bar; // undefined
console.log(typeof bar); // "undefined"
bar = 12; // number
console.log(typeof bar); // "number"
bar = '极客时间'; // string
console.log(typeof bar); // "string"
bar = true; // boolean
console.log(typeof bar); // "boolean"
bar = null; // null,但 typeof 居然是 "object"!这是 JS 的历史 Bug
console.log(typeof bar); // "object"
bar = {name: '极客时间'}; // object
console.log(typeof bar); // "object"
这里,bar 变量像个“变色龙”,随意变换类型。这体现了 JS 的动态性,但也提醒大家:
易错点——typeof null 返回 "object" 是 JS 设计缺陷,是 JS 第一个版本的 Bug,至今无法修复。因为无数网站依赖了这个错误返回值,改了会崩全网,所以别被骗了!
用 Object.prototype.toString.call(null) 才能准确得 "[object Null]"。
对比静态语言:为什么 C/C++ 要自己 malloc/free,JS 不用
因为 JS 帮你抽象了:
C
// C 语言要手动管理
int main(){
int a = 1; // 栈上
char* b = "极客时间"; // 字符串字面量在只读段
char* c = malloc(1000); // 手动申请堆内存
// 用完必须 free(c)
}
而 JS:
JavaScript
let obj = {name: "极客时间"} // V8 自动分配 + 自动回收
你永远不用关心内存地址,只需要关注引用关系。
这就引出了 JS 的内存空间划分。
扩展一下:JS 有八大数据类型(ECMAScript 标准):Undefined、Null、Boolean、Number、String、Symbol、BigInt(简单类型,存栈)、Object(复杂类型,存堆)。简单类型直接存储值,复杂类型存储引用地址。这就是为什么 JS 高效却灵活。
JS 内存空间的“三足鼎立”
JS 引擎(像 V8)将内存分为三部分:代码空间、栈内存、堆内存。为什么这样分?因为 JS 是单线程的,执行靠调用栈驱动,需要高效管理。
-
代码空间:存放可执行代码。从硬盘加载到内存,准备运行。体积不大,但至关重要。
-
栈内存:小巧高效,存放简单数据类型和执行上下文。为什么小?因为调用栈切换频繁(函数调用/返回),栈空间固定、连续,指针偏移快。想象栈像个“弹夹”,先进后出(LIFO)。
-
堆内存:大块头,存放复杂对象(如数组、对象)。分配/回收耗时,但空间大、不连续,适合动态数据。
为什么简单类型放栈,复杂放堆?栈小(几 MB),如果大对象全塞栈,切换上下文时效率低下。堆大(GB 级),但垃圾回收(GC)复杂。
底层逻辑:JS 执行时,变量提升到执行上下文。简单类型直接赋值栈中;复杂类型在栈存指针(引用),指向堆中实际数据。
来看图1:
这张图生动展示了内存分层。橙色顶层是代码空间,粉色中层栈,蓝色底层堆。简单直观,提醒我们:代码运行从栈开始,堆辅助存储大物件。
值类型 vs 引用类型:赋值与拷贝的陷阱
JS 数据分值类型(简单类型)和引用类型(Object)。赋值行为大不同。
值类型示例:
function foo() {
var a = 1; // 栈中直接存 1
var b = a; // 拷贝值,b 独立存 1
a = 2;
console.log(a); // 2
console.log(b); // 1,仍是原值
}
foo();
这里,a 和 b 独立,修改 a 不影响 b。因为栈中直接拷贝值。
引用类型示例:
function foo() {
var a = {name: '极客时间'}; // 栈存地址,堆存对象
var b = a; // 拷贝地址,b 指向同一堆对象
a.name = '极客邦';
console.log(a); // {name: '极客邦'}
console.log(b); // {name: '极客邦'},同步变了!
}
foo();
易错点提醒:很多人以为 b = a 是深拷贝,其实是浅拷贝!修改 a 影响 b,因为共享堆地址。想深拷贝?用 JSON.parse(JSON.stringify(a)) 或 lodash 的 cloneDeep,但注意 Symbol/BigInt 等不支持。
扩展:Object 的 key 是 string 或 Symbol,value 任意类型。
来看图2:
- 执行上下文是固定大小的块状结构
- 每个块包含相同的组成部分(变量环境、词法环境)
- 上下文在栈中整齐排列
- 指针移动是垂直方向的直接切换
这意味着:
- 每个执行上下文的内存布局是标准化的
- 上下文的边界是清晰明确的
- 从一个上下文切换到另一个是高效的
调用栈与执行上下文:JS 执行的“指挥中心”
调用栈是栈内存的核心,管理执行上下文。每个函数调用推入栈帧(frame),包含变量环境、词法环境、this、外层作用域(outer)。
执行流程:
-
全局上下文入栈。
-
函数调用,新上下文入栈(栈顶执行)。
-
返回,弹出栈帧。
function foo() {
var a = '极客时间'; // 值类型,栈存
var b = a; // 拷贝
var c = {name: '极客时间'}; // 引用,栈存地址
var d = c; // 共享地址
}
function bar() {}
foo();
扩展知识:执行上下文分变量环境(var 声明,提升 undefined)和词法环境(let/const,不提升)。this 指向取决于调用方式(全局 window,对象方法 this=对象)。
堆内存与垃圾回收:大物件的“仓库”
堆存放对象,动态分配。V8 用新生代(Scavenge 算法,复制存活对象)和老生代(标记-清除,标记-整理)GC。
左侧调用栈,右侧堆。箭头显示栈变量指向堆地址。注意,堆中对象有地址如 1003,存实际数据。
闭包:内存机制的“杀手级”应用
闭包是函数 + 访问外部变量的环境。为什么需要内存理解?因为闭包变量存堆,不随栈弹出销毁。
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 执行,创建执行上下文。扫描内部函数,检测 myName/test1 被引用 → 创建 Closure(foo) 对象在堆中。
-
innerBar 返回,foo 栈帧弹出,但闭包变量 persist 在堆。
-
bar.getName() 访问堆中 Closure。
这张图展示了闭包对象在堆,栈变量指向它。即使 foo 结束,堆数据不回收。
底层逻辑:闭包核心——词法作用域链(outer)。V8 在堆创 [[Scopes]] 属性存闭包变量。好处:状态持久(如计数器);坏处:内存泄漏!如果 bar 不释放,堆变量永存。
易错点:闭包不是函数返回函数那么简单,而是“引用外部变量”。提醒:用完闭包设 null 释放引用
几个细节知识点
1.执行上下文的切换是如何实现的?
「JS 的执行上下文切换,本质就是调用栈栈顶指针(rsp)的快速偏移。
在早期 JavaScript 引擎中(2009–2015 阶段),函数的“编译/创建阶段”会进行变量提升:所有 var 声明的变量会被提前注册到当前执行上下文的变量环境中,而这个变量环境在物理内存层面就是直接分配在调用栈的当前栈帧之上的。
此时,引擎会在栈帧中为每一个 var 变量预分配一个固定槽位(slot),并将它们统一初始化为 undefined。
到了真正的“执行阶段”进行赋值时,基本数据类型(number、boolean、string、undefined、null)的值会直接写入栈上对应的那个槽位——也就是说,它们确实是真实地存储在栈中的。
正是因为 JavaScript 是动态弱类型语言,变量本身不绑定固定类型,类型完全由当前持有的值决定,所以早期引擎完全可以采用“栈上直接存值”的最简单实现。
无论 JS 引擎处于哪个历史时期,每个函数调用产生的栈帧大小在进入该函数时必然是固定的。 唯一的区别在于:
- 早期实现中,这个固定大小必须把所有 var 声明的基本类型槽位(8 字节/个)和引用类型指针都算进去;
- 现代实现中,这个固定大小彻底与变量数量脱钩永远是固定的极小值,所有变量(无论基本类型还是引用类型)都统一扔到堆上的 Context 对象里,栈帧只保留一个 8 字节的 Context 指针 + 少数固定的控制信息。”
当函数 return 时,引擎只做两件事:
- 一条 add rsp, 固定值(比如 add rsp, 48)——瞬间把栈顶指针跳回调用者的栈帧顶部
- 一条 ret ——跳回调用者继续执行
这两条指令就把当前执行上下文彻底从调用栈中“弹出”,并让栈顶指针精确指向父执行上下文的栈帧,完成切换。
2. 扫描内部函数,怎么决定的要不要创建闭包?
V8 会在编译阶段(Ignition 编译字节码时)做两次关键扫描:
-
第一次:语法扫描 + 早期错误检测(SyntaxError、TDZ 相关的 ReferenceError 等) → 这一步如果出错,直接抛错,根本不生成字节码,更不会执行。
-
第二次:作用域分析(Scope Analysis) —— 这才是真正决定“要不要创建闭包”的关键一步 → 这一步不光看语法错误,更重要的是做 变量引用分析:
- 哪些变量被内部函数引用了?
- 这些变量是 var / let / const?
- 是否可能被闭包捕获?
真正决定创建闭包的不是语法检查,而是后面的作用域分析阶段。
3. 闭包真正的形成时间
时间线是这样的(超级重要!):
| 阶段 | 是否创建闭包对象(Closure / Context) | 说明 |
|---|---|---|
| 编译阶段(Scope Analysis) | 不创建!只做标记 | 只标记“这个变量可能被闭包捕获”,生成字节码时准备好“捕获列表” |
| 函数第一次被调用(进入函数) | 才真正创建堆上的 Context 对象 | 此时才分配内存,把被捕获的变量拷贝/引用进去 |
| 后续调用(如果已经热) | 可能复用同一个 Context(共享) | V8 会做 Context 共享优化 |
举个真实例子:
JavaScript
function foo() {
let a = 1;
function inner() { console.log(a); } // ← 编译时只标记 a 可能被捕获
return inner;
}
- 编译 foo 时:只标记 a 需要捕获,生成字节码
- 第一次调用 foo() 时:才在堆上创建 Context,a = 1 写进去
- 第二次调用 foo() 时:可能创建一个新 Context,也可能复用(取决于优化)
所以:闭包对象不是编译时创建的,而是在外层函数第一次执行时才真正分配堆内存。
小知识点:“栈中存地址指向堆内存,从而节省栈空间”。 现代 V8 就是靠这个,才敢让闭包变量全跑堆上,栈帧保持极小。
3. 闭包与outer
- 每个函数对象(inner)有一个内部属性 outer,在函数创建时就永久指向定义它时的词法环境(outer lexical environment)
- 这个 outer 在函数定义那一刻就绑定死了,一辈子不会变!
- 当父函数执行完,父执行上下文的 Context 对象本该被销毁,但因为 inner 的 outer 还指着它 → 垃圾回收机制 发现有引用 → 不回收 → 形成“闭包”
inner 函数对象的 outer 指针牢牢抓住父 Context → 父 Context 逃逸到堆上 → 形成持久闭包状态
V8 实际实现中叫 [[Scopes]] 数组:
JavaScript
inner.[[Scopes]] = [
0: Closure Context (包含 a = 1) ← 被 inner 抓住
1: Script Context
2: Global Context
]
父函数 foo 结束后,它的栈帧没了,但 Closure Context 还被 inner 的 [[Scopes]][0] 引用着,所以活在堆上。
一句话:因为包含自由变量的完整父级执行上下文已经被回收了,所以 inner 的 outer 最终指向的是一个单独创建的、只装着被捕获自由变量的闭包对象(Closure Context)
总结
JS 内存机制像一台精密机器:栈管执行,堆存数据,闭包桥接两者。掌握它,能写出高效代码,避免泄漏。