JavaScript 内存机制完全解析(含 V8 底层实现)

72 阅读11分钟

JavaScript 内存机制完全解析(含 V8 底层实现)​

​​​

image.png

引言​

JavaScript 的内存机制是理解其执行效率、作用域、闭包等核心特性的基础。本文从 V8 引擎编译执行流程切入,系统拆解内存空间布局、数据存储规则、执行上下文管理及闭包原理,结合底层实现细节,构建完整的 JS 内存知识体系。​

一、V8 引擎编译执行流程:从源码到机器码的完整链路​

V8 引擎采用「即时编译(JIT)」的混合执行模式,将 JS 源码转换为机器码需经历 5 个核心阶段,每个阶段均与内存机制深度绑定:​

​​

image.png

  1. 阶段 1:词法分析 —— 代码→Token 流​
  • 核心作用:将字符串形式的 JS 代码拆分为最小语法单元(Token),过滤空格、注释等无关字符​
  • Token 类型:标识符(变量 / 函数名)、关键字(var/let)、运算符、字面量(数值 / 字符串 / 对象)​
  • 内存关联:Token 流临时存储在「代码空间」的连续缓冲区,便于后续阶段快速读取​
  1. 阶段 2:语法分析 ——Token 流→抽象语法树(AST)​
  • 核心作用:验证语法合法性,将 Token 流转换为结构化的 AST(代码逻辑的树形表示)​
  • 核心操作:​
  • 基于 JS 语法规则构建树形节点(如变量声明、表达式、函数定义)​
  • 语法错误时直接抛出异常,终止编译​
  • 内存关联:AST 存储在「堆内存」(树形结构大小不固定,需动态分配),每个节点包含类型、子节点引用、代码位置信息​
  • 示例 AST 结构:​

VariableDeclaration(变量声明节点)​

├─ kind: "var"​

├─ declarations: [VariableDeclarator]​

│ ├─ id: Identifier(a)(变量名节点)​

│ └─ init: BinaryExpression(二进制表达式节点)​

│ ├─ operator: "+"​

│ ├─ left: Literal(1)(数值字面量节点)​

│ └─ right: Literal(2)(数值字面量节点)​

└─ range: [0, 10](代码位置)​

  1. 阶段 3:预编译 ——AST→执行上下文初始化​
  • 核心作用:分析 AST 中的变量 / 函数声明,创建执行上下文,完成变量提升和作用域绑定​
  • 核心操作(与执行上下文强关联):​
  1. 遍历 AST 的变量声明节点(var/let/const)和函数声明节点​
  1. 初始化「变量环境(VO)」:​
  • 函数声明:直接写入堆内存中函数对象的引用地址​
  • var变量:初始化为undefined(变量提升特性)​
  1. 初始化「词法环境(LE)」:​
  • let/const变量:标记为「未初始化」(暂时性死区 TDZ 状态)​
  1. 建立「outer 链」:根据 AST 嵌套结构绑定外层作用域引用​
  1. 绑定this:根据执行场景(全局 / 函数调用 / 构造函数)确定指向​
  • 内存关联:执行上下文存储在「栈内存」的栈帧(局部上下文)或全局内存区(全局上下文),函数对象本体仍存于堆内存​
  1. 阶段 4:字节码生成 ——AST→字节码​
  • 核心背景:早期 V8 直接将 AST 编译为机器码(Full Codegen),启动慢;引入字节码后平衡启动速度与执行效率​
  • 核心作用:将 AST 转换为中间代码(字节码),非直接执行的机器指令​
  • 字节码特性:包含操作码(如LdaSmi加载数值)和操作数,体积小、生成快​
  • 内存关联:字节码存储在堆内存的「字节码缓冲区」(连续内存块,便于解释器读取)​
  • 示例:var a = 1 + 2 → 简化字节码:​

LdaSmi [1] // 加载数值1到累加器​

AddSmi [2] // 累加器值+2,结果为3​

StaGlobal [a] // 将结果存储到全局变量a​

  1. 阶段 5:即时编译 + 执行 —— 字节码→机器码​

V8 通过「解释器(Ignition)+ 编译器(TurboFan)」协同工作:​

