跳到主要内容
预计阅读 35 分钟

JavaScript引擎 — V8 如何把你的代码变成机器能跑的指令

JavaScript 是一门解释型语言——但这个说法在 V8 面前只对了一半。V8 使用了即时编译(JIT)技术,把频繁执行的代码直接编译成机器码。理解 V8 的工作原理,能帮你写出”对引擎友好”的高性能代码。

📋 开篇自测:你已经知道多少?

  1. JavaScript 代码在浏览器中是如何被执行的?是逐行解释还是编译后执行?
  2. 什么是隐藏类(Hidden Class)?为什么动态添加属性会影响性能?
  3. V8 的 Ignition 和 TurboFan 分别是什么?它们如何协作?

一、JavaScript 引擎概览

1.1 主流 JavaScript 引擎

不同浏览器使用不同的 JavaScript 引擎:

浏览器JS 引擎特点
Chrome / EdgeV8最广泛使用,也用于 Node.js
FirefoxSpiderMonkeyMozilla 开发,最早的JS引擎
SafariJavaScriptCore (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 编译为机器码,跳过了字节码阶段。这样做执行速度快,但有两个问题:

  1. 编译耗时长:首次加载页面时,大量 JavaScript 都需要编译,导致启动慢
  2. 内存占用大:机器码比字节码体积大得多

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种隐藏类)
    └── 巨态: 最慢 (放弃缓存)

📝 结尾自测:检验你的学习成果

  1. V8 的三个核心组件(Parser、Ignition、TurboFan)各自的职责是什么?它们如何协作?
  2. 什么是惰性解析?它对页面启动性能有什么帮助?
  3. 隐藏类是如何加速属性访问的?为什么建议在构造函数中一次性初始化所有属性?
  4. 内联缓存的三种状态(单态、多态、巨态)分别对应什么场景?性能差异有多大?
  5. 什么是去优化(Deoptimization)?什么样的代码模式容易导致反复去优化?

下一章预告:V8 执行 JavaScript 代码时,遇到异步操作(setTimeoutPromisefetch)怎么办?下一章我们将深入浏览器的事件循环机制,彻底搞清楚调用栈、任务队列、微任务和宏任务的协作关系。

购买课程解锁全部内容

前端进阶第一课:11 章掌握浏览器核心

¥29.90