编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
        • 1. 案例与疑问引入
          • 1.1 一行代码在三引擎
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 V8 全管线总图
          • 2.2 为什么分层三阶
        • 3. 解析字符到AST
          • 3.1 Scanner
          • 3.2 PreParser
          • 3.3 Parser
          • 3.4 IIFE 的解析
        • 4. Ignition
          • 4.1 寄存器机 vs
          • 4.2 核心字节码指令表
          • 4.3 类型反馈向量
        • 5. 快速编译层详解
          • 5.1 非优化 JIT
          • 5.2 字节码直译成机器码
        • 6. 中间层编译器
          • 6.1 为什么在 Ign
          • 6.2 SSA 中间表示
        • 7. 优化编译器详解
          • 7.1 Sea-of-Nodes
          • 7.2 关键优化:隐藏类
          • 7.3 去优化(Deop
        • 8. 分层与OSR
          • 8.1 四层编译的升降级
          • 8.2 On-Stack
        • 9. 三引擎横向对比
          • 9.1 SpiderMonkey
          • 9.2 JavaScriptCore
          • 9.3 为什么同一段代码
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个 const
          • 10.3 设计哲学回扣
          • 10.4 速查表速览
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

引擎解析编译执行

# 01.引擎解析编译执行

📍 本专栏开篇。我们将从「一行 JS 代码到底变成了什么」出发,拆开 V8 从源码字符串到机器指令的完整管线。

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一行代码在三引擎
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 V8 全管线总图
    • 2.2 为什么分层三阶
  • 3. 解析字符到AST
    • 3.1 Scanner
    • 3.2 PreParser
    • 3.3 Parser
    • 3.4 IIFE 的解析
  • 4. Ignition
    • 4.1 寄存器机 vs
    • 4.2 核心字节码指令表
    • 4.3 类型反馈向量
  • 5. 快速编译层详解
    • 5.1 非优化 JIT
    • 5.2 字节码直译成机器码
  • 6. 中间层编译器
    • 6.1 为什么在 Ign
    • 6.2 SSA 中间表示
  • 7. 优化编译器详解
    • 7.1 Sea-of-Nodes
    • 7.2 关键优化:隐藏类
    • 7.3 去优化(Deop
  • 8. 分层与OSR
    • 8.1 四层编译的升降级
    • 8.2 On-Stack
  • 9. 三引擎横向对比
    • 9.1 SpiderMonkey
    • 9.2 JavaScriptCore
    • 9.3 为什么同一段代码
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个 const
    • 10.3 设计哲学回扣
    • 10.4 速查表速览

# 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
1
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 的专属优化路径更短)
1
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 的整条执行管线追问以下六个问题——每个问题对应中间的一章:

  1. ① 解析:const a = 1 这个字符串是怎么变成 V8 能理解的树状结构的?
  2. ② 字节码:V8 为什么不直接把 AST 翻译成机器码,中间非要夹一层"字节码"?
  3. ③ 快速编译:2021 年 V8 为什么要加 Sparkplug——解释器 Ignition 不够快吗?
  4. ④ 中间层:2023 年 V8 为什么又在 Ignition 和 TurboFan 之间塞了 Maglev——两层不够吗?
  5. ⑤ 激进优化:TurboFan 做出了什么"假设"才能把 JS 优化得这么快?假设错了怎么办?
  6. ⑥ 引擎差异: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 → 隐藏类推断 → 内联 →     │    │
│         │  │  逃逸分析 → 循环优化 → 指令选择              │    │
│         │  └─────────────────────────────────────────────┘   │
└─────────┴───────────────────────────────────────────────────┘
1
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 深度编译(最大加速,编译较慢)
  → 结论:编译开销和运行时加速的帕累托最优
1
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]
1
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}` → 遇到 ${ → 暂停字符串扫描 → 递归进入表达式扫描
1
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/...
1
2
3
4
5
6
7
8
9
10
11

如果启动时就把所有 50 个函数全部解析成 AST(并生成对应的字节码),V8 会在启动阶段消耗大量时间和内存——而这些函数可能整个会话都不会被调用。

V8 的解决方案是 PreParser(预解析器,src/parsing/preparser.h):

┌─────────────────────────────────────────┐
│           某个函数首次被扫描              │
├─────────────────────────────────────────┤
│                                         │
│  ┌─────────────┐                        │
│  │  PreParser   │ ← 只扫描,不建完整 AST │
│  │  (轻量)      │   记录:函数边界、变量名、 │
│  │             │   作用域信息、可能的语法错误  │
│  └──────┬──────┘                        │
│         │ 函数被调用时                     │
│  ┌──────▼──────┐                        │
│  │   Parser     │ ← 建立完整 AST           │
│  │   (完整)     │   把源码逐块解析成节点      │
│  └─────────────┘                        │
│                                         │
└─────────────────────────────────────────┘
1
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    │
// └──────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Parser 做的是递归下降解析(Recursive Descent Parsing)——每个语法结构(表达式、语句、函数声明、类声明)都有对应的解析函数,这些函数互相调用,形成一棵"解析调用"树,构造出来的正是 AST。

V8 的 Parser 有几个重要的优化:

  1. 单遍解析(Single-Pass):大部分情况下,Parser 在解析的同时就生成 AST,不会先产生中间表示再转译
  2. 字符串驻留(String Interning):所有标识符(如变量名、函数名)被存入一个全局字符串表,add 出现了 100 次也只存一份
  3. 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
1
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 省下的时间
    })();
  }
};
1
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 次栈操作               │
└─────────────────────────────────┘  └─────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

论证:

  1. 指令条数:栈机需要更多指令来表达同一个语义(因为每次读取操作数都需要额外的 load/store 指令隐式通过栈)
  2. 寄存器机用虚拟寄存器:Ignition 维护了一个寄存器文件(r0, r1, r2...),一条 Add 指令直接指定"从哪读到哪",不需要压栈弹栈
  3. 但寄存器指令更长:栈机指令用一个字节就能编码(操作数在栈上,不需要编码到指令中),寄存器指令需要额外字节编码"源寄存器"和"目标寄存器"

实测证据: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                ← 返回累加器中的结果
1
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 在对象内的偏移位置)
1
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 放弃优化——"什么都可能来,我直接用字典查找"
1
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!
└─────┴──────────┴──────────┴──────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

论证:

  1. TurboFan 的编译太贵了(450ms)——它要对 IR 做 20+ 轮优化 pass,每轮都要重写 IR 图
  2. Sparkplug 的编译极便宜(5ms)——它不做任何优化,就是把字节码一条一条"翻译"成对应的 x86-64 指令
  3. 大部分 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!                              │
│  直接从字节码发射机器指令——                              │
│  就像"逐字翻译",不是"先理解再重写"                       │
└──────────────────────────────────────────────────┘
1
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 编译开销        │
└──────────────────────────────────────────────────┘
1
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%                 │
│                                                        │
│  "够热、但不极热"的代码现在有了平滑的过渡路径                    │
└──────────────────────────────────────────────────────────┘
1
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 各赋值一次
1
2
3
4
5
6
7
8

为什么这对优化至关重要?因为每个 xn 的定义位置是唯一的。编译器可以直接从 x4 回溯到 x3,再回溯到 x2,再回溯到 x1——这个"使用-定义链"(use-def chain)让以下优化变得极其简单:

  1. 常量折叠(Constant Folding):如果 x1 = 1,x2 = 1 + 2 → 直接算出 x2 = 3
  2. 死代码消除(DCE):如果 x3 从未被使用 → 删除 y = x * 3 这整行
  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    │
    └───────────────┘  └───────────┘

关键属性:
- 没有"基本块"的概念——调度由后端的"指令选择"阶段自由决定
- 控制节点只是"约束源",不是"围墙"
- 数据流节点可以自由地在控制流之间移动
1
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
1
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 += ...` 处于同一个编译单元,
//          调度器可以把它们排得更密
1
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 全程操作在寄存器/栈上
1
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!
1
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 继续执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14

