Chrome偷藏了你的JS!V8引擎到底做了什么?
你有没有想过:为什么 JavaScript 能"秒执行"?你写的
console.log('Hello')到底经历了什么?从 Chrome 偷藏你的代码,到 V8 引擎对你的 JS 做了什么——今天全部揭秘!
原文地址
墨渊书肆/Chrome偷藏了你的JS!V8引擎到底做了什么?
V8 是什么?
JavaScript 引擎
浏览器能执行 JavaScript,全靠 JavaScript 引擎。
常见的引擎有:
- V8 — Chrome、Node.js、Deno 在用
- SpiderMonkey — Firefox 在用
- JavaScriptCore — Safari 在用
- Chakra — 旧版 Edge 在用
V8 是 Google 开发的高性能引擎,用 C++ 编写,让 JS 执行速度可以媲美编译型语言。
V8 的工作流程
你写的 JS 代码,V8 要做的事情很简单:
JS代码 → 解析 → 编译 → 执行
但这中间,V8 做了大量偷跑优化。
V8 架构演进
| 时代 | 架构 | 说明 |
|---|---|---|
| 早期 | Full Codegen → Crankshaft | 快速生成机器码,但维护困难 |
| 现在 | Ignition → TurboFan | 字节码+优化编译器,更高效 |
| 最新 | Ignition + TurboFan + Sparkplug | 新增无解释的 baseline JIT |
代码是怎么跑起来的?
从 JS 到机器码
你写了一段代码:
function add(a, b) {
return a + b;
}
console.log(add(1, 2));
V8 拿到这段代码后,经历了这些阶段:
1. 解析(Parser)
↓
把JS代码变成 AST(抽象语法树)
↓
2. 解释(Ignition)
↓
编译成字节码,立即执行
↓
3. 优化编译(TurboFan)
↓
热代码被编译成高效的机器码
↓
4. 执行
Ignition — 解释器
字节码是什么?
V8 首先用 Ignition 解释器处理代码。
Ignition 会把你的 JS 代码编译成字节码——一种中间代码,比机器码容易生成,但比 JS 容易执行。
// 你写的 JS
function add(a, b) {
return a + b;
}
对应的字节码(简化版):
# 字节码类似这样
LdaSmi [1] # 加载小整数 1
StaA [0] # 存到 [0] 位置(寄存器)
LdaSmi [2] # 加载小整数 2
AddA [0] # 加上 [0] 位置的数
Return # 返回结果
为什么要转字节码?
| 直接执行 JS | 转字节码再执行 |
|---|---|
| 每次都要重新解析 | 字节码更紧凑 |
| 无法优化 | 可以记录执行信息 |
| 启动慢 | 启动更快 |
Ignition 不只解释执行,还会记录信息——哪些函数被调用多次、参数类型是什么。这些信息给后续优化用。
Ignition 的执行反馈
function add(a, b) {
return a + b;
}
add(1, 2); // 第1次:记录类型
add(3, 4); // 第2次:类型一致,继续记录
add("x", "y"); // 第3次:类型变了!记录下来
Ignition 维护一个 Feedback Vector(反馈向量),记录每段代码的类型信息。
TurboFan — 优化编译器
JIT 是什么?
JIT(Just-In-Time)= 即时编译。
不是提前编译好,而是一边执行一边编译。执行次数多的代码,会被更高效的机器码替代。
TurboFan 优化流程
TurboFan 不是直接生成最优机器码,而是层层优化:
字节码 + 执行反馈
↓
Sea of Nodes(中间表示)
↓ 优化 Pass 1: 类型推导
↓ 优化 Pass 2: 内联
↓ 优化 Pass 3: 环路优化
↓ 优化 Pass 4: 寄存器分配
↓
机器码
热代码检测
V8 有一套"热点检测"机制:
function add(a, b) {
return a + b;
}
// 这个函数被调用了10000次
for (let i = 0; i < 10000; i++) {
add(1, 2);
}
调用次数 < 1000
↓
Ignition 解释器执行(字节码)
调用次数 > 1000
↓
TurboFan 优化编译(机器码)
优化与反优化
TurboFan 很聪明,但也有"翻车"的时候:
function add(a, b) {
return a + b;
}
// 前1000次调用,参数都是整数
for (let i = 0; i < 1000; i++) {
add(1, 2); // TurboFan 优化:整数加法
}
// 第1001次,参数变成字符串
add("hello", "world"); // 反优化!退回字节码
TurboFan 发现类型变了,会反优化(Deoptimization),退回字节码。
常见的优化场景
// ✅ 好优化:类型稳定
function length(arr) {
return arr.length; // 数组 length 是稳定的
}
length([1, 2, 3]);
length([4, 5]);
// ❌ 难优化:类型不稳定
function getX(obj) {
return obj.x; // obj 可能是任意类型
}
getX({ x: 1 });
getX("string"); // 字符串没有 x 属性!
隐藏类 — 快速属性访问
对象属性查找
JS 里访问对象属性很快,这要归功于隐藏类(Hidden Class),也叫 Shapes 或 Maps。
const person = { name: 'Tom', age: 18 };
V8 内部会为这个对象创建一个隐藏类:
隐藏类 HC0
├── name: offset 0
└── age: offset 1
属性访问加速原理
当你访问 person.name 时:
// 幕后发生的事情
person.name
→ 通过隐藏类 HC0
→ 直接定位到 offset 0
→ 拿到值 "Tom"
就像图书馆的书有固定编号(隐藏类),管理员知道每本书在哪个书架第几格。
隐藏类转换
对象属性改变时,会产生新的隐藏类:
const obj = { x: 1 };
// ↓ 添加 y
obj.y = 2;
// ↓ 修改 x
obj.x = 10;
HC0: { x: 1 }
↓ 添加 y 属性
HC1: { x: 1, y: 2 }
↓ 修改 x 属性(值变化不改变结构)
HC1(不变)
属性顺序很重要!
// 好:属性顺序一致 → 共享同一个隐藏类
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };
// 差:属性顺序不一致 → 产生多个隐藏类
const p3 = { y: 1, x: 2 }; // 新建 HC1!
多态与全态
// 单态(Monomorphic):一种隐藏类,最快
function getX(obj) { return obj.x; }
getX({ x: 1 }); // HC0
getX({ x: 2 }); // 还是 HC0,命中缓存
// 多态(Polymorphic):2-4种隐藏类,较慢
function getX(obj) { return obj.x; }
getX({ x: 1, a: 0 }); // HC0
getX({ x: 2, b: 0 }); // HC1
// 全态(Megamorphic):5+种隐藏类,最慢
function getX(obj) { return obj.x; }
getX({ ... }); // 每次都是新结构
内联缓存 — 加速函数调用
函数调用有多慢?
函数调用看起来简单:
function getName(user) {
return user.name;
}
const user = { name: 'Tom' };
getName(user);
但每次调用,V8 都要查找 user.name 在哪里。
内联缓存的原理
V8 第一次执行 getName(user) 时:
第1次调用:
1. 查找 user 的隐藏类 → HC0
2. 查找 name 属性在 HC0 的位置 → offset 0
3. 返回结果
4. 记录:HC0 的对象调用这个函数,返回 offset 0
之后调用同样的函数,直接跳过查找:
第2次调用:
1. 检查隐藏类是 HC0 ✓
2. 直接用记录的 offset 0
3. 返回结果
这就是内联缓存(Inline Cache)——把查找结果"缓存"起来。
IC 的类型状态
Uncached → Monomorphic → Polymorphic(2-4) → Megamorphic(5+)
↓ ↓ ↓ ↓
每次查 命中缓存 部分命中 全局查表
垃圾回收 — 内存管理
什么是垃圾?
程序里不再使用的对象就是"垃圾":
function createUser() {
const user = { name: 'Tom' };
return user.name; // user 对象还在用
} // 但 user 变量没了
createUser();
// 之后再也访问不到这个 { name: 'Tom' } 对象了
// 它就成了"垃圾"
V8 的内存布局
┌─────────────────────────────┐
│ 新生代 │ ← 新对象
│ (New Space / Semi-Space) │
├─────────────────────────────┤
│ 老生代 │ ← 存活久的对象
│ (Old Space) │
├─────────────────────────────┤
│ 大对象区 │ ← 无法放入其他区的对象
│ (Large Object Space) │
├─────────────────────────────┤
│ 代码区 │ ← JIT 编译后的机器码
│ (Code Space) │
├─────────────────────────────┤
│ Cell / Map │ ← 特殊对象
│ (Cell / Map Space) │
└─────────────────────────────┘
V8 的垃圾回收策略
V8 采用分代回收:
| 代际 | 对象来源 | 回收频率 | 算法 |
|---|---|---|---|
| 新生代 | 新创建的对象 | 频繁 | Scavenge(复制) |
| 老生代 | 经历一次 GC 仍存活 | 较少 | Mark-Sweep-Compact |
新生代:Scavenge 算法
新生代内存分两半:From 和 To:
┌─────────────────┬─────────────────┐
│ From │ To │
│ (使用中) │ (空闲) │
└─────────────────┴─────────────────┘
1. From 满了,存活对象复制到 To
2. From 清空
3. From 和 To 交换
晋升:经历两次 Scavenge 仍存活的对象,会进入老生代。
老生代:Mark-Sweep-Compact
步骤1:标记(Mark)
遍历所有根对象(全局变量、栈上变量)
↓
标记能访问到的对象为"存活"
↓
没被标记的就是垃圾
步骤2:清除(Sweep)
回收没有标记的对象的内存
步骤3:压缩(Compact)
存活对象移动到一起
↓
解决内存碎片问题
增量 GC
为了避免长时间停顿(Stop-The-World),V8 使用增量标记:
传统 GC:
████████████████████████████ 100% 停顿
执行时间 ←────────────────→
增量 GC:
███ ████ ███ ██
↓ ↓ ↓ ↓
执行 执行 执行 执行
Orinoco — 并行与并发 GC
现代 V8 使用更先进的 GC 算法:
| 技术 | 说明 | 效果 |
|---|---|---|
| 并行 GC | GC 多线程并行执行 | 充分利用多核 CPU |
| 增量 GC | GC 分多次小步执行 | 减少停顿时间 |
| 并发 GC | GC 与 JS 执行同时进行 | 几乎无停顿 |
深入了解 V8 🔬
V8 执行流程全图
JS代码
↓ Parser
AST(抽象语法树)
↓ Ignition
字节码 + Feedback Vector(反馈向量)
↓ (热代码触发)
TurboFan
↓
优化机器码
↓ (类型不稳定)
反优化 → 退回字节码
为什么 V8 这么快?
| 优化手段 | 作用 |
|---|---|
| JIT 即时编译 | 热代码用机器码执行 |
| 隐藏类 | 对象属性快速访问 |
| 内联缓存 | 函数调用加速 |
| 分代回收 | 高效内存管理 |
| 懒解析 | 延迟解析,只解析用到的 |
| 并行 GC | 多核加速垃圾回收 |
Sparkplug — 无解释的 Baseline JIT
V8 最近引入了 Sparkplug,一个超快的 baseline JIT:
之前:JS → Ignition 字节码 → TurboFan 机器码
现在:JS → Ignition 字节码 → Sparkplug 机器码 → TurboFan 优化机器码
Sparkplug 不做任何优化,直接把字节码转成机器码,比 Ignition 快 2-5 倍。
TurboFan 优化的代码例子
// 优化前:字节码执行
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
// 优化后:TurboFan 可能生成的机器码
// 1. 使用寄存器代替变量
// 2. 循环展开(Loop Unrolling)
// 3. 预取数据到 CPU 缓存
编写高性能 JS
// ✅ 好:保持属性类型一致
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };
// ✅ 好:避免类型变化
function add(a, b) {
return a + b;
}
add(1, 2); // 都是整数
add(3.14, 2.86); // 都是浮点数
// ❌ 差:属性顺序不一致
const a = { x: 1, y: 2 };
const b = { y: 1, x: 2 }; // 新建隐藏类!
// ❌ 差:类型乱变
function example(x) {
return x.value; // x 可能是对象,可能是 undefined
}
// ✅ 好:使用固定形状的对象
const cache = {};
for (let i = 0; i < 1000; i++) {
cache.key = i; // 每次都用相同的 key
}
V8 性能陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 隐藏类爆炸 | 对象结构不一致 | 保持属性顺序一致 |
| 类型不稳定 | 参数类型经常变化 | 使用多态函数时要小心 |
| 内存泄漏 | 闭包引用大量对象 | 及时解除引用 |
| 大对象 | 大数组、大对象放新生代 | 手动管理或拆分 |
总结
| 概念 | 作用 | 比喻 |
|---|---|---|
| Ignition | 解释器,生成字节码 + 记录反馈 | 同声传译先听懂意思 |
| TurboFan | 优化编译器,生成高效机器码 | 翻译稿润色升级 |
| JIT | 即时编译,热代码加速 | 多次练习后越说越溜 |
| 隐藏类 | 快速属性访问 | 图书馆编号系统 |
| 内联缓存 | 函数调用加速 | 记住常走的路 |
| 分代回收 | 高效内存管理 | 新书放前台,旧书放仓库 |
| Sparkplug | 超快 baseline JIT | 不用练习,直接上岗 |
写在最后
现在你知道了:
- V8 不是直接执行 JS,而是经过 Parser → Ignition → TurboFan
- JIT 让热代码越来越快,但类型变化会导致反优化
- 隐藏类和内联缓存,是 JS 快的秘密
- 写代码时保持类型一致,能帮助 V8 优化
- 新生代用复制算法,老生代用标记清除
下次有人说"JS 慢",你可以理直气壮地说:你了解 V8 吗?