让动态世界拥有确定性(一):V8 如何把动态代码跑出静态速度
Hidden Class、Inline Cache 与 TurboFan 如何协作,让 obj.name 从属性查找变成接近内存偏移访问
JavaScript 最大的特点是动态。
对象可以随时添加属性:
const user = {};
user.name = "Tom";
user.age = 20;
变量类型可以随时变化:
let value = 1;
value = "hello";
这种灵活性带来了开发效率,但也给运行时优化带来了挑战。
传统静态语言中:
struct User {
char* name;
int age;
};
编译器在编译阶段就知道:
- 对象有哪些字段
- 字段在哪里
- 如何访问
而 JavaScript 在运行之前,并不知道这些信息。
那么问题来了:
一个完全动态的语言,为什么可以运行得这么快?
答案是:
JavaScript 引擎会主动寻找动态世界中的确定性。
V8 通过一套三层机制,把动态代码优化成接近静态代码的执行方式:
Hidden Class → 对象结构确定性
Inline Cache → 访问路径确定性
TurboFan JIT → 机器码生成确定性
下面一层一层拆。
一、Hidden Class:对象结构确定性
在 JavaScript 中:
const user = {
name: "Tom",
age: 20
};
开发者看到的是一个普通对象。
但是 V8 内部会给这个对象关联一个隐藏结构:
Map(User)
name -> offset 0
age -> offset 1
当访问:
user.name
V8 不需要每次都搜索属性。
它可以通过对象的 Map 知道:
user 当前结构是 Map(User)
name 在 offset 0
然后直接读取对应位置。
这类似于静态语言中的 struct:
struct User {
char* name;
int age;
};
提前知道字段的位置。
Hidden Class 本质上是 V8 给动态对象维护的一份“结构描述信息”,让运行时可以像处理静态结构一样快速访问属性。
对象结构稳定,V8 更容易优化
例如:
function User(name, age) {
this.name = name;
this.age = age;
}
const a = new User("Tom", 20);
const b = new User("Bob", 30);
两个实例拥有相同的属性创建顺序:
{}
|
name
|
name + age
因此它们可以共享同一个 Hidden Class。
但是:
const a = {};
a.name = "Tom";
a.age = 20;
const b = {};
b.age = 30;
b.name = "Bob";
虽然最终结果一样:
{
name,
age
}
但是创建过程不同:
对象 a:
{}
|
name
|
name + age
对象 b:
{}
|
age
|
age + name
可能产生不同的 Hidden Class。
对于 JavaScript 引擎来说,结构越稳定,越容易优化。
Hidden Class Transition
V8 不会为每个对象单独创建一个 Map。
它用的是一种链式转换机制:
Map0 (空对象)
│
├─ 添加 name ──→ Map1 { name }
│ │
│ ├─ 添加 age ──→ Map2 { name, age }
│
└─ 添加 age ──→ Map3 { age }
│
└─ 添加 name ──→ Map4 { age, name }
如果两个对象走的是同一条转换路径(先加 name,再加 age),它们就会共享同一个 Map。
这意味着:
V8 不需要为每个对象存储完整的结构信息,只需要记录“从哪个 Map 转换到哪个 Map”。
一个 Map 对象只占几十字节,但可以被成千上万个对象共享。
属性顺序的影响
这解释了一个常见的性能建议:
// ✅ 好:属性顺序一致,共享同一个 Map
const a = { name: "Tom", age: 20 };
const b = { name: "Bob", age: 30 };
// ❌ 差:属性顺序不一致,可能产生不同 Map
const c = { name: "Tom", age: 20 };
const d = { age: 30, name: "Bob" };
对于静态语言,属性顺序无关紧要,编译器已经确定了内存布局。
但对于 V8,属性顺序决定了 Map 转换路径,影响优化效果。
二、Inline Cache:访问路径确定性
Hidden Class 解决了“对象结构”的问题,但还有一个问题没解决:
如果每次访问
user.name都要查 Map → 找 offset → 读内存,那和哈希表查找有什么区别?
答案是 Inline Cache(内联缓存,简称 IC)。
IC 的核心思想
V8 观察代码运行过程,把属性访问的结果缓存在调用点旁边。
function getName(obj) {
return obj.name;
}
getName(user1); // 第一次:查找 Map → 找到 offset 0 → 缓存
getName(user2); // 第二次:检查 Map 相同 → 直接用缓存的 offset
getName(user3); // 第三次:同上,直接用
用伪代码表示 IC 的工作方式:
getName 函数的调用点旁边:
┌─────────────────────────────┐
│ IC Cache: │
│ Map: Map(User) │
│ offset: 0 │
│ value: "Tom" (last seen) │
└─────────────────────────────┘
第一次调用:
1. 读取 obj 的 Map
2. Map == Map(User)? → 没有缓存,查找属性
3. 找到 offset = 0
4. 写入 IC Cache
第二次调用:
1. 读取 obj 的 Map
2. Map == Map(User)? → 命中缓存!
3. 直接用 offset = 0 读取
IC 的三种状态
V8 内部把 IC 分为几个阶段,代表不同的优化程度:
Uninitialized
↓
Monomorphic
↓
Polymorphic
↓
Megamorphic
简单理解:
| 状态 | 含义 | 优化程度 |
|---|---|---|
| Uninitialized | 还没执行过 | 无 |
| Monomorphic | 只命中过一个 Map | 最高 |
| Polymorphic | 命中过 2-4 个 Map | 中等 |
| Megamorphic | 命中过 5+ 个 Map | 退化为通用查找 |
Monomorphic 是最理想的状态——V8 确定这个调用点只会看到一种对象结构,可以直接用固定偏移访问。
Monomorphic 是最理想的状态——V8 确定这个调用点只会看到一种对象结构,可以直接用固定偏移访问。
这也是为什么:
// ✅ Monomorphic:同一个函数只接收同结构的对象
function processUser(user) {
return user.name; // IC 命中 Monomorphic
}
// ❌ Megamorphic:同一个函数接收各种结构的对象
function process(obj) {
return obj.name; // IC 可能退化
}
process(user);
process(product);
process(order);
process(config);
IC 与 Hidden Class 的协作
两者配合形成完整的优化闭环:
Hidden Class:
对象 → Map(User) → { name: offset 0, age: offset 1 }
Inline Cache:
调用点 → 记录 Map + offset → 下次直接跳过查找
协作效果:
user.name
→ 读 obj 的 Map(O(1))
→ Map == 缓存的 Map?(O(1) 比较)
→ 是:直接读 offset(O(1))
→ 否:重新查找(fallback)
这已经很快了,但还不是最快的。
最快的是:完全跳过属性查找,直接生成内存偏移的机器码。
这就是 TurboFan 的工作。
三、TurboFan:机器码生成确定性
TurboFan 是 V8 的优化编译器。
它的工作是:把频繁执行的 JavaScript 函数编译成高度优化的机器码。
JIT 编译的基本逻辑
V8 的代码执行分两个阶段:
源代码
↓
Ignition(字节码解释器)← 快速启动,但执行慢
↓ 被频繁调用
TurboFan(优化编译器)← 编译慢,但执行快
当一个函数被反复调用,V8 认为“这段代码值得优化”,就把它交给 TurboFan。
TurboFan 如何利用 Hidden Class + IC
TurboFan 编译时,会读取 IC 收集的类型反馈信息:
IC 记录:
getName 的 obj.name 调用点
→ 一直命中 Map(User)
→ name 在 offset 0
TurboFan 生成的机器码(概念化表示):
// 不再是通用的属性查找
// 而是直接的内存操作
result = *(obj + 8) // offset 0,跳过对象头
对比:
原始 JavaScript: obj.name
隐式执行:
1. 读取 obj 的 Map
2. 在 Map 中查找 "name" 属性
3. 获取 offset
4. 计算内存地址
5. 读取值
TurboFan 优化后:
1. 直接从 obj + offset 读取值
从通用属性查找路径,变成针对已知结构的快速访问路径。
如果类型变了怎么办?
TurboFan 假设 IC 记录的类型会持续成立。
但如果假设被打破:
function getName(obj) {
return obj.name;
}
// 一直都是 User 对象
getName(user1);
getName(user2);
getName(user3);
// 突然传了一个 Product 对象
getName(product); // Map 不同!
V8 会执行 Deoptimization(去优化):
TurboFan 生成的机器码 → 作废
回退到 Ignition 字节码执行
重新收集类型信息
可能重新生成优化代码
去优化是有代价的——所以频繁改变对象结构或传入不同类型的对象,会导致性能下降。
TurboFan 的其他优化手段
除了利用 Hidden Class + IC 的信息,TurboFan 还会做:
内联(Inlining):
smallFunc() 的代码直接嵌入调用者,省去函数调用开销
逃逸分析(Escape Analysis):
如果一个对象只在函数内部使用,不逃逸到外部
→ 可以直接在寄存器或栈上分配,省去堆分配
常量折叠(Constant Folding):
if (x > 0) { return 1 + 2; }
→ 编译时直接计算为 3
死代码消除(Dead Code Elimination):
永远不会执行的代码 → 直接删掉
这些优化的前提都是:V8 对代码行为有足够高的确定性。
三者如何协作
把三层放在一起看:
JavaScript 源代码
const user = { name: "Tom", age: 20 };
↓ V8 第一层:Hidden Class
对象被关联到 Map(User):
{ name: offset 0, age: offset 1 }
↓ V8 第二层:Inline Cache
getName(user.name) 的调用点缓存了:
Map = Map(User), offset = 0
↓ V8 第三层:TurboFan
TurboFan 看到稳定的类型反馈,生成:
result = *(user + 8) // 直接内存偏移访问
每一层都在做同一件事:
观察动态行为,记录确定性模式,然后利用这个模式做针对性优化。
总结
JavaScript 本身没有结构。
但 V8 会主动寻找确定性:
- 如果一个对象长期保持相同结构 → Hidden Class 共享
- 如果一个属性访问长期命中相同类型 → IC 缓存
- 如果一段代码长期表现稳定 → TurboFan 生成优化机器码
动态语言的性能,本质上来自:
在运行时寻找确定性。
这也是 V8 的设计哲学:不预设你的代码会怎么写,但会观察你的代码怎么跑,然后用观察到的“规律”来加速。
下一篇
V8 在运行时偷偷给对象加了“类型”,让动态代码跑出了接近静态代码的速度。
但引擎的优化有上限——代码写得烂,神仙也救不了。
下一章我们换个视角,看 React 如何反过来:不是引擎优化你的代码,而是要求你按它的规则写,它来帮你调度更新。
《让动态世界拥有确定性(二):React 如何让 UI 更新变得可调度》
V8 优化 代码执行
React 优化 任务调度
Vue Vapor 优化 代码生成