JavaScript引擎 — V8 如何把你的代码变成机器能跑的指令
JavaScript 是一门解释型语言——但这个说法在 V8 面前只对了一半。V8 使用了即时编译(JIT)技术,把频繁执行的代码直接编译成机器码。理解 V8 的工作原理,能帮你写出”对引擎友好”的高性能代码。
📋 开篇自测:你已经知道多少?
- JavaScript 代码在浏览器中是如何被执行的?是逐行解释还是编译后执行?
- 什么是隐藏类(Hidden Class)?为什么动态添加属性会影响性能?
- V8 的 Ignition 和 TurboFan 分别是什么?它们如何协作?
一、JavaScript 引擎概览
1.1 主流 JavaScript 引擎
不同浏览器使用不同的 JavaScript 引擎:
| 浏览器 | JS 引擎 | 特点 |
|---|---|---|
| Chrome / Edge | V8 | 最广泛使用,也用于 Node.js |
| Firefox | SpiderMonkey | Mozilla 开发,最早的JS引擎 |
| Safari | JavaScriptCore (Nitro) | Apple 开发 |
本章以 V8 为核心进行讲解,因为它是目前使用最广泛的 JavaScript 引擎。
1.2 V8 的设计目标
V8 引擎诞生于 2008 年,由 Google 的 Lars Bak 团队开发。它的设计目标只有一个:让 JavaScript 跑得尽可能快。
为了实现这个目标,V8 采用了一系列激进的优化策略:
- 直接将 JavaScript 编译为机器码,跳过字节码阶段(这是早期 V8 的做法)
- 2017 年起改为先编译为字节码,再将热点函数编译为优化的机器码
- 使用隐藏类和内联缓存加速属性访问
- 精确的垃圾回收器
二、V8 的执行流水线
2.1 整体架构
V8 的代码执行分为多个阶段,由不同的组件负责:
V8 执行流水线
JavaScript 源代码
│
▼
┌──────────────┐
│ Parser │ 解析器
│ (解析阶段) │ 源代码 → AST (抽象语法树)
└──────┬───────┘
│ AST
▼
┌──────────────┐
│ Ignition │ 解释器
│ (解释阶段) │ AST → 字节码 → 逐条解释执行
└──────┬───────┘
│ 收集类型反馈信息
│ (哪些函数被频繁调用,参数类型是什么)
▼
┌──────────────┐
│ TurboFan │ 优化编译器
│ (编译阶段) │ 字节码 + 类型反馈 → 优化的机器码
└──────┬───────┘
│
▼
高速执行机器码
如果类型假设失败 (去优化/Deoptimization):
机器码 → 回退到 Ignition 字节码执行
2.2 为什么先解释后编译
早期的 V8(2008-2017)直接将 JavaScript 编译为机器码,跳过了字节码阶段。这样做执行速度快,但有两个问题:
- 编译耗时长:首次加载页面时,大量 JavaScript 都需要编译,导致启动慢
- 内存占用大:机器码比字节码体积大得多
2017 年,V8 引入了 Ignition 解释器,采用”先解释后编译”的策略:
- 所有代码先由 Ignition 编译为紧凑的字节码并解释执行
- 只有被识别为”热点”的函数才会被 TurboFan 编译为优化的机器码
V8 的编译管线仍在持续演进。V8 引入了 Maglev(2022 年开始开发,2023 年随 Chrome 117 正式发布)——一个介于 Ignition 和 TurboFan 之间的中间层优化编译器,它能更快地生成较优的机器码,缩短了从解释执行到优化执行之间的性能断层。此外,TurboFan 自身也在向新一代架构 Turboshaft 迁移,以获得更好的编译速度和代码质量。V8 团队还在推进实验性的 Turbolev 项目,尝试用 Maglev 的 IR 作为 Turboshaft 后端的前端,Maglev 和 TurboFan 的边界正在进一步模糊。
编译策略对比
旧方案 (Full-codegen + Crankshaft):
源代码 ──→ [编译全部代码为机器码] ──→ 执行
耗时长,内存大,但执行快
新方案 (Ignition + TurboFan):
源代码 ──→ [快速编译为字节码] ──→ 解释执行
│ │
│ 热点函数被识别
│ │
└──→ [TurboFan 优化编译] ──→ 机器码执行
只编译热点代码,按需优化
三、解析阶段:从源代码到 AST
3.1 词法分析(Lexing / Tokenization)
解析的第一步是把源代码字符串拆分成 Token(词法单元):
词法分析示例
源代码:
function add(a, b) { return a + b; }
Token 序列:
┌──────────┬─────────┐
│ Token类型 │ Token值 │
├──────────┼─────────┤
│ Keyword │ function │
│ Identifier│ add │
│ Punctuator│ ( │
│ Identifier│ a │
│ Punctuator│ , │
│ Identifier│ b │
│ Punctuator│ ) │
│ Punctuator│ { │
│ Keyword │ return │
│ Identifier│ a │
│ Punctuator│ + │
│ Identifier│ b │
│ Punctuator│ ; │
│ Punctuator│ } │
└──────────┴─────────┘
3.2 语法分析(Parsing)
Token 序列被送入语法分析器,根据 ECMAScript 语法规则构建 AST(Abstract Syntax Tree,抽象语法树):
AST 结构示意
function add(a, b) { return a + b; }
FunctionDeclaration
├── id: Identifier("add")
├── params: [Identifier("a"), Identifier("b")]
└── body: BlockStatement
└── ReturnStatement
└── BinaryExpression
├── operator: "+"
├── left: Identifier("a")
└── right: Identifier("b")
3.3 惰性解析(Lazy Parsing)
V8 并不会立即解析所有代码。对于函数体,V8 采用惰性解析策略:
惰性解析策略
function outer() { ← 完整解析 (立即执行)
const x = 1;
function inner() { ← 预解析 (只扫描语法,不构建AST)
return x + 2; │ 等到真正调用时才完整解析
} ←┘
return inner(); ← 此时才触发 inner 的完整解析
}
outer();
惰性解析的好处:
- 减少启动时间——很多函数可能永远不会被调用
- 减少内存占用——不为未使用的函数构建 AST
但如果一个函数被预解析后又立即被调用,就会浪费一次预解析的时间。这就是为什么有些工具会用 IIFE 包裹模块代码——让 V8 知道这个函数需要立即解析。
🤔 想一想 如果一个 JavaScript 文件中有 100 个函数,但页面首次加载只会调用其中 5 个,惰性解析能节省多少解析时间?
四、Ignition 解释器:字节码的生成与执行
4.1 什么是字节码
字节码是介于源代码和机器码之间的一种中间表示。它比源代码紧凑、比机器码通用(不依赖特定 CPU 架构)。
字节码示例
JavaScript 源代码:
function add(a, b) {
return a + b;
}
Ignition 字节码 (简化):
Ldar a1 // 把参数a加载到累加器
Add a2, [0] // 把参数b加到累加器
Return // 返回累加器中的值
4.2 Ignition 的寄存器机
Ignition 是一个寄存器机(Register Machine),使用虚拟寄存器来存储中间值。每个函数都有自己的寄存器文件:
Ignition 寄存器布局
┌────────┬────────┬────────┬────────┬────────┐
│ r0 │ r1 │ r2 │ ... │ 累加器 │
│(局部变量)│(局部变量)│(临时值) │ │(核心寄存器)│
└────────┴────────┴────────┴────────┴────────┘
大多数字节码操作都涉及累加器:
LdaSmi [42] // 加载小整数42到累加器
Star r0 // 将累加器的值存到r0
Ldar r0 // 将r0的值加载到累加器
Add r1, [1] // 累加器 = 累加器 + r1
4.3 类型反馈(Type Feedback)
在解释执行字节码的过程中,Ignition 会收集类型反馈信息。这些信息记录了每个操作实际遇到的数据类型:
类型反馈收集
function sum(a, b) { return a + b; }
sum(1, 2); // 记录: Add操作的参数是 Smi(小整数)
sum(3, 4); // 记录: 还是Smi
sum(5, 6); // 记录: 还是Smi → TurboFan可以假设参数总是整数!
sum("hello", " "); // 类型变了! → 如果已经编译为优化机器码,需要去优化
类型反馈信息被存储在**反馈向量(Feedback Vector)**中,它是 TurboFan 进行优化编译的关键依据。
五、TurboFan 优化编译器:让热点代码飞起来
5.1 什么是热点代码
当一个函数被调用足够多次(通常是几百到几千次),V8 会认为它是”热点”函数,值得花时间进行优化编译。
热点识别机制
函数调用计数:
add(): 1次 ──→ Ignition解释执行
add(): 10次 ──→ 还是Ignition
add(): 100次 ──→ 接近阈值
add(): 达到阈值 ──→ 触发TurboFan优化编译! (具体阈值由 V8 内部启发式算法动态决定,受函数大小、类型反馈质量等多因素影响)
add(): 1001次 ──→ 执行优化后的机器码 (快10-100倍)
5.2 TurboFan 的优化手段
TurboFan 基于类型反馈信息,进行多种优化:
投机优化(Speculative Optimization):
function calc(x) {
return x * 2 + 1;
}
// 如果类型反馈显示x总是整数:
// TurboFan 生成的机器码 (伪代码):
// imul rax, 2 // 整数乘法 (单条CPU指令)
// add rax, 1 // 整数加法
// ret
// 而不是通用的 "先检查类型, 再选择操作" 路径
内联(Inlining):
function square(x) { return x * x; }
function sumOfSquares(a, b) {
return square(a) + square(b); // TurboFan会把square的代码内联到这里
}
// 优化后等价于:
function sumOfSquares(a, b) {
return a * a + b * b; // 消除了函数调用开销
}
逃逸分析(Escape Analysis):
function createPoint(x, y) {
return { x, y }; // 创建了一个临时对象
}
function distance(p1, p2) {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.sqrt(dx * dx + dy * dy);
}
// 如果TurboFan分析发现对象不会"逃逸"出函数:
// 它可以直接用寄存器存储x和y,不实际创建堆对象
// 这叫做 "标量替换 (Scalar Replacement)"
5.3 去优化(Deoptimization)
TurboFan 的优化是基于”假设”的——假设参数类型不变、假设对象结构不变。如果运行时假设被打破,就必须退回到 Ignition 字节码执行:
去优化流程
TurboFan 假设: add(a, b) 的参数总是整数
生成: 整数专用的机器码
add(1, 2) → 使用优化机器码, 快速执行 ✓
add(3, 4) → 使用优化机器码, 快速执行 ✓
add("hello", " world") → 类型假设失败!
│
▼
[去优化 Deopt]
丢弃优化机器码
回退到Ignition字节码
重新收集类型反馈
(可能重新触发TurboFan编译)
🤔 想一想 以下代码为什么可能导致 V8 反复进行”优化→去优化→重新优化”的循环?
function process(data) { return data.value * 2; } process({ value: 1 }); // 对象形状A process({ value: 2, extra: 0 }); // 对象形状B process({ value: 3 }); // 对象形状A
六、隐藏类(Hidden Class):V8 访问属性的秘密武器
6.1 JavaScript 对象的挑战
在 C++ 或 Java 中,对象的结构(有哪些属性、每个属性的类型和偏移量)在编译时就确定了。但 JavaScript 的对象是动态的——随时可以添加或删除属性。
如果每次访问属性都要在哈希表中查找,性能会非常差。V8 使用**隐藏类(Hidden Class,也叫 Map 或 Shape)**来解决这个问题。
6.2 隐藏类的工作原理
每个 JavaScript 对象都关联一个隐藏类。隐藏类记录了对象的属性名称、顺序和在内存中的偏移量。
隐藏类示例
const point = {}; // 关联隐藏类 C0 (空对象)
point.x = 1; // 转换到隐藏类 C1 (有属性x, 偏移量0)
point.y = 2; // 转换到隐藏类 C2 (有属性x和y, 偏移量0和1)
隐藏类链:
┌─────┐ 添加x ┌─────────────────┐ 添加y ┌─────────────────────┐
│ C0 │ ──────→ │ C1 │ ──────→ │ C2 │
│(空) │ │ x: offset 0 │ │ x: offset 0 │
└─────┘ └─────────────────┘ │ y: offset 1 │
└─────────────────────┘
point 对象内存布局:
┌──────────┬──────┬──────┐
│ 隐藏类指针│ x: 1 │ y: 2 │
│ (→ C2) │ [0] │ [1] │
└──────────┴──────┴──────┘
6.3 隐藏类共享
如果两个对象以相同的顺序添加相同的属性,它们会共享同一个隐藏类:
隐藏类共享
const a = {}; a.x = 1; a.y = 2; // 隐藏类: C0 → C1 → C2
const b = {}; b.x = 3; b.y = 4; // 隐藏类: C0 → C1 → C2 (共享!)
const c = {}; c.y = 5; c.x = 6; // 隐藏类: C0 → C3 → C4 (不同顺序, 不共享!)
结论: 属性添加顺序不同 → 不同的隐藏类 → 无法共享优化
6.4 对编码的影响
// 好的做法: 在构造函数中一次性初始化所有属性
class Point {
constructor(x, y) {
this.x = x; // 所有Point实例共享相同的隐藏类转换链
this.y = y;
}
}
// 坏的做法: 在不同分支中以不同顺序添加属性
function makeObject(type) {
const obj = {};
if (type === 'a') {
obj.x = 1;
obj.y = 2; // 隐藏类路径 1
} else {
obj.y = 2;
obj.x = 1; // 隐藏类路径 2 (不同!)
}
return obj;
}
// 更坏的做法: 动态添加不可预测的属性
function addDynamic(obj, key, value) {
obj[key] = value; // 每次key不同就会创建新的隐藏类
}
七、内联缓存(Inline Cache):加速属性访问
7.1 什么是内联缓存
内联缓存(IC)是一种优化属性访问的技术。当代码 obj.x 第一次执行时,V8 需要根据 obj 的隐藏类查找 x 的偏移量。查找结果会被缓存在字节码的”操作位置”上,下次执行相同的代码时,如果对象的隐藏类没变,就可以直接用缓存的偏移量访问属性。
内联缓存工作原理
function getX(obj) {
return obj.x; // 这个位置有一个IC槽
}
第1次调用: getX({ x: 1, y: 2 })
IC槽: 空 → 查找隐藏类C2, x的偏移量是0
IC槽: [C2 → offset 0] (单态/Monomorphic)
第2次调用: getX({ x: 3, y: 4 }) (同样的隐藏类C2)
IC槽: [C2 → offset 0] → 命中! 直接读取offset 0 (快速)
第N次调用: getX({ x: 5, z: 6 }) (不同的隐藏类C5!)
IC槽: [C2 → offset 0, C5 → offset 0] (多态/Polymorphic)
如果IC槽中的隐藏类超过4种:
IC槽: [太多了] → 退化为慢速查找 (Megamorphic)
7.2 IC 的三种状态
内联缓存状态转换
┌───────────┐ 首次调用 ┌───────────┐ 不同形状 ┌───────────┐ 更多形状 ┌───────────┐
│ 未初始化 │ ────────→ │ 单态 │ ────────→ │ 多态 │ ────────→ │ 巨态 │
│(Uninitialized)│ │(Monomorphic)│ │(Polymorphic)│ │(Megamorphic)│
└───────────┘ └───────────┘ │ 2-4种形状 │ │ 放弃缓存 │
最快! │ 较快 │ │ 最慢 │
直接偏移量访问 └───────────┘ └───────────┘
7.3 对编码的影响
// 好的做法: 保持对象形状一致 (单态IC)
const points = [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
{ x: 5, y: 6 },
];
// 所有对象共享相同隐藏类, IC保持单态
// 坏的做法: 对象形状不一致 (多态/巨态IC)
const items = [
{ x: 1, y: 2 },
{ x: 3, y: 4, z: 5 },
{ a: 6, b: 7 },
];
// 不同的隐藏类, IC退化为多态甚至巨态
八、JIT 编译的整体流程回顾
把前面所有概念串起来,看一段代码在 V8 中的完整旅程:
一段代码的 V8 旅程
// 源代码
function calculate(items) {
let sum = 0;
for (let i = 0; i < items.length; i++) {
sum += items[i].value;
}
return sum;
}
Step 1: Parser 解析
源代码 → AST
Step 2: Ignition 编译 + 解释执行
AST → 字节码
逐条执行字节码
同时收集类型反馈:
- items 总是 Array
- items[i].value 总是 Number (Smi)
- items[i] 的隐藏类总是 C3 (有value属性)
Step 3: 函数被调用 ~1000次, 识别为热点
Step 4: TurboFan 优化编译
基于类型反馈假设:
- items 是数组 → 使用数组专用的索引操作
- value 是整数 → 使用整数加法指令
- 对象隐藏类是C3 → 直接用偏移量访问value
生成优化的机器码
Step 5: 后续调用直接执行机器码 (快!)
Step 6 (如果类型变了):
calculate([{ value: "not a number" }])
类型假设失败 → 去优化 → 回退到Ignition
九、实用编码建议
基于对 V8 内部机制的理解,以下是一些实用的编码建议:
V8 友好编码指南
┌────────────────────────────────────────────────────────────────┐
│ 1. 保持对象形状一致 │
│ - 在构造函数中初始化所有属性 │
│ - 避免动态添加/删除属性 │
│ - 相同"类型"的对象按相同顺序初始化属性 │
├────────────────────────────────────────────────────────────────┤
│ 2. 保持函数参数类型稳定 │
│ - 避免用同一个函数处理不同类型的参数 │
│ - 特别是频繁调用的函数 │
├────────────────────────────────────────────────────────────────┤
│ 3. 优先使用TypedArray处理数值 │
│ - Float64Array, Int32Array 等 │
│ - V8可以生成更高效的机器码 │
├────────────────────────────────────────────────────────────────┤
│ 4. 避免在循环中创建函数或闭包 │
│ - 每次创建都是一次堆分配 │
│ - 把函数提取到循环外部 │
├────────────────────────────────────────────────────────────────┤
│ 5. 使用 const 和 let 替代 var │
│ - V8 对块级作用域变量有更好的优化 │
├────────────────────────────────────────────────────────────────┤
│ 6. 避免 delete 操作符 │
│ - delete 会破坏隐藏类 │
│ - 用 obj.prop = undefined 替代 │
└────────────────────────────────────────────────────────────────┘
十、动手实验:观察 V8 的行为
10.1 实验一:查看字节码
使用 Node.js 可以直接查看 V8 生成的字节码:
# 查看函数的字节码
node --print-bytecode --print-bytecode-filter=add -e "
function add(a, b) { return a + b; }
add(1, 2);
"
10.2 实验二:观察内联缓存状态
# 查看IC状态
node --trace-ic -e "
function getX(obj) { return obj.x; }
getX({ x: 1 }); // 单态
getX({ x: 2 }); // 还是单态 (相同隐藏类)
getX({ x: 3, y: 4 }); // 多态 (不同隐藏类)
"
10.3 实验三:观察去优化
# 观察去优化事件
node --trace-deopt -e "
function calc(x) { return x * 2; }
for (let i = 0; i < 10000; i++) calc(i); // 触发优化
calc('string'); // 触发去优化
"
十一、本章知识脉络总结
JavaScript 引擎知识地图
V8 执行流水线
├── 解析阶段 (Parser)
│ ├── 词法分析: 源代码 → Token
│ ├── 语法分析: Token → AST
│ └── 惰性解析: 未调用的函数延迟解析
│
├── 解释阶段 (Ignition)
│ ├── 字节码生成: AST → 字节码
│ ├── 寄存器机: 虚拟寄存器 + 累加器
│ └── 类型反馈收集: 记录实际运行时类型
│
├── 优化编译阶段 (TurboFan)
│ ├── 热点识别: 调用计数超过阈值
│ ├── 投机优化: 基于类型假设生成专用机器码
│ ├── 内联: 消除函数调用开销
│ ├── 逃逸分析: 避免不必要的堆分配
│ └── 去优化: 类型假设失败时回退
│
├── 隐藏类 (Hidden Class)
│ ├── 目的: 让动态对象的属性访问接近静态语言速度
│ ├── 转换链: 每添加一个属性创建新的隐藏类
│ ├── 共享: 相同初始化路径的对象共享隐藏类
│ └── 注意: delete/动态属性会破坏隐藏类
│
└── 内联缓存 (Inline Cache)
├── 目的: 缓存属性查找结果
├── 单态: 最快 (只见过一种隐藏类)
├── 多态: 较快 (2-4种隐藏类)
└── 巨态: 最慢 (放弃缓存)
📝 结尾自测:检验你的学习成果
- V8 的三个核心组件(Parser、Ignition、TurboFan)各自的职责是什么?它们如何协作?
- 什么是惰性解析?它对页面启动性能有什么帮助?
- 隐藏类是如何加速属性访问的?为什么建议在构造函数中一次性初始化所有属性?
- 内联缓存的三种状态(单态、多态、巨态)分别对应什么场景?性能差异有多大?
- 什么是去优化(Deoptimization)?什么样的代码模式容易导致反复去优化?
下一章预告:V8 执行 JavaScript 代码时,遇到异步操作(
setTimeout、Promise、fetch)怎么办?下一章我们将深入浏览器的事件循环机制,彻底搞清楚调用栈、任务队列、微任务和宏任务的协作关系。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90