组件​核心作用​
Ignition(解释器)​逐行解释执行字节码,启动快、内存占用低,执行过程中记录代码执行频率(热点检测)​
TurboFan(编译器)​将重复执行≥阈值(如 100 次)的「热点代码」编译为优化机器码,缓存后直接执行​

  • 完整执行流程:​
  1. 解释器读取堆内存中的字节码,逐行执行并操作栈 / 堆内存​
  1. 热点代码标记后,编译器后台编译为与 CPU 架构匹配的机器码​
  1. 后续执行直接使用堆内存中的机器码缓存(效率提升 5~10 倍)​
  1. 环境变化时(如变量类型改变),触发「去优化」退回到字节码解释执行​
  • 内存关联:机器码缓存存储在堆内存的「代码缓存区」(只读权限),执行时直接操作 CPU 寄存器和内存​

编译与内存的联动关系​

编译阶段​操作内存空间​核心内存交互​
词法分析​代码空间​读取源码到缓冲区,生成连续存储的 Token 流​
语法分析​堆内存​动态分配内存构建 AST 树形结构​
预编译​栈内存 / 全局区​创建执行上下文栈帧,初始化变量环境 / 词法环境​
字节码生成​堆内存​分配连续缓冲区存储字节码指令​
即时编译 + 执行​栈内存 + 堆内存​读写栈(局部变量 / 参数)和堆(对象 / 机器码缓存),热点代码缓存机器码​

二、JS 内存空间:数据存储的物理基础​

JS 内存分为三大空间,遵循「栈管执行、堆管存储」的核心分工:​

  1. 内存空间布局​

​​

image.png

  1. 栈内存 vs 堆内存:核心差异(含底层实现)​
特性​栈内存​堆内存​
存储内容​简单数据类型、栈帧、堆引用指针​复杂数据类型、闭包变量、字符串常量、中间代码​
空间大小​固定(V8:64 位 16MB/32 位 8MB)​动态(无上限,受系统内存限制)​
访问效率​极高(连续内存,栈指针直接访问)​较低(非连续内存,需指针寻址 + 哈希查找)​
管理方式​自动回收(栈帧覆盖,无需 GC)​垃圾回收(GC)分代回收策略​
底层实现​操作系统连续内存块,栈指针(ESP)移动实现分配 / 释放​V8 管理的非连续内存块,分新生代 / 老生代 / 大对象空间​

  1. 内存分配与回收底层逻辑​

(1)内存分配流程​

  • 简单数据类型(栈分配):​
  1. 函数调用时创建栈帧,在局部变量区预留固定大小内存(如 Number 占 8 字节)​
  1. 变量赋值时直接写入栈内存地址(值存储)​
  1. 示例:var a = 1 → 栈地址 0x100 写入二进制值00000001​
  • 复杂数据类型(堆分配):​
  1. 栈帧中分配指针内存(64 位系统占 8 字节)​
  1. 堆内存中分配非连续内存,创建哈希表结构存储对象属性​
  1. 栈指针存储堆内存地址,引用赋值仅复制指针(不复制对象本体)​

(2)垃圾回收(GC)机制​

JS 无需手动管理内存,依赖 V8 的分代回收策略:​

  • 新生代(New Space):存储新创建的小对象(≤2MB),采用 Scavenge 算法:​
  1. 分为 From/To 空间(各 1MB),新对象分配到 From 空间​
  1. From 空间满时,标记存活对象并复制到 To 空间(压缩内存)​
  1. 交换 From/To 角色,存活≥2 次的对象晋升到老生代​
  • 老生代(Old Space):存储大对象 / 长期存活对象,采用 Mark-Sweep+Mark-Compact 算法:​
  1. Mark-Sweep(标记 - 清除):标记可达对象,回收未标记对象(产生碎片)​
  1. Mark-Compact(标记 - 整理):移动存活对象到内存一端,释放空闲内存(解决碎片)​
  1. 触发时机:老生代内存使用率达 70% 阈值​
  • 闭包变量回收:​

外部函数执行完后,栈帧销毁,但闭包引用的变量转移到堆内存 closure 对象;当闭包无引用时,closure 对象被 GC 回收​

三、JS 数据类型:内存存储的直接体现​

  1. 语言特性定位​

分类​定义​JS 归属​底层原理补充​
静态 / 动态​编译时 / 运行时检查数据类型​动态语言​无类型指针设计,栈内存存储值 / 引用,运行时通过类型标签判断(如 Number 标签 0x00)​
强 / 弱类型​类型是否可隐式转换​弱类型语言​自动调用类型转换函数(如1 + "2"触发Number.prototype.toString())​

  1. 八种数据类型的底层存储​

类型分类​具体类型​存储方式​底层细节​
简单数据类型​Undefined​栈内存(值存储)​固定二进制值0x09,占 4 字节​
Null​栈内存(值存储)​二进制值0x00,typeof null返回object是设计 bug(早期 3 位类型标记冲突)​
Boolean​栈内存(值存储)​0x01(true)/0x00(false),占 1 字节​
Number​栈内存(值存储)​IEEE 754 标准 64 位双精度浮点数(最大安全值 2^53-1)​
String​栈引用 + 堆常量池​字面量存储在堆常量池,栈存储引用地址,相同字符串复用地址​
Symbol​栈内存(值存储)​唯一标识符,底层通过哈希值实现唯一性​
BigInt​栈内存(值存储)​支持任意精度整数,按需分配栈内存​
复杂数据类型​Object(含数组 / 函数)​栈引用 + 堆本体​堆内存中以哈希表存储属性,栈存储堆地址​

  1. 关键差异:值赋值 vs 引用赋值​

// 1. 值赋值(栈内存独立存储)​

function foo1() {​

var a = 1;​

var b = a; // 复制栈中值,b独立存储​

a = 2;​

console.log(a); // 2,仅修改a的栈值​

console.log(b); // 1,b的栈值不变​

}​

// 2. 引用赋值(栈指针指向同一堆地址)​

function foo2() {​

var a = { name: "极客时间" };​

var b = a; // 复制栈中指针,指向同一堆对象​

a.name = "极客邦"; // 修改堆对象属性​

console.log(a); // {name: "极客邦"}​

console.log(b); // {name: "极客邦"}(同一引用)​

}​

四、执行上下文:代码运行的核心载体​

  1. 执行上下文的组成结构​

每个执行上下文包含 4 个核心部分,决定变量访问规则和执行逻辑:​

组成部分​作用​底层实现细节​
变量环境(VO)​存储var变量 + 函数声明​哈希表结构,函数提升优先于变量,变量初始化为undefined​
词法环境(LE)​存储let/const变量(块级作用域)​双向链表结构,每个块级作用域对应环境记录项,支持 TDZ(未初始化标记)​
outer 链​词法作用域链,指向外层上下文​指针链结构,变量查找时遍历链表,直至全局上下文(未找到返回undefined)​
this​绑定当前执行上下文的调用对象​隐藏属性,创建阶段绑定:全局→全局对象,函数调用→调用对象,new→实例对象​

  1. 执行上下文生命周期​

​​

image.png

五、闭包:内存机制的典型应用​

闭包的底层本质​

当内部函数引用外部函数变量时,V8 引擎会:​

  1. 编译阶段扫描内部函数,识别引用的自由变量(如myName/test1)​
  1. 堆内存中创建closure(foo)对象,保存这些自由变量(独立于外部函数执行上下文)​
  1. 外部函数执行完后,栈帧销毁,但closure对象因被内部函数引用而保留​
  1. 内部函数通过 outer 链访问closure中的变量,实现「函数外部访问内部变量」​

  2. 闭包代码执行与内存表现​

function foo() {​

var myName = "极客时间"; // 自由变量→存入closure​

let test1 = 1; // 自由变量→存入closure​

const test2 = 2; // 未被引用→不存入​

return {​

setName: function(newName) {​

myName = newName; // 访问closure.myName​

},​

getName: function() {​

console.log(test1); // 访问closure.test1​

return myName;​

}​

};​

}​

var bar = foo(); // bar持有内部函数引用→closure不回收​

bar.setName("极客邦"); // 修改closure.myName​

bar.getName(); // 输出1,返回"极客邦"​

  • 内存分布:​
  • 栈内存:bar存储内部函数引用指针​
  • 堆内存:内部函数对象 + closure(foo)对象(myName: "极客邦"、test1: 1)​

六、核心总结​

  1. 内存分工原则:栈内存负责执行(上下文、简单数据、引用指针),堆内存负责存储(复杂数据、闭包变量、中间代码),代码空间负责源码缓存​
  1. V8 核心逻辑:源码→Token→AST→字节码→机器码,JIT 编译(解释器 + 编译器)平衡启动速度与执行效率​
  1. 数据存储规则:简单类型栈存储(值),复杂类型堆存储(本体)+ 栈存储(引用),字符串复用常量池​
  1. 闭包底层原理:堆内存closure对象保留自由变量,突破作用域限制,依赖 V8 编译期扫描与执行期引用​
  1. 设计核心目标:通过「分栈堆存储」优化访问效率,通过「JIT 编译」优化执行速度,通过「GC 机制」自动管理内存