本节目标:把 TypeScript 的类型声明放到一边,去看真正执行代码的那台机器。读完你会知道 V8 为什么要采集「类型反馈」、隐藏类与内联缓存如何让属性访问变快、TurboFan 的推测式优化在什么条件下会失效,并能读懂
--trace-deopt的输出。
8.1 V8 类型反馈与 JIT
前七章我们都在「编译期」这一侧打转:类型怎么推导、怎么化简、怎么生成 .d.ts、怎么被编辑器消费。但类型在运行时会整体消失——这是 1.2 类型擦除与运行时边界
已经讲过的结论。程序跑到最后,交给 CPU 的只有 JavaScript,你写下的每一个类型标注对 V8 来说都不存在。
那性能从哪来?答案不是类型,而是运行时观测到的类型反馈。V8 在执行过程中记录「这个函数上次收到的参数是什么形状」,据此生成带假设的机器码。理解这条链路,是写出高性能 TypeScript 的前提。
8.1.1 三段式执行管线
V8 执行一段 JavaScript 并非一步到位,而是分成三个阶段:
| 阶段 | 组件 | 产物 | 特点 |
|---|---|---|---|
| 解析 | Parser / Pre-parser | AST | 惰性解析函数体,先扫一遍函数声明 |
| 执行 | Ignition(解释器) | 字节码 | 启动快、体积小,同时采集类型反馈 |
| 优化 | TurboFan(优化编译器) | 机器码 | 按反馈做推测优化,命中则极快 |
这种「解释器 + 优化编译器」的双层结构并非 V8 独创,几乎所有现代语言运行时都是这个思路,想横向对比可以延伸阅读 解释器与 JIT 编译 。
关键认知是:Ignition 不只是执行器,它还是一个探针。它在跑字节码的同时,把每个操作数实际观察到的类型写进一张反馈表(Feedback Vector)。TurboFan 编译时读的就是这张表——所以优化编译器看到的不是源码里的类型,而是运行时的行为统计。
用一条命令就能看到字节码长什么样:
node --print-bytecode --print-bytecode-filter=add add.js
输出里最值得注意的是这一行:
LdaNamedProperty a0, [0], [0]
方括号里的 [0] 就是反馈槽位编号。每一条带槽位的指令,都是 V8 埋下的一个观测点。字节码本身很朴素,真正让 V8 变快的不是字节码,而是槽位里积累的统计。
8.1.2 类型反馈:藏在 Feedback Vector 里的观测结果
反馈的最小单元叫 Feedback Slot。每个可能多态的位置(属性读取、函数调用、二元运算)都对应一个槽位,槽位里记录「曾经见过的类型集合」以及各自出现的次数。
槽位状态会沿着下面的阶梯演化:
| 状态 | 含义 | 优化后果 |
|---|---|---|
UNINITIALIZED | 还没执行到 | 不优化 |
MONOMORPHIC | 只见过 1 种类型 | 可生成单态内联缓存,最快 |
POLYMORPHIC | 见过 2–4 种类型 | 生成多态缓存,稍慢 |
MEGAMORPHIC | 超过 4 种类型 | 退化为哈希查找,等同未优化 |
「4」这个数字不是随口说的,它由 max_polymorphic_map_count 决定。一旦越过这条线,内联缓存从「比较一次映射指针」退化为「查表」,属性访问的成本会突然抬高一个数量级。
这段代码会让 add 的参数槽位停在单态:
type Vec = { x: number; y: number };
function add(a: Vec, b: Vec): Vec {
return { x: a.x + b.x, y: a.y + b.y };
}
for (let i = 0; i < 100_000; i++) {
add({ x: i, y: i }, { x: 1, y: 1 }); // 形状始终一致
}
而下面这段,只要在同一个调用点混入一个带额外字段的对象,槽位立刻从单态跳成多态:
type Vec3 = { x: number; y: number; z: number };
const a: Vec = { x: 1, y: 2 };
const b: Vec3 = { x: 1, y: 2, z: 3 }; // 形状不同!
// 同一个调用点交替收到两种形状 → POLYMORPHIC
for (let i = 0; i < 100_000; i++) {
const v = i % 2 === 0 ? a : b;
add(v, a);
}
注意这里踩坑的根因不是「多了一个字段」,而是同一个调用点同时看到两种对象形状。把两类数据分流到两个函数里,问题就消失了。
8.1.3 隐藏类:对象形状的身份证明
要理解内联缓存为什么快,得先理解隐藏类(Hidden Class,V8 内部叫 Map)。JavaScript 对象在规范上是一个无序的字符串键字典,如果真按字典实现,每次 obj.x 都要做一次字符串哈希,性能无法接受。
V8 的做法是:给每个「形状」分配一个 Map 对象,对象内部只保留一个指向 Map 的指针,属性值按固定偏移存放。于是 obj.x 变成「解引用 + 固定偏移取值」,和 C 结构体访问一样快。
Map 是按属性添加顺序逐步迁移形成的。下面两种写法语义等价,但形状迁移路径不同:
// 形状 A:x → y
const a = {};
a.x = 1;
a.y = 2;
// 形状 B:y → x
const b = {};
b.y = 2;
b.x = 1;
a 和 b 在 V8 眼里是两个不同的形状,虽然最终字段集合相同。如果二者出现在同一个调用点,内联缓存同样会变成多态。
最稳妥的写法是在构造函数或对象字面量里一次性给出全部字段:
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y; // 顺序固定,形状稳定
}
}
const p = { x: 1, y: 2 }; // 字面量:形状一次性定型
class 字段声明的书写顺序就是迁移顺序。把 y 挪到 x 前面就会得到另一个形状——如果你的代码里两处 Point 定义顺序不一致(比如一个来自旧版库),性能会莫名其妙地掉。
还有一个隐蔽的陷阱:动态增删属性。
const cfg = { host: "localhost" };
cfg.port = 8080; // 形状迁移:{host} → {host, port}
delete cfg.host; // 强制进入字典模式,IC 全部失效
delete 之所以危险,是因为它会让 Map 退化成字典模式(dictionary mode),此后所有属性访问都走哈希表,再怎么优化也追不回来。需要「删掉」一个字段时,赋 null 或重建对象都远好于 delete。
8.1.4 内联缓存:把「查」变成「比」
内联缓存(Inline Cache,IC)是 JIT 的看家本领。它的思路极其简单:在调用点旁边缓存上次的结果,下次先比对前提条件是否成立。
以属性读取为例,未优化时是「按名字查 Map」;单态 IC 生成后变成:
if (obj.map === cachedMap) return obj[offset]; // 快路径
else goto slowPath; // 慢路径,重新查表
这就是为什么「形状稳定」如此重要——形状一变,快路径判断不成立,代码回到慢路径重新查找,同时反馈槽位被污染。
方法调用有两级 IC:先缓存「从对象到函数」的查找结果(这层可能沿原型链走好几跳),再缓存函数内部的类型假设。原型链越深,第一级缓存的价值越大;反过来,如果方法被动态改写(monkey patch),第一级缓存就废了。
多态 IC 会依次比较 2–4 个缓存项,命中即返回;超过 4 个就退化成 MEGAMORPHIC 的哈希查找。想深入这条优化链路的细节,可以延伸阅读 JIT 优化与热点代码识别 。
8.1.5 真实工程里的多态从哪来
单态很美好,但真实业务里多态几乎无法避免。常见的四个来源:
| 来源 | 例子 | 缓解手段 |
|---|---|---|
| 联合类型分派 | Shape = Circle | Square | Triangle | 每种形状走独立函数 |
| 可选字段 | { type, payload? } 有无 payload 是两种形状 | 显式补 payload: null |
| 多来源数据 | DB 行、HTTP JSON、缓存对象混入同一函数 | 边界处归一化 |
| 库与业务共存 | 旧版对象结构未同步升级 | 版本切换时统一重建 |
以「可选字段」为例,下面这种写法在类型上完全正确,在运行时却制造了两个形状:
type Event = { type: string; payload?: unknown };
function handle(e: Event): void {
// 有 payload 与没有 payload 是两种 Map
if (e.payload !== undefined) consume(e.payload);
}
handle({ type: "a" }); // 形状 1
handle({ type: "b", payload: 42 }); // 形状 2 → IC 变多态
修正方式很反直觉:主动把字段补齐。
handle({ type: "a", payload: null }); // 形状统一,代价是一个字段
用一个 null 换回单态,通常划算。这也解释了为什么很多库的对象类型里字段全是必填——不是设计上的洁癖,而是 JIT 友好的选择。
8.1.6 推测式优化与去优化
当函数被调用足够多次(或循环回边触发次数越过阈值),TurboFan 会把它编译成机器码。这一步叫推测式优化:编译器根据反馈表假设「这里的参数永远是 Vec」,生成不含类型检查的机器码。
问题来了——JavaScript 是动态类型语言,谁也不能保证下一次调用还传同一种类型。V8 的应对是插入检查点(check):进入优化代码时验证假设,假设破裂就触发去优化(deoptimization),把执行状态「回滚」回字节码继续解释执行。
去优化本身不是错误,它是设计的一部分。但频繁去优化会让程序在「优化—去优化」之间反复横跳,性能比纯解释执行还差。用一个类型不稳定的函数就能观察到:
function sum(a: number, b: number): number {
return a + b;
}
// 先喂 10 万次 number,让 sum 被优化
for (let i = 0; i < 100_000; i++) sum(i, i);
// 再喂字符串:假设破裂,触发 deopt
console.log(sum("1" as unknown as number, "2" as unknown as number));
注意 as unknown as number 这层断言——它骗过了 TypeScript 的类型检查,却骗不过 V8。这正是「类型擦除」的代价:类型系统的保证在运行时为零。
8.1.7 读懂 –trace-deopt 输出
排查这类问题必须靠日志。用下面的命令运行,V8 会把每一次去优化的原因打出来:
node --trace-deopt --trace-opt app.js
典型输出长这样:
[marking 0x2a1b <JSFunction sum (sfi = 0x3c10)> for optimization]
[optimizing 0x2a1b <JSFunction sum> - took 0.21 ms]
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing 0x2a1b]
读日志只要抓住两个字段:
| 字段 | 常见取值 | 含义 |
|---|---|---|
kind | deopt-eager | 立刻回滚,本次调用走解释器 |
kind | deopt-lazy | 标记失效,下次调用时才回滚 |
reason | not a Smi | 期望小整数,收到别的类型 |
reason | wrong map | 对象形状与缓存的 Map 不符 |
reason | insufficient type feedback | 反馈不足,编译假设无从建立 |
wrong map 出现得最频繁,它几乎总是指向「同一个调用点混用了不同形状的对象」。not a Smi 则通常是数值类型不稳定:number 与 bigint 混用、整数与浮点混用,都会让 V8 从「小整数(Smi)快路径」掉出来。
还有一个高频原因值得单独说:函数参数个数变化。V8 为「参数个数固定」的函数生成的机器码更紧凑,arguments 的使用或 fn.length 依赖会让优化变复杂。
8.1.8 用 –trace-opt 看优化时机
--trace-opt 记录的是反面:哪些函数被优化了、什么时候、花了多久。
node --trace-opt --trace-deopt app.js 2>&1 | grep -E "optimizing|bailout"
输出形如:
[optimizing 0x2a1b <JSFunction add> - took 0.34 ms]
[optimizing 0x2f8c <JSFunction handle> - took 1.02 ms]
这里有两个实用信号:
- 函数被反复
optimizing多次 → 说明它被反复去优化,必然有形状或类型不稳定。 took时间很长 → 说明函数体太大,TurboFan 编译成本高,可能触发「优化本身比不优化还慢」。超过一定体积的函数甚至会被直接放弃优化。
长函数的另一个问题是「一荣俱荣、一损俱损」:函数内任意一个位置去优化,整段机器码都作废。把热路径拆成小函数,让不稳定部分被隔离出去,是提升优化命中率的常用手法。
8.1.9 面向 JIT 友好的编码约定
把前面的结论收敛成几条可执行的规则:
| 规则 | 反例 | 正例 |
|---|---|---|
| 字段一次定型 | 分多次 obj.a = … | 字面量 / 构造函数内写全 |
| 字段顺序一致 | 各处声明顺序不同 | 统一模板 |
| 保持单态 | 同一调用点混多种形状 | 分流到不同函数 |
| 数值类型统一 | number 与 bigint 混用 | 边界处显式转换 |
避免 delete | delete obj.x | 赋 null 或重建对象 |
| 热路径拆小 | 单函数上千行 | 稳定部分独立成函数 |
用 class 而不是工厂函数、用 Map 而不是把对象当哈希表用,都是同一类思路:让形状可预测。
需要强调的是,这些规则并非 V8 独有。Bun 背后的 JavaScriptCore 用的是 Structure + Inline Cache,思路完全一致;想了解另一种引擎的实现差异,可以延伸阅读 为什么 Bun 这么快:Zig 与 JavaScriptCore 以及 Node.js 深度解析:运行架构与性能 。跨运行时做性能迁移时,这些共性比某个引擎的调优开关更有价值。
最后一句提醒:不要过早做这些优化。上面每一条都有可读性代价,只有当剖析(下一节的主题)真的指向热点函数时才值得动手。
8.1.10 本节要点
- 类型在运行时不存在,性能来自运行时类型反馈,不是类型标注。
- 反馈槽位沿
单态 → 多态 → 巨态演化,4 是分水岭。 - 隐藏类决定属性访问是「偏移取值」还是「哈希查表」,形状稳定是前提。
- 内联缓存把「查」变成「比」,形状一变就回慢路径。
- TurboFan 做推测优化,假设破裂即去优化;
--trace-deopt是唯一的诊断入口。 - 所有规则最终归为一句:让形状与类型可预测。
小结
本节我们从类型系统的上游走到了运行时的最底层:TypeScript 的类型在编译后消失,V8 靠 Ignition 采集的反馈表重建出「事实上的类型」,再用隐藏类、内联缓存和推测优化把它变成机器码。你写下的代码是否 JIT 友好,取决于它是否让这些机制保持单态、稳定、可预测。
但这里还留了一个问题没有回答:类型擦除之后,.ts 编译出来的 .js 到底长什么样?枚举、命名空间、参数属性这些「有运行时痕迹」的语法会被编译成什么?装饰器和元数据又是怎么留下的?下一节 8.2 类型擦除后的运行时形态
就来看编译产物的真实面貌。
阅读导航:上一节:7.3 semver、发布与类型破坏性变更 · 下一节:8.2 类型擦除后的运行时形态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。