引擎解析编译执行
# 01.引擎解析编译执行
📍 本专栏开篇。我们将从「一行 JS 代码到底变成了什么」出发,拆开 V8 从源码字符串到机器指令的完整管线。
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 解析字符到AST
- 4. Ignition
- 5. 快速编译层详解
- 6. 中间层编译器
- 7. 优化编译器详解
- 8. 分层与OSR
- 9. 三引擎横向对比
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一行代码在三引擎
打开 Chrome、Firefox 和 Safari,分别跑下面这段代码:
// 对 100 万个对象做加法
function bench() {
const start = performance.now();
for (let i = 0; i < 1_000_000; i++) {
const a = { x: i };
const b = { x: i + 1 };
const c = a.x + b.x;
}
return performance.now() - start;
}
console.log(`耗时: ${bench().toFixed(1)} ms`);
// Chrome : 约 12 ms
// Firefox: 约 36 ms
// Safari : 约 18 ms
2
3
4
5
6
7
8
9
10
11
12
13
14
同样的 ECMAScript 规范、同样的源代码、甚至同样是"现代 JS 引擎"——Chrome 和 Firefox 差了 3 倍。而且这不是特例:
// 同一个排序逻辑在不同引擎上的速度差可达 4 倍
const arr = Array.from({ length: 50000 }, (_, i) => Math.random());
console.time('sort');
arr.sort((a, b) => a - b);
console.timeEnd('sort');
// Chrome : sort: ~45ms(Maglev 介入后)
// Firefox: sort: ~38ms(IonMonkey 激进内联)
// Safari : sort: ~28ms(FTL 对 compareFn 的专属优化路径更短)
2
3
4
5
6
7
8
疑惑:JS 是一门"标准化的语言"——规范(ECMA-262)规定了行为,不是实现。那为什么同样的规范,在不同引擎上能产生 3~4 倍的性能差异?甚至同一个 V8 引擎,一段热代码跑第 1 次和跑第 10 万次的耗时能差 100 倍——为什么?
# 1.2 顺藤摸到根因
答案不在 ECMA-262 规范里,而在每条执行管线背后的架构决策中。规范只规定 a + b 应该返回什么值,但从不规定:
a和b在内存中长什么样+操作要产生几条 CPU 指令- 如果
a过去一万次都是 number,这次还是 number,能不能跳过类型检查 - 一段代码被调用 10 万次后,有没有必要把它"翻译"成更快的形态
这些决定,由引擎内部的执行管线做出。
如果把一行 JS 代码比作一封"信",那么:
- 规范是这封信使用的语言(汉语/英语)——规定了"意思"
- 引擎管线是递送这封信走的路径——决定了送达速度
同一个人(规范)写的信,走快递、走空运、还是走骡马——速度是物理送信路径决定的,不是信封上的文字决定的。引擎管线的差异,就是这封信从源码到 CPU 所走的不同"递送路径"。
# 1.3 我们要回答什么
本文沿着 V8 的整条执行管线追问以下六个问题——每个问题对应中间的一章:
- ① 解析:
const a = 1这个字符串是怎么变成 V8 能理解的树状结构的? - ② 字节码:V8 为什么不直接把 AST 翻译成机器码,中间非要夹一层"字节码"?
- ③ 快速编译:2021 年 V8 为什么要加 Sparkplug——解释器 Ignition 不够快吗?
- ④ 中间层:2023 年 V8 为什么又在 Ignition 和 TurboFan 之间塞了 Maglev——两层不够吗?
- ⑤ 激进优化:TurboFan 做出了什么"假设"才能把 JS 优化得这么快?假设错了怎么办?
- ⑥ 引擎差异:SpiderMonkey 和 JavaScriptCore 为什么不照抄 V8 的方案?
最后,我们会用 一个 const add = (a, b) => a + b 的完整执行旅程串起所有这些阶段。
# 2. 架构全景概览
# 2.1 V8 全管线总图
下面是 V8(截至 2025 年 12.x 版本)从源码到机器码的完整路径:
┌─────────────────────────────────────────────────────────────┐
│ V8 执行管线(4 层) │
├─────────┬───────────────────────────────────────────────────┤
│ │ ┌──────────┐ │
│ 源码 │ │ 'const a │ JS 源码字符串 │
│ 字符串 │ │ = 1+2' │ │
│ │ └────┬─────┘ │
│ │ │ Scanner / Parser │
│ │ ┌────▼─────┐ │
│ AST │ │ 抽象语法 │ 树状结构 │
│ │ │ 树(AST) │ │
│ │ └────┬─────┘ │
│ │ │ Ignition (解释器) │
│ │ ┌────▼─────────────────────────────────────┐ │
│ 第 1 层 │ │ Ignition 字节码 │ │
│ (解释) │ │ LdaConstant [0] │ │
│ │ │ Star r0 ← 150+ 条字节码指令 │ │
│ │ │ Add r1, [1] │ │
│ │ │ Return │ │
│ │ └────┬──────────────────┬───────────────────┘ │
│ │ │ 热度够了? │ 热度不够,继续解释 │
│ │ │ │ │
│ │ ┌────▼─────────────────────────────────────┐ │
│ 第 2 层 │ │ Sparkplug 非优化机器码 │ │
│ (快编) │ │ 字节码 1:1 直译为 x86-64 指令 │ │
│ │ │ 不做任何优化,只是不再逐条解释 │ │
│ │ └────┬──────────────────────────────────────┘ │
│ │ │ 热度继续增长? │
│ │ │ │
│ │ ┌────▼──────────────────────────────────────┐ │
│ 第 3 层 │ │ Maglev 中度优化机器码 │ │
│ (中优) │ │ 做 SSA 形式 + 简单类型推断 │ │
│ │ │ 优化程度比 TurboFan 浅,但编译快 10 倍 │ │
│ │ └────┬──────────────────────────────────────┘ │
│ │ │ 极度热点? │
│ │ │ │
│ │ ┌────▼──────────────────────────────────────┐ │
│ 第 4 层 │ │ TurboFan 深度优化机器码 │ │
│ (深优) │ │ Sea-of-Nodes IR → 隐藏类推断 → 内联 → │ │
│ │ │ 逃逸分析 → 循环优化 → 指令选择 │ │
│ │ └─────────────────────────────────────────────┘ │
└─────────┴───────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
ℹ️ 在 V8 9.x 之前(2021 年以前)只有两层:Ignition → TurboFan。Sparkplug(2021,V8 9.1)和 Maglev(2023,V8 11.4)是近年补齐的中间层。
# 2.2 为什么分层三阶
讨论分层之前,有一个前置问题必须厘清:
疑惑:编译器可以直接把任何 JS 代码编译成最快形态吗?为什么不让 TurboFan 一开始就介入?
论证:
假设两种极端策略——
策略 A:"一上来就 TurboFan 深度优化"
→ 任何代码第一次执行就启动最慢/最强的优化编译器
→ 优点:一旦编译完成,跑得飞快
→ 代价:
1. 编译时间极长(TurboFan 编译 100KB 的 JS 源码要花几百毫秒)
2. 大部分代码永远不会变成热点(比如页面初始化逻辑只跑一次)
3. 你为"只执行一次"的代码付了几百毫秒的编译开销——
一个 10ms 的初始化函数编译花了 500ms,总耗时 = 510ms
→ 结论:这 500ms 白花了
策略 B:"一直解释执行,永远不编译"
→ 零编译开销
→ 优点:启动极快
→ 代价:
1. 热点代码每次执行都要解释——一个循环体的字节码被逐条执行 10 万次
2. 解释执行比机器码慢 50~100 倍
3. 一个本该 10ms 跑完的循环实际跑了 1000ms
→ 结论:省了编译时间,但十倍地花在执行时间上
策略 C:"跑几次 → 觉得热 → 逐渐升级编译"
→ 冷代码:解释执行就够了
→ 热代码:先 Sparkplug 快速编译(轻微加速,编译极快)
→ 更热代码:Maglev 中度编译(较大加速,编译较快)
→ 极热代码:TurboFan 深度编译(最大加速,编译较慢)
→ 结论:编译开销和运行时加速的帕累托最优
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
结论:分层编译不是"越多层越好"的设计炫耀,而是 "为高频代码付编译代价、为低频代码省编译代价" 的捕食策略——就像动物不会对每个猎物都用全力冲刺,只有"值得用全力的猎物"才值得付出冲刺的体力代价。
用 V8 团队的比喻:Hot code should be fast, cold code should start fast.
# 3. 解析字符到AST
# 3.1 Scanner
疑惑:const a = 1 + 2 这 16 个字符,引擎第一步是怎么"看懂"的?
V8 的 Scanner(源码文件:src/parsing/scanner.h / scanner.cc)负责词法分析(Lexing)——把字符流切成 Token 流:
// 源码字符串
"const a = 1 + 2"
// Scanner 扫描过程(字符逐位推进)
// 位置 0-4: c o n s t → Token::CONST
// 位置 5: (空格) → 跳过
// 位置 6: a → Token::IDENTIFIER ("a")
// 位置 7: (空格) → 跳过
// 位置 8: = → Token::ASSIGN
// 位置 9: (空格) → 跳过
// 位置 10: 1 → Token::NUMBER (1.0)
// 位置 11: (空格) → 跳过
// 位置 12: + → Token::ADD
// 位置 13: (空格) → 跳过
// 位置 14: 2 → Token::NUMBER (2.0)
// 位置 15: ; → Token::SEMICOLON
// 输出 Token 流:
[CONST, IDENT("a"), ASSIGN, NUM(1.0), ADD, NUM(2.0), SEMICOLON]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
V8 的 Scanner 做这件事的方式非常底层:它直接操作 const uint8_t* 指针逐字节扫描,不做任何 std::string 分配。它内部维护了一个 uc32 c0_ 字段(当前字符的 Unicode code point),通过 Advance() 推进指针。对每个 Token 类型(关键字/标识符/数字/字符串/运算符),Scanner 内部都有独立的"扫描子函数"(ScanIdentifier、ScanNumber、ScanString 等)。
几个有趣的词法细节:
// 以下三个 token 是如何被扫描的:
// 1) 数字:0x1F → Scanner 读 '0' 后看下一个字符,'x' → 进入 hex 扫描模式
// 2) 字符串:"反斜杠转义" → 遇到 \ → 读取下一字符 → 映射为对应字符
// 3) 模板字面量:`hello ${name}` → 遇到 ${ → 暂停字符串扫描 → 递归进入表达式扫描
2
3
4
5
结论:Scanner 是管线的第一站,它把人类写的文本变成机器能理解的最小语义单元。这一步的性能瓶颈几乎不在 CPU——而在字符串长度。一个 10MB 的 JS 文件,Scanner 必须先逐字节扫完才能开始下一步。
# 3.2 PreParser
疑惑:如果 JS 文件里有 200 个函数,真的需要启动时就全部解析吗?
看一段典型代码:
// main.js —— 启动时加载
import { render } from './app.js';
render();
// app.js —— 导出了 50 个函数
export function initHeader() { /* 200 行 */ }
export function initFooter() { /* 300 行 */ }
export function initSidebar() { /* 400 行 */ }
// ... 还有 47 个函数,每个都很长
// 但启动时只调用了 render(),并没有调用 initHeader/initFooter/...
2
3
4
5
6
7
8
9
10
11
如果启动时就把所有 50 个函数全部解析成 AST(并生成对应的字节码),V8 会在启动阶段消耗大量时间和内存——而这些函数可能整个会话都不会被调用。
V8 的解决方案是 PreParser(预解析器,src/parsing/preparser.h):
┌─────────────────────────────────────────┐
│ 某个函数首次被扫描 │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ PreParser │ ← 只扫描,不建完整 AST │
│ │ (轻量) │ 记录:函数边界、变量名、 │
│ │ │ 作用域信息、可能的语法错误 │
│ └──────┬──────┘ │
│ │ 函数被调用时 │
│ ┌──────▼──────┐ │
│ │ Parser │ ← 建立完整 AST │
│ │ (完整) │ 把源码逐块解析成节点 │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PreParser 做的事情:用大约 1/3 的成本走一遍源码,记录下最低限度信息(函数从哪里开始、到哪里结束、用了哪些变量、有没有语法错误)。只在实际需要时才"唤醒"Parser 做完整解析。
但这里有一个经典陷阱,见 3.4 节。
# 3.3 Parser
当代码被确定需要执行时,V8 的 Parser(完整解析器,src/parsing/parser.cc) 会把 Scanner 产出的 Token 流组织成 AST:
// 源码
function add(a, b) { return a + b; }
// Parser 产出的 AST(简化表示)
// ┌──────────────────────────┐
// │ FunctionLiteral │
// │ name: "add" │
// │ params: [a, b] │
// │ body: │
// │ └ ReturnStatement │
// │ └ BinaryOperation│
// │ op: "+" │
// │ left: a │
// │ right: b │
// └──────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Parser 做的是递归下降解析(Recursive Descent Parsing)——每个语法结构(表达式、语句、函数声明、类声明)都有对应的解析函数,这些函数互相调用,形成一棵"解析调用"树,构造出来的正是 AST。
V8 的 Parser 有几个重要的优化:
- 单遍解析(Single-Pass):大部分情况下,Parser 在解析的同时就生成 AST,不会先产生中间表示再转译
- 字符串驻留(String Interning):所有标识符(如变量名、函数名)被存入一个全局字符串表,
add出现了 100 次也只存一份 - PreParser 协作:如果函数已被 PreParser 扫过,Parser 可以直接"续上",不需要从零开始
结论:AST 是管线第一道"实物产出"。从此刻起,源码字符串被彻底扔掉——后续所有阶段(字节码、机器码)处理的都是这棵树。
# 3.4 IIFE 的解析
疑惑:为什么 (function(){})() 和 function(){}() 表现不同?
// ✅ 能正常执行
(function() { console.log('OK'); })(); // → "OK"
// ❌ SyntaxError
function() { console.log('FAIL'); }();
// Uncaught SyntaxError: Function statements require a function name
2
3
4
5
6
答案出在 PreParser 和 Parser 的交接上:
| 写法 | Scanner 看到第一个 Token | Parser 的判断 | 结果 |
|---|---|---|---|
(function(){})() | ( | "这是一个表达式上下文" | 把 function 当作函数表达式→生成 AST→正常执行 |
function(){}() | function | "这是一个语句上下文" | 把 function 当作函数声明→要求必须有函数名→SyntaxError |
关键在于:Scanner 返回给 Parser 的 Token 本身不携带上下文信息——Token FUNCTION 既可以出现在语句位置(函数声明),也可以出现在表达式位置(函数表达式)。这个区分由 Parser 根据当前位置做出。
一个更诡异的例子:
// 这是 PreParser 的经典漏判场景
const obj = {
method: function() { // ← PreParser 扫描到这里
// 500 行代码
(function() { // ← 里面还有一个 IIFE
// PreParser 可能误判括号匹配——提前认为 method 结束了
// 导致 Parser 后续重新解析,浪费掉了 PreParser 省下的时间
})();
}
};
2
3
4
5
6
7
8
9
10
结论:PreParser 是一种"投机优化"——大部分时候它能省钱,但当嵌套结构复杂时,它可能错误判断函数边界,反而导致 Parser 重做工作。这不是 bug,是"惰性解析"与"准确性"之间的一种工程设计权衡。
# 4. Ignition
# 4.1 寄存器机 vs
疑惑:大多数语言的虚拟机(JVM、CPython)用**栈机(Stack Machine)**模型,为什么 V8 的 Ignition 选择 寄存器机(Register Machine)?
对比两种模型的执行方式——计算 1 + 2:
┌─────────────────────────────────┐ ┌─────────────────────────────┐
│ 栈机(如 JVM) │ │ 寄存器机(如 V8 Ignition) │
├─────────────────────────────────┤ ├─────────────────────────────┤
│ │ │ │
│ iconst_1 // 1 压栈 │ │ LdaSmi [1] // r0 ← 1 │
│ iconst_2 // 2 压栈 │ │ Add r0, [2] // r0 ← r0+2 │
│ iadd // 弹出两个,相加 │ │ Return // 返回 r0 │
│ // 结果压回 │ │ │
│ │ │ ↕ 3 条指令 │
│ ↕ 3 条指令 │ │ │
│ 需要 3 次栈操作 │ │ 需要 0 次栈操作 │
└─────────────────────────────────┘ └─────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
论证:
- 指令条数:栈机需要更多指令来表达同一个语义(因为每次读取操作数都需要额外的 load/store 指令隐式通过栈)
- 寄存器机用虚拟寄存器:Ignition 维护了一个寄存器文件(
r0, r1, r2...),一条Add指令直接指定"从哪读到哪",不需要压栈弹栈 - 但寄存器指令更长:栈机指令用一个字节就能编码(操作数在栈上,不需要编码到指令中),寄存器指令需要额外字节编码"源寄存器"和"目标寄存器"
实测证据:V8 团队在 2016 年做 Ignition 升级时对比过两种方案:
| 指标 | 栈机 | 寄存器机(Ignition) |
|---|---|---|
| 平均指令条数(同一段 JS) | 1000 | 600(少 40%) |
| 每条指令字节数 | 1~2 字节 | 2~4 字节 |
| 总字节码体积 | 约 1500 字节 | 约 1600 字节(略大 7%) |
| 解释执行速度 | 基准 | 快 20~30% |
结论:Ignition 牺牲了 7% 的字节码体积,换来 20~30% 的解释执行提速和 40% 的指令数减少。考虑到 JS 中大量函数体很短(5~20 行),"每条指令做更多事"比"每条指令更紧凑"更有价值。这条原则也贯穿了整个 V8 设计——V8 始终偏向"用内存换速度"。
# 4.2 核心字节码指令表
疑惑:JS 里写了 const a = obj.x + arr[0],这一行会被翻译成哪些字节码?
让我们真正跑一次 V8 的字节码打印(用 --print-bytecode 标志):
// 在 Node.js 中运行:
// node --print-bytecode --print-bytecode-filter=add -e "
// function add(a, b) { return a + b; }
// add(1, 2);
// "
// V8 输出的字节码(简化):
//
// [generated bytecode for function: add]
// Parameter count: 3 ← a, b, 以及隐式的 this
// Register count: 0 ← 不需要额外寄存器
// Frame size: 0
//
// 0 : LdaOrUndef a0 ← 取参数 a (a0 = 第 1 个寄存器参数)
// 1 : Star r2 ← 暂存到 r2(用于 Feedback Vector)
// 2 : GetNamedProperty r2, [1], [0] ← 找 + 运算符(作为属性查找)
// 实际对小整数加法会走快速路径
// 4 : Star r2
// 5 : LdaOrUndef a1 ← 取参数 b
// 6 : Star r3
// 7 : Add r2, r3 ← r2 + r3
// 8 : Return ← 返回累加器中的结果
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Ignition 的指令集约有 200~300 条,覆盖 JS 所需的所有操作。下面按类别列出最常见的:
数据加载类:
| 指令 | 操作 | 场景 |
|---|---|---|
LdaConstant [idx] | 累加器 ← 常量池[idx] | const x = "hello" |
LdaUndefined | 累加器 ← undefined | 函数默认返回值 |
LdaNull | 累加器 ← null | x = null |
LdaSmi [int32] | 累加器 ← 小整数 | for (let i = 0; ...) |
LdaZero / LdaTrue / LdaFalse | 累加器 ← 0/true/false | 布尔/数字专用快路径 |
变量存取类:
| 指令 | 操作 | 场景 |
|---|---|---|
Star rN | rN ← 累加器 | 把计算结果存到寄存器 |
Ldar aN | 累加器 ← 参数aN | 取函数参数 |
StaGlobal [name] | 全局[name] ← 累加器 | x = 1(全局作用域) |
LdaGlobal [name], [slot] | 累加器 ← 全局[name] | console.log(...) |
运算类:
| 指令 | 操作 | 场景 |
|---|---|---|
Add rA, rB | 累加器 ← rA + rB | a + b(含字符串拼接) |
Sub rA, rB | 累加器 ← rA - rB | a - b |
Mul rA, rB | 累加器 ← rA * rB | a * b |
Div rA, rB | 累加器 ← rA / rB | a / b |
Inc [slot] | 局部变量++ | i++ |
ToNumber | 累加器 ← ToNumber(累加器) | 显式/隐式数值转换 |
控制流类:
| 指令 | 操作 | 场景 |
|---|---|---|
Jump [offset] | 无条件跳转 | continue / break |
JumpIfTrue [offset] | 累加器是 truthy → 跳转 | if (x) { ... } |
JumpIfFalse [offset] | 累加器是 falsy → 跳转 | if (!x) { ... } |
JumpLoop [offset] | 循环回跳(触发 OSR 检查) | for / while 循环体尾部 |
对象操作类:
| 指令 | 操作 | 场景 |
|---|---|---|
CreateObjectLiteral [idx] | 分配对象字面量 | const obj = { x: 1 } |
CreateArrayLiteral [idx] | 分配数组字面量 | const arr = [1, 2] |
GetNamedProperty [name], [slot] | 累加器 ← obj.name | obj.x |
SetNamedProperty [name], [slot] | obj.name ← 累加器 | obj.x = 5 |
当我们在 Chrome DevTools 中无法直接看到字节码时,可以在 Node.js 中用 --print-bytecode 输出,或者使用 V8 的 %DebugPrint()(需 --allow-natives-syntax)。
# 4.3 类型反馈向量
疑惑:Ignition 只解释不优化,那它凭什么为后面的优化编译器提供信息?
答案在 Feedback Vector(类型反馈向量,src/feedback-vector.h)——一条字节码跑了几百次之后,"值类型"的统计数据会被收集到一段内嵌在函数对象中的数组里。
function getX(obj) {
return obj.x; // ← GetNamedProperty 指令
}
// 假设 getX 被以下方式调用:
getX({ x: 1 }); // obj 是 {x: 1} → 类型反馈:隐藏类 A
getX({ x: 2 }); // obj 是 {x: 2} → 类型反馈:隐藏类 A (相同!)
getX({ x: 3 }); // obj 是 {x: 3} → 类型反馈:隐藏类 A
// Feedback Vector 记录:
// GetNamedProperty slot[0]:
// 类型 = 单态(MONOMORPHIC)
// 隐藏类 = 0x12345678({x: number} 的隐藏类地址)
// 属性偏移 = 12 bytes(x 在对象内的偏移位置)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果后来传入了其他形状的对象:
getX({ x: 1, y: 2 }); // obj 形状变了 → 不同隐藏类 B
getX({ z: 3 }); // 又是一个新形状 → 隐藏类 C
getX([1, 2, 3]); // 数组,完全不同类型
// Feedback Vector 的退化路径:
// MONOMORPHIC(单态) → POLYMORPHIC(多态) → MEGAMORPHIC(超态)
//
// 单态:TurboFan 可以做「条件跳过」——"我赌下次还是隐藏类 A"
// 多态:TurboFan 做「分支表」——"2~4 种可能,每次先查表再执行"
// 超态:TurboFan 放弃优化——"什么都可能来,我直接用字典查找"
2
3
4
5
6
7
8
9
10
结论:Feedback Vector 是 V8 分层编译的"情报系统"。Ignition 本身不优化,但它在执行时默默收集每一条指令的类型历史——就像快递员送货时顺手记下"这栋楼通常是这个户型的包裹"。等 TurboFan 接手时,这些"顺手记录"就成了它做激进优化的依据。
# 5. 快速编译层详解
# 5.1 非优化 JIT
疑惑:2021 年,V8 9.1 版本引入了 Sparkplug。他们的论文标题很直白——"A Non-Optimizing JavaScript Compiler"。如果 Sparkplug 不优化,那它有什么用?
先看一组数据。V8 团队实测了一段典型的 web 初始化代码(React 组件挂载):
只有 Ignition + TurboFan 两级时(V8 9.0 之前):
┌──────────────┬──────────┬──────────┐
│ │ Ignition │ TurboFan │
├──────────────┼──────────┼──────────┤
│ 执行耗时 │ 800ms │ 120ms │
│ 编译耗时 │ 0 │ 450ms │
│ 总耗时 │ 800ms │ 570ms │ ← TurboFan 虽然执行快,
│ │ │ │ 但编译耗时吞掉了大半收益
└──────────────┴──────────┴──────────┘
加上 Sparkplug 后(V8 9.1+):
┌─────┬──────────┬──────────┬──────────┐
│ │ Ignition │Sparkplug │ TurboFan │
├─────┼──────────┼──────────┼──────────┤
│ 执行耗时 │ 800ms │ 300ms │ 120ms │
│ 编译耗时 │ 0 │ 5ms │ 450ms │
│ 总耗时 │ 800ms │ 305ms │ 570ms │ ← Sparkplug: 只用 5ms 编译,
│ │ │ │ │ 就把耗时从 800→305ms!
└─────┴──────────┴──────────┴──────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
论证:
- TurboFan 的编译太贵了(450ms)——它要对 IR 做 20+ 轮优化 pass,每轮都要重写 IR 图
- Sparkplug 的编译极便宜(5ms)——它不做任何优化,就是把字节码一条一条"翻译"成对应的 x86-64 指令
- 大部分 JS 代码跑不到 TurboFan 的热度门槛——一个页面上的 80% 代码都是"冷启动"或"只跑几次"的逻辑,它们从 Sparkplug 获得 3~4 倍加速,但从未被 TurboFan 编译过,也不需要被 TurboFan 编译
结论:Sparkplug 解决了"解释执行太慢,TurboFan 编译太贵"的中间空白。它的设计动机不是"让热代码更快",而是让温代码别那么慢——用 5ms 的编译开销换 300ms 的执行加速回报。
# 5.2 字节码直译成机器码
Sparkplug 的编译思想非常简单粗暴:
┌──────────────────────────────────────────────────┐
│ Ignition 字节码 → Sparkplug x86-64 │
├──────────────────────────────────────────────────┤
│ │
│ LdaSmi [1] → mov rax, 1 (Smi tagged) │
│ Add r0, [2] → add rax, rdx │
│ JumpIfFalse [offset] → test rax, rax; jz .Lxx │
│ Return → ret │
│ │
│ 注意:完全不创建 IR! │
│ 直接从字节码发射机器指令—— │
│ 就像"逐字翻译",不是"先理解再重写" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
关键在于 Sparkplug 不生成任何中间表示(IR)——这与 TurboFan 形成极端对比:
| Sparkplug | TurboFan | |
|---|---|---|
| 是否有 IR | 无(字节码直接 → 机器码) | 多层 IR(Sea-of-Nodes → 低级 IR → 机器码) |
| 优化 pass | 0 个 | 20+ 个 |
| 编译速度 | 极快(~5ms / 100KB JS) | 较慢(~450ms / 100KB JS) |
| 代码质量 | 较差(无内联、无逃逸分析) | 极好 |
| 适用场景 | 温代码(执行 10~100 次) | 热代码(执行 10000+ 次) |
另外,Sparkplug 还做了一个巧妙的设计:栈帧布局与 Ignition 完全兼容。这意味着从 Ignition 切换到 Sparkplug(或反之)时,不需要重建栈帧——调用栈上的解释器帧可以被 Sparkplug 代码直接继续使用。这为 OSR(On-Stack Replacement)提供了基础设施(§8)。
# 6. 中间层编译器
# 6.1 为什么在 Ign
疑惑:2023 年,V8 11.4 引入 Maglev。为什么已经有三层了(Ignition / Sparkplug / TurboFan),还要再加一层?
这个问题要从 TurboFan 的"编译质量 vs 编译速度"困境开始说起:
V8 8.x ~ 10.x(没有 Maglev 的时代):
┌──────────────────────────────────────────────────┐
│ │
│ Ignition ──→ Sparkplug ──→ ──→ ──→ TurboFan │
│ (解释) (快编) (深优) │
│ │
│ 中间有一个巨大的跳跃: │
│ Sparkplug 编译 5ms、TurboFan 编译 450ms │
│ Sparkplug 执行 300ms、TurboFan 执行 120ms │
│ │
│ 问题:很多代码"够热到需要优化,但不够热到值 450ms" │
│ 比如:一个函数被调用了 2000 次 → 值得优化 → │
│ 但不一定值得 TurboFan 的 450ms 编译开销 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
V8 团队在 benchmarking 中发现了这个"跳跃断层"——大量 JS 代码(包括 React render 函数、Vue computed、数据转换管道)的调用频率正好落在这个断层里。
Maglev 的设计就是填补这个断层:
V8 11.4+(有 Maglev):
┌──────────────────────────────────────────────────────────┐
│ │
│ Ignition → Sparkplug → Maglev → TurboFan │
│ (解释) (5ms编译) (30ms编译) (450ms编译) │
│ (300ms执行) (180ms执行) (120ms执行) │
│ │
│ 编译速度:Maglev 比 TurboFan 快 ~15 倍 │
│ 执行质量:Maglev 代码比 Sparkplug 快 ~60% │
│ │
│ "够热、但不极热"的代码现在有了平滑的过渡路径 │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
结论:Maglev 不是"让极热代码更快"的层,而是让温-热代码在编译代价可承受的前提下获得可观的加速。它是一个"性价比层"——编译速度介于 Sparkplug 和 TurboFan 之间,代码质量也介于两者之间。
# 6.2 SSA 中间表示
与 Sparkplug 完全不同,Maglev 确实生成了 IR——SSA 形式的中间表示。
疑惑:什么是 SSA(Static Single Assignment),它为什么重要?
论证:
SSA 形式的 IR 有一条铁律:每个变量只被赋值一次。
普通代码: SSA 形式(Maglev IR):
x = 1 → x1 = 1
x = x + 2 → x2 = x1 + 2
y = x * 3 → x3 = x2
x = x + 4 → x4 = x3 + 4
// x 被赋值了 4 次 // 每个"版本"都是新变量
// x1, x2, x3, x4 各赋值一次
2
3
4
5
6
7
8
为什么这对优化至关重要?因为每个 xn 的定义位置是唯一的。编译器可以直接从 x4 回溯到 x3,再回溯到 x2,再回溯到 x1——这个"使用-定义链"(use-def chain)让以下优化变得极其简单:
- 常量折叠(Constant Folding):如果
x1 = 1,x2 = 1 + 2→ 直接算出x2 = 3 - 死代码消除(DCE):如果
x3从未被使用 → 删除y = x * 3这整行 - 值编号(GVN):如果
x2 = a + b和x5 = a + b计算结果必然相同 → 复用x2
Maglev 在 SSA 基础上做了什么优化:
| 优化 | 说明 | 例子 |
|---|---|---|
| 类型推断(基于 Feedback Vector) | 用采集的类型信息跳过类型检查 | 如果过去 1000 次 a 都是 Smi → 跳过 typeof a 检查 |
| 简单内联 | 只内联函数体很短、没有 try/catch 的函数 | const double = x => x*2 → 直接内联到调用处 |
| 分支简化 | 用类型信息消除不可能走到的分支 | typeof x === 'number' 成立 100% → 删除 else 分支 |
| 逃逸分析(轻量) | 检测对象是否逃逸出函数——没有则栈上分配 | { x: 1, y: 2 } 如果只在函数内使用 → 不分配堆对象 |
Maglev 不做什么(留给 TurboFan 做):
- 不跨多个循环迭代做优化(循环展开、向量化)
- 不做复杂的函数内联(跨模块、多态调用点)
- 不重新排列对象字段的内存布局
结论:Maglev 是"优化编译器里的务实派"——它用 SSA 做有限的、但确定性回报高的优化,为真正值得 TurboFan 投入的热点代码留出清晰的过渡路径。
# 7. 优化编译器详解
# 7.1 Sea-of-Nodes
疑惑:为什么 TurboFan 不用"三地址码 SSA"这种编译器教科书上的常规 IR,而另造了一个"Sea-of-Nodes"?
三地址码 SSA(如 LLVM IR)的组织方式:
bb0: %1 = load %obj.x // 加载属性 %2 = smi_check %1 // 检查是否为小整数 br %2, bb1, bb2 // 条件跳转 bb1: %3 = add %1, 1 // 加法 br bb3 bb2: %4 = call @ToNumber(%1) // 类型转换 %5 = add %4, 1 br bb3 bb3: %6 = phi [%3, bb1], [%5, bb2] // 合并两个分支的结果 ret %6
这种 IR 强制指定了**基本块(basic block)的线性顺序**和控制流图的明确边界——这一方面让图容易遍历,另一方面却让调度约束变得僵化。
**TurboFan 的 Sea-of-Nodes:**
```text
Sea-of-Nodes 把控制、数据、副作用全都表示为**节点**,
把所有节点丢进一张大图("海"),每个节点只携带最小约束:
┌─────────────────┐
│ Return #6 │ ← 效果节点(Effect)
└────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌────────▼──────┐ ┌────▼─────┐ ┌─────▼─────┐
│ Phi #6 │ │ Add #5 │ │ Store #.. │
│ (数据节点) │ │ (数据节点)│ │ (效果节点)│
└───────┬───────┘ └────┬─────┘ └───────────┘
│ │
┌────────┴──────┐ ┌─────▼─────┐
│ Load #1 │ │ Load #.. │
│ obj.x │ │ obj.x │
└───────────────┘ └───────────┘
关键属性:
- 没有"基本块"的概念——调度由后端的"指令选择"阶段自由决定
- 控制节点只是"约束源",不是"围墙"
- 数据流节点可以自由地在控制流之间移动
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
论证:这种设计对 JS 特别有利——JS 中大量函数体很短(5~20 行),控制流简单,但数据操作频繁(属性访问、类型转换、字符串拼接)。Sea-of-Nodes 允许调度器在这些短函数中最大化数据流指令的重排自由度,从而产生更密集的机器码。
结论:Sea-of-Nodes 是一种面向"控制和数据具有高度交织特性的语言"而设计的 IR 方案。它放弃了传统 IR 中"基本块即围墙"的约束,换取调度灵活性——这在 JS 这种高度动态的语言中产生了显著的性能收益。
# 7.2 关键优化:隐藏类
TurboFan 在 Sea-of-Nodes 图上运行 20+ 轮优化 pass,其中最有 JS 特色的三个:
(1)隐藏类推断(Map Inference)
这是 Feedback Vector 情报最核心的应用(下一篇文章会详细展开):
function getX(obj) {
return obj.x; // ← 这条 GetNamedProperty 被跑了几千次
}
// Feedback Vector 记录了:obj 的隐藏类地址 = 0x12345678
// → TurboFan 直接生成"检查隐藏类 → 跳过查找 → 直接读偏移"的代码
// 伪汇编:
// cmp [obj.map_addr], 0x12345678 ← 检查隐藏类是否匹配
// jne .deopt ← 不匹配?去优化!
// mov rax, [obj + 12] ← 匹配!直接从偏移 12 读属性 x
// ret
2
3
4
5
6
7
8
9
10
11
12
(2)函数内联(Inlining)
function double(x) { return x * 2; }
function sum(arr) {
let total = 0;
for (let v of arr) {
total += double(v); // ← 如果 arr 很大,这个调用成为瓶颈
}
return total;
}
// TurboFan 内联后:
// function sum(arr) {
// let total = 0;
// for (let v of arr) {
// total += (v * 2); // ← double 被就地展开(inlined)
// }
// return total;
// }
//
// 节省了:函数调用开销(push/pop/Call指令)、栈帧分配、Feedback Vector 查找
// 额外收益:现在 `v * 2` 和 `total += ...` 处于同一个编译单元,
// 调度器可以把它们排得更密
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(3)逃逸分析(Escape Analysis)
function createPoint(x, y) {
return { x, y, distance() { return Math.sqrt(x*x + y*y); } };
}
function pipeline(arr) {
return arr
.map(p => createPoint(p.x, p.y)) // ← 产生 N 个临时对象
.filter(p => p.distance() > 10);
}
// 不做逃逸分析:每个 createPoint 调用都会在堆上分配一个新对象 → N 次 GC
//
// TurboFan 逃逸分析检测到:这些 {x, y, distance()} 对象
// 1) 从未被赋给全局变量
// 2) 从未被返回给调用方
// 3) 从未被任何"不透明"函数使用
// → 对象"没有逃逸"出 pipeline 函数
// → 把 x 和 y 拆分为独立的局部变量
// 把 distance() 调用内联为 Math.sqrt(x*x + y*y)
// → 零堆分配!pipe 全程操作在寄存器/栈上
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 7.3 去优化(Deop
疑惑:TurboFan 内联了 double 函数,它直接生成 v * 2 的机器码——但如果 double 后来被重新赋值为另一个函数呢?
function double(x) { return x * 2; }
function sum(arr) {
let total = 0;
for (let v of arr) total += double(v);
return total;
}
// sum 被调用 10000 次 → TurboFan 把 double 内联为 v * 2
// 此时 sum 的机器码里已经没有 "call double" 指令了
// 后来某处代码执行:
double = function(x) { return x + x; };
// ← double 被替换了!但 sum 的机器码里还在跑 v * 2!
2
3
4
5
6
7
8
9
10
11
12
13
这就是 TurboFan 最精妙(也最复杂)的机制:去优化(Deoptimization)。
TurboFan 编译时,会在每个"做了假设"的位置插入一个 去优化检查点(Deopt Checkpoint):
TurboFan 生成的代码:
[检查点 0] 假设 double 没有被修改过
如果被修改了 → 跳转 .deopt_0
[检查点 1] 假设 arr 是 PACKED_SMI_ELEMENTS 类型的数组
如果是其他类型 → 跳转 .deopt_1
[检查点 2] 假设 v 是 Smi (小整数)
如果是 HeapNumber → 跳转 .deopt_2
循环体: total += (v * 2) ← 内联后的实际机器码
.deopt_0: 保存所有寄存器 → 重建解释器栈帧 → 回到 Ignition 继续执行
2
3
4
5
6
7
8
9
10
11
12
13
14
去优化发生时:
- 停止执行 TurboFan 代码
- 保存所有寄存器到内存("冻结"当前执行状态)
- 将机器码中的寄存器值映射回"解释器能理解的"变量/寄存器——这个映射表("Frame Translation")在编译时已生成
- 切换到 Ignition 模式,从去优化点之后的第一条字节码继续执行
- 如果函数后来又被调用很多次,TurboFan 会在下一次编译时把"double 可能被修改"作为一个新条件,产生不同的内联策略
验证:你可以用 --trace-deopt 标志在 Node.js 中亲眼看到去优化:
node --trace-deopt -e "
function hot(a, b) { return a + b; }
for (let i = 0; i < 100000; i++) hot(1, 2);
hot('hello', 'world'); // ← a+b 的类型从两个 number 变成了 string
"
# 输出:[deoptimizing (DEOPT eager): begin ...]
2
3
4
5
6
# 8. 分层与OSR
# 8.1 四层编译的升降级
V8 对每个函数维护一个 tiering state(分层状态),决定它当前跑在哪个编译器、下一步往哪升/降:
┌────────────────────────────────────────────────┐
│ V8 分层编译决策流程 │
├────────────────────────────────────────────────┤
│ │
│ 函数第一次执行 │
│ │ │
│ │ ← Ignition 解释执行 │
│ │ 同时:为每条指令收集 Feedback │
│ │ │
│ │ 函数被调用 ~10 次? │
│ ├──→ Sparkplug 编译(5ms) │
│ │ 执行 Sparkplug 机器码 │
│ │ │
│ │ 函数被调用 ~500 次? │
│ ├──→ Maglev 编译(30ms) │
│ │ 执行 Maglev 中度优化代码 │
│ │ │
│ │ 函数被调用 ~5000 次? │
│ ├──→ TurboFan 编译(450ms) │
│ │ 执行 TurboFan 深度优化代码 │
│ │ │
│ │ 去优化发生(假设失败) │
│ ├──→ 退回到 Ignition │
│ │ 但 Feedback Vector 被保留 │
│ │ 下次再编译时会考虑新的假设 │
│ │ │
└────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
注意:具体的热度阈值("调用 10 次/500 次/5000 次")不是硬编码的常量,V8 在运行时根据以下因素动态调整:
- 函数大小(大函数更难触发高层编译——编译成本高)
- 机器负载(CPU 繁忙时降低编译激进程度)
- 之前的去优化历史(一个经常去优化的函数会"冷遇"——编译升层减慢)
# 8.2 On-Stack
疑惑:如果一个函数正在被 Ignition 解释执行,此时 V8 判定"这个函数够热了,该编译了"——但函数正在跑,怎么办?
这就是 OSR(On-Stack Replacement,栈上替换) 解决的问题:
function heavyLoop() {
let sum = 0;
for (let i = 0; i < 1_000_000; i++) { // ← OSR 的目标:循环体
sum += process(i); // 跑了几千次后,直接替换为
} // 编译后的机器码版本
return sum;
}
heavyLoop(); // ← 这个调用只发生了一次!
2
3
4
5
6
7
8
如果不做 OSR:heavyLoop 只被调用一次,热度计数可能达不到编译阈值——即使里面的循环跑了 100 万次,引擎也会一直解释执行。
OSR 的机制:在 Sparkplug/Maglev/TurboFan 编译时,循环回跳指令(JumpLoop)是一个特殊的检查点:
JumpLoop 字节码在执行时:
1. 检查该循环的迭代次数
2. 如果达到 OSR 阈值:
a. 编译该函数的 OSR 版本(入口点在当前的 JumpLoop 位置)
b. 保存当前解释器/Sparkplug 栈帧的所有寄存器
c. 将寄存器映射到 OSR 版本机器码所需的寄存器布局
d. 跳转到 OSR 版本的入口
3. 否则:继续原路径执行
2
3
4
5
6
7
8
论证:OSR 解决了"热度检测粒度"和"实际热点位置"之间的错位——一个函数可能总共只被调用了一次(热度低),但内部某个循环跑了 10 万次(局部热度极高)。没有 OSR 的引擎会永远错过优化这个循环的机会。
# 9. 三引擎横向对比
# 9.1 SpiderMonkey
SpiderMonkey(Firefox 的 JS 引擎)的管线:
SpiderMonkey 管线:
┌────────────────────────────────────────────────────┐
│ │
│ 源码 → Parser(Full+Syntax) → Baseline Interpreter │
│ ↓ │
│ Baseline Compiler (WarpBuilder) ← 温代码 │
│ ↓ │
│ IonMonkey (优化 JIT) ← 热代码 │
│ │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
与 V8 的关键差异:
- 没有"快速非优化编译器"这个中间层——SpiderMonkey 只有 Baseline Interpreter + Baseline Compiler(对应 V8 的 Ignition + Sparkplug),但没有 Maglev 这样的"半优化"中间层。IonMonkey 直接从 Baseline 跳到深度优化
- IonMonkey 更激进的内联策略:默认允许更深的内联深度(V8 限制更保守)。这意味着 Firefox 在一些高内联场景下可以跑得比 Chrome 更快——但内联失败的去优化代价也更大
- WarpBuilder 是 2021 年引入的新 Baseline Compiler(取代旧的 Baseline Compiler),它直接利用 IonMonkey 的 MIR(中级 IR)来生成 Baseline 代码,使得 Baseline 和 Ion 共享同一套 IR 基础设施——这一点 V8 没有(Ignition/Sparkplug/Maglev/TurboFan 各自有各自的 IR)
# 9.2 JavaScriptCore
JavaScriptCore(Safari 的 JS 引擎,原名 SquirrelFish Extreme)的管线:
JSC 管线(四层):
┌────────────────────────────────────────────────────┐
│ │
│ 源码 → Parser → LLInt (Low-Level Interpreter) │
│ ↓ ← 汇编手写的解释器! │
│ Baseline JIT ← ~30 次调用后 │
│ ↓ │
│ DFG JIT (Data Flow Graph) ← ~1000 次调用后 │
│ ↓ │
│ FTL JIT (Faster Than Light) ← ~10000 次调用后 │
│ ← 基于 LLVM 后端! │
│ │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
与 V8 的关键差异:
- LLInt 是手写汇编的解释器(不是 C++ 写的),与 V8 的 Ignition(C++ 字节码解释器)完全不同。LLInt 直接在汇编层执行——这让它在解释执行的纯速度上比 Ignition 快,但牺牲了跨平台的可移植性
- FTL 基于 LLVM 后端:JSC 的第四层用了 LLVM 的优化和代码生成设施。这意味着 FTL 继承了 LLVM 十年积累的优化 pass(如向量化、循环展开),这些 TurboFan 也做,但深度不同
- DFG 是 FTL 的前置过滤器:大部分函数在 DFG 层已经获得了可观的加速,只有极少数函数会升到 FTL——因为 LLVM 的编译时间即使在 2025 年仍然不菲
# 9.3 为什么同一段代码
回到 §1.1 中 Chrome vs Firefox vs Safari 的 3 倍速度差——现在我们能给出结构性的解释了:
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ 优化维度 │ V8 (Chrome) │ SpiderMonkey │ JSC (Safari) │
│ │ │ (Firefox) │ │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 解释执行速度 │ 中等(Ignition)│ 中等(解释器) │ 最快(LLInt) │
│ │ C++实现 │ C++实现 │ 手写汇编 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 快速编译速度 │ 最快(Sparkplug)│ 中等(Baseline) │ 中等(Baseline)│
│ │ 无IR、直译 │ 有轻量IR │ 有轻量IR │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 中优编译层 │ ✅ Maglev │ ❌ 无 │ ✅ DFG │
│ │ SSA轻量优化 │ 直接跳到深度 │ 数据流优化 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 深度优化层 │ TurboFan │ IonMonkey │ FTL (LLVM) │
│ │ Sea-of-Nodes │ MIR + LIR │ LLVM IR │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 内联激进程度 │ 保守 │ 极激进 │ 中等 │
│ │ 减少去优化频率 │ 追求峰值性能 │ 折中 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 短函数场景 │ 最快 │ 快 │ 最快 │
│ (React组件) │ Maglev刚好命中 │ Baseline够用 │ DFG刚好命中 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ 长循环场景 │ 中等 │ 最快 │ 快 │
│ (数据处理) │ TurboFan较保守 │ IonMonkey激进 │ FTL向量化强 │
└──────────────┴──────────────┴──────────────┴──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
根本原因:三个引擎在编译激进程度和优化质量之间做了不同取舍。这没有对错——它直接决定了每个引擎在特定代码形态下的表现:
- Chrome 的目标:Web 应用(大量短函数、高交互频率) → Maglev 的 30ms 编译 + 180ms 执行在 React/Vue 场景下是黄金组合
- Firefox 的目标:大量数据处理 + 多媒体(ASM.js 的历史遗产) → IonMonkey 的激进内联在纯计算密集型代码上仍是最强
- Safari 的目标:低功耗移动设备(macOS/iOS) → LLInt 的手写汇编降低了第一次执行的能耗,DFG 提供温和的中度加速,FTL 只在绝对必要时启动
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到 §1 的六个疑问——逐一作答:
① 解析:const a = 1 + 2 先经过 Scanner 切成 7 个 Token,再经 Parser 构建为一棵 AST,树根是 VariableDeclaration 节点。整个过程耗时约 10~50 微秒(取决于源码长度)。
② 字节码:V8 不直接从 AST 生成机器码,因为 AST 携带的是"语义结构"而非"执行序列"。Ignition 把 AST 译成了约 200~300 种字节码(LdaConstant、Add、Return 等),每种字节码在 C++ 中对应一段解释执行逻辑。字节码还充当了"情报收集"的载体——每条指令执行时顺手写入 Feedback Vector。
③ Sparkplug:2021 年加入的快速编译器,5ms 编译 100KB JS,不做任何优化,直接字节码→机器码。它填补了"解释太慢、TurboFan 编译太贵"之间的空白。网页中 80% 的代码只跑到 Sparkplug 就够了。
④ Maglev:2023 年加入的中间编译器,30ms 编译,做 SSA 轻量优化。它填补了"Sparkplug 不够快、TurboFan 编译太贵"之间的又一个空白——React render/Vue computed 这类"半热"函数正好用上。
⑤ TurboFan:深度优化编译器,Sea-of-Nodes IR + 20+ 轮优化 pass + Feedback Vector 情报驱动。它做出了大量"假设"来生成极快代码,但当假设被打破时会触发去优化——保存寄存器、重建解释器帧、退回 Ignition。
⑥ 引擎差异:SpiderMonkey 的 IonMonkey 更激进(追求峰值)、JSC 的 FTL 借力 LLVM(追求向量化)、V8 靠 Maglev 命中大前端生态的"短函数"形态。没有"最好的引擎",只有"最匹配你的代码形态的引擎"。
# 10.2 一个 const
让我们追踪一行现实的代码——我们每天写了无数次的 add 函数——它从诞生到被 TurboFan 优化的完整旅程:
时刻 0: 源码加载
┌─────────────────────────────────────────────┐
│ 'const add = (a,b) => a + b' │
│ → Scanner 扫描 → [CONST, IDENT(add), │
│ ASSIGN, LPAREN, IDENT(a), │
│ COMMA, IDENT(b), RPAREN, │
│ ARROW, IDENT(a), ADD, IDENT(b)] │
│ → PreParser 快速扫过(不建 AST) │
└─────────────────────────────────────────────┘
时刻 1: 第一次调用 add(1, 2)
┌─────────────────────────────────────────────┐
│ Parser 建立完整 AST │
│ → Ignition 生成字节码: │
│ LdaOrUndef a0 // 读参数 a │
│ Star r2 │
│ LdaOrUndef a1 // 读参数 b │
│ Star r3 │
│ Add r2, r3 // 执行 a + b │
│ Return │
│ → Ignition 逐条解释执行(耗时 ~0.5μs) │
│ → Feedback Vector 记录: │
│ Add 指令:a 的类型 = Smi, b 的类型 = Smi │
└─────────────────────────────────────────────┘
时刻 20: add 被调用了约 20 次(类型始终是 number + number)
┌─────────────────────────────────────────────┐
│ → Sparkplug 触发编译(5ms) │
│ → 字节码直译为 x86-64: │
│ mov rax, rdi // rdi = a │
│ add rax, rsi // rsi = b │
│ ret │
│ → 执行速度:~0.05μs(比 Ignition 快 10 倍) │
│ → 继续收集 Feedback │
└─────────────────────────────────────────────┘
时刻 1000: add 被调用了约 1000 次
┌─────────────────────────────────────────────┐
│ → Maglev 触发编译(30ms) │
│ → 利用 Feedback Vector: │
│ "a 是 Smi 的概率 99.7%,b 是 Smi 的概率 99.7%" │
│ → 做 SSA 优化: │
│ 跳过 ToNumber 转换检查 │
│ 把 a + b 直接映射到 add rax, rsi │
│ 但如果 a 不是 Smi → 通过去检查点回退 │
│ → 执行速度:~0.03μs │
└─────────────────────────────────────────────┘
时刻 5000: add 被调用了约 5000 次
┌─────────────────────────────────────────────┐
│ → TurboFan 触发编译(450ms) │
│ → Sea-of-Nodes IR: │
│ "这个函数极其简单——体量只有 a+b—— │
│ 把它内联到每一个调用点" │
│ → 所有调用 add 的地方, │
│ 不再执行 'call add' → 直接展开为 add rax, rsi │
│ → 执行速度:~0.01μs(比解释执行快 50 倍) │
│ → 加上内联省下的调用开销, │
│ 在循环中累加的性能提升可达 100 倍以上 │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 10.3 设计哲学回扣
从这段旅程中可以提炼出三条贯穿整条 V8 管线的设计哲学:
哲学一·「热的才值得编译」——分层即妥协
V8 花了几十年从"两层"演进到"四层",每一层都不是凭空加的——Sparkplug(2021)和 Maglev(2023)解决了真实生产环境中的"断层"问题。这告诉我们:编译器的设计不是追求"每次都能产生最快的代码",而是 "每次都为当前场景产生最值的代码"。大部分代码不值得 TurboFan 的 450ms 编译代价——Sparkplug 用 5ms 省掉的 500ms 执行时间已经足够让用户感知到快了。
哲学二·「先跑再优化」——情报驱动编译
JS 是一门动态类型语言。在静态时候(源码阶段)几乎无法知道 a 是 number 还是 string。V8 的策略是:先跑起来(Ignition),边跑边记录(Feedback Vector),记录够了再优化。这个"先跑再优化"的模式没有静态类型系统的前置成本,却能在运行时获得接近静态类型的执行效率——这是 JS 类语言能追平 Java/C# 性能的根本原因。
哲学三·「假设可以被打破」——去优化是工程的保险丝
TurboFan 做出的每一个假设("a 是 Smi"、"double 没被替换"、"隐藏类没变")都带有一个去优化检查点。这让编译器可以做出最激进的假设而不必担心"万一错了怎么办"——万一错了,去优化兜底。这种"乐观假设 + 悲观回退"的双轨设计让 V8 在优化激进性和正确性之间找到了一条极其巧妙的平衡线。
# 10.4 速查表速览
V8 四层编译全对比:
| 指标 | Ignition | Sparkplug | Maglev | TurboFan |
|---|---|---|---|---|
| 层级 | 1(解释) | 2(快编) | 3(中优) | 4(深优) |
| 引入版本 | V8 5.9 (2017) | V8 9.1 (2021) | V8 11.4 (2023) | V8 5.9 (2017) |
| 编译耗时 (100KB JS) | 0 | ~5ms | ~30ms | ~450ms |
| 执行速度 (相对 Ignition) | 1× | ~10× | ~20× | ~50× |
| IR 形式 | 无(仅字节码) | 无(直译) | SSA 轻量 | Sea-of-Nodes |
| 核心优化 | 无 | 无 | 类型推断、简单内联、轻度逃逸 | 全部(隐藏类推断、深内联、GVN、LICM、向量化...) |
| 去优化支持 | 不需要 | 不支持(去优化回 Ignition) | 支持 | 支持 |
对 JIT 友好的编码习惯:
| 习惯 | 为什么对 JIT 友好 | 反例 |
|---|---|---|
| 保持对象形状一致 | 相同隐藏类 → 单态 Feedback → TurboFan 极快路径 | 动态删加属性,导致隐藏类分裂 |
| 保持参数类型一致 | 稳定的 Smi/Number 类型 → 跳过类型检查 | fn(a) 第一次 a=1,第二次 a='hello' → 去优化 |
| 不修改内置原型 | Array.prototype 被修改 → 引擎必须放弃大量内建优化假设 | Array.prototype.myMethod = ... |
避免 arguments 对象 | 在严格模式下或使用剩余参数 (...args) → 引擎不需要创建类数组对象 | 非严格模式 + arguments[0] |
避免 eval / with | eval 阻止 V8 做任何作用域相关优化(变量可能来自任意字符串) | eval('var x = ' + userInput) |
try/catch 放在函数最外层 | 被 try 包裹的代码块,TurboFan 会减少优化激进程度 | 性能关键路径的循环体放在 try 块内 |
下一步:知道了代码如何执行之后,下一个问题——代码里创建的对象,在内存里长什么样?进入 02.隐藏类与分代垃圾回收。