让动态世界拥有确定性(一):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 优化 代码生成