去优化发生时:

  1. 停止执行 TurboFan 代码
  2. 保存所有寄存器到内存("冻结"当前执行状态)
  3. 将机器码中的寄存器值映射回"解释器能理解的"变量/寄存器——这个映射表("Frame Translation")在编译时已生成
  4. 切换到 Ignition 模式,从去优化点之后的第一条字节码继续执行
  5. 如果函数后来又被调用很多次,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 ...]
1
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 被保留                   │
│    │   下次再编译时会考虑新的假设                    │
│    │                                           │
└────────────────────────────────────────────────┘
1
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(); // ← 这个调用只发生了一次!
1
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. 否则:继续原路径执行
1
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)             ← 热代码           │
│                                                  │
└────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10

与 V8 的关键差异:

  1. 没有"快速非优化编译器"这个中间层——SpiderMonkey 只有 Baseline Interpreter + Baseline Compiler(对应 V8 的 Ignition + Sparkplug),但没有 Maglev 这样的"半优化"中间层。IonMonkey 直接从 Baseline 跳到深度优化
  2. IonMonkey 更激进的内联策略:默认允许更深的内联深度(V8 限制更保守)。这意味着 Firefox 在一些高内联场景下可以跑得比 Chrome 更快——但内联失败的去优化代价也更大
  3. 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 后端!     │
│                                                  │
└────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

与 V8 的关键差异:

  1. LLInt 是手写汇编的解释器(不是 C++ 写的),与 V8 的 Ignition(C++ 字节码解释器)完全不同。LLInt 直接在汇编层执行——这让它在解释执行的纯速度上比 Ignition 快,但牺牲了跨平台的可移植性
  2. FTL 基于 LLVM 后端:JSC 的第四层用了 LLVM 的优化和代码生成设施。这意味着 FTL 继承了 LLVM 十年积累的优化 pass(如向量化、循环展开),这些 TurboFan 也做,但深度不同
  3. 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向量化强   │
└──────────────┴──────────────┴──────────────┴──────────────┘
1
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 倍以上               │
  └─────────────────────────────────────────────┘
1
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.隐藏类与分代垃圾回收。

上次更新: 2026/06/16, 12:36:20
README
隐藏类与回收机制

← README 隐藏类与回收机制→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式