在 C 里写 p->x,编译出来是一条指令:从 p 指的地址往后数固定的字节数,取值。偏移量是编译期算好的常数,因为结构体的布局写在类型里,类型在编译期就定死了。
换成 JavaScript 的 p.x,语言规范说这是一次字符串键查找:先在对象自身的属性里找 "x",找不到就沿原型链一路往上找,中途可能撞上 getter、撞上 Proxy、撞上一个上一行刚被删掉的字段。规范没有承诺任何内存布局,因为对象的形状随时可以变。
但真在 Chrome 里跑一个热点循环反复读 p.x,V8 最终执行的机器码通常只有两三条指令:比较一个指针,然后按常数偏移取值——和 C 几乎一样。
中间隔着一整套机制,它的核心思路可以压缩成一句话:静态推不出来的类型,就在运行时观察出来,然后赌它不会变。 这篇文章讲清楚这个赌局怎么下、赌注记在哪本账上、以及赌输时如何全身而退。
破除误解
误解一:JIT 快,是因为"把 JavaScript 编译成了机器码"。
单纯翻译成机器码带来的收益,比多数人以为的小一个数量级。V8 在 2021 年的 Chrome 91 里加入了 Sparkplug——一个把字节码单遍线性扫描直接吐成机器码的编译器,没有中间表示、没有任何跨指令优化。它消除的正是"解释执行"本身的开销(取指、分派、操作数解码)。V8 团队公布的收益是:Speedometer 提升 5–10%,真实网页浏览中的 V8 主线程时间减少 5–15%。是百分之几,不是几倍。
原因很直白:一段没有类型信息的 JavaScript,即使编成机器码,a + b 也只能编成"调用通用加法内建函数",那个内建函数里仍然要判断两个操作数是数字、字符串还是对象。真正数量级的收益来自专门化(specialization)——知道 a 和 b 这两处历史上一直是小整数,于是把这行编成一条机器加法指令加一个溢出检查。而"知道"这件事,靠的是观察,不是编译。
误解二:分层编译是一部单向电梯,函数升上去就一直待在上面。
恰恰相反。去优化(deoptimization,简称 deopt)不是故障处理,是设计的一部分,在任何一个稍复杂的页面里每秒都在发生。优化过的代码随时可能被整块作废,函数被踢回解释器重新执行——甚至可以在这个函数正在执行、栈帧还在栈上的时候完成这次退回。整条流水线的正确性论证不是"优化不会出错",而是"出错时一定退得回去"。
误解三:V8 会把优化好的机器码缓存下来,下次打开页面直接用。
不会。V8 的代码缓存(code cache)缓存的是源码解析与字节码编译的结果,优化编译器产出的机器码不在其中。原因是它无法被复用:优化代码建立在本次运行观察到的类型反馈之上,而反馈是这次会话的产物,页面一关就没了。每一次刷新,V8 都要从零重新认识你的代码。
这个约束反过来解释了整个分层设计。桌面端的一个网页交互常常只有几秒钟,"跑够久之后达到的峰值性能"在浏览器里经常根本等不到——所以档位之间的过渡速度,和最高档的代码质量同等重要。
一个 JavaScript 对象在内存里长什么样
先看最朴素的实现:每个对象带一张自己的哈希表,键是属性名字符串,值是属性值。这是正确的,也是灾难性的。一个 {x, y} 的二维点,光哈希表的桶数组、装载因子余量、键字符串指针就要几十个字节,读一次属性要算哈希、比较字符串、处理冲突。一百万个点就是一百万张结构一模一样的哈希表。
问题的关键在于:这一百万张表的结构是一样的,只有值不一样。 结构信息被重复存了一百万遍。
隐藏类:把哈希表重新变回结构体
解法在 1989 年就有了。Craig Chambers、David Ungar 和 Elgin Lee 在 OOPSLA 上发表的 Self 实现论文里提出了 maps:把对象拆成两半,一半是各不相同的字段值,另一半是所有同形状对象共享的布局描述。V8 沿用了这个词,内部数据结构就叫 Map(与 JavaScript 语言里的 Map 毫无关系,对外文档一般称之为 hidden class,隐藏类)。
一个 V8 堆对象的开头是一个指向 map 的指针,后面跟着若干槽位。map 里记着:有哪些属性、每个属性在第几个槽、字段的表示形式(小整数 / 双精度 / 堆对象指针)、是否可写可枚举、原型是谁。
关键在于 map 的共享和不可变:
function Point(x, y) {
this.x = x; // map A(空)→ map B(有 x)
this.y = y; // map B → map C(有 x, y)
}
const a = new Point(1, 2); // a 的 map 是 C
const b = new Point(3, 4); // b 的 map 也是 C,同一个指针
```执行 this.x = x 时,V8 不修改当前 map,而是新建一个带 x 的 map,并在旧 map 上记一条转移边(transition):「从我这里加一个叫 x 的属性,去 map B」。下一个对象走同一条路时直接沿边复用。于是这些 map 组成一棵转移树,构造一百万个 Point 只产生三个 map。
代价立刻显现:路径决定身份。
const p = {}; p.x = 1; p.y = 2; // 走 A→B→C,最终 map = C
const q = {}; q.y = 2; q.x = 1; // 走 A→D→E,最终 map = E
```p 和 q 有完全相同的属性集合,却是两个不同的隐藏类。任何见过它们俩的代码点,在 V8 眼里都是"多态"的。
这套设计买来的是:判断"这个对象是不是我认识的那一类"退化成一次指针比较;确认之后,属性偏移量就是一个编译期常量。它卖掉的是灵活性,账单有三张:
- 形状爆炸。把对象当哈希表用(键是用户 ID、单词、URL),每加一个键就生成一个新 map,转移树膨胀成一条长链,占内存且毫无复用。这类场景应该用
Map,它内部就是真哈希表,不参与隐藏类体系。 - 字典模式(dictionary mode)。
delete obj.x无法在转移树上表达(转移边只描述"加"),V8 的处理是把对象整体降级成字典模式:属性搬回一张真正的哈希表,对象从此没有稳定的 map 可供比较。属性数量过多、形状变化过频时 V8 也会主动降级。这个降级基本是单向的。 - 数组另有一套。数组的下标元素不走 map 的属性槽,而是存在独立的 elements 存储里,用 elements kind 标记。V8 区分 21 种 elements kind,它们构成一个只能向下走的格(lattice):
[1,2,3]是PACKED_SMI_ELEMENTS(紧凑小整数,最快);push 一个1.5变成PACKED_DOUBLE_ELEMENTS;push 一个字符串变成PACKED_ELEMENTS(装的是任意堆对象指针);一旦出现空洞(a[100] = 1或new Array(3))就变成对应的HOLEY_*版本,此后每次读取都要额外检查这一格是不是洞、要不要去原型链上找。这些转移都不可逆:把浮点数删掉,数组也回不到小整数状态;把洞填满,它依然是 holey。
内联缓存:在每个访问点记一本小账
隐藏类解决了"对象的形状能不能被廉价识别",还剩一半问题:代码里的 p.x 这个位置,怎么知道该拿哪个 map 去比、比中了取第几个槽?
答案是 内联缓存(inline cache,IC),Peter Deutsch 和 Alan Schiffman 在 1984 年的 Smalltalk-80 实现里提出。名字来自最初的做法——把上次查找的结果直接改写进那条指令所在的位置(inline)。现代 V8 不改写指令流,而是把反馈集中存进每个函数一份的 FeedbackVector:字节码里每个属性访问、每个函数调用、每个二元运算都分到一个槽位编号,反馈就写在对应槽里。Sparkplug 生成的栈帧里专门留了一格指向它,可见其地位。
FeedbackVector 是懒分配的:一个函数要执行满约 1KB 字节码之后 V8 才给它分配反馈向量。理由很现实——网页里绝大多数函数一辈子只跑一两次,为它们记账纯属浪费内存。
一个属性访问点的账本有四种状态:
| IC 状态 | 记着什么 | 读一次属性的代价 | 优化编译器能做什么 |
|---|---|---|---|
| uninitialized | 空,从没执行过 | —— | 无反馈,不敢优化 |
| monomorphic(单态) | 1 个 map + 1 个偏移 | 一次指针比较 + 一次定址取值 | 直接内联成常数偏移访问 |
| polymorphic(多态) | 2–4 个 map 及各自偏移 | 逐个比较,最多 4 次 | 生成一组分支,每支专门化 |
| megamorphic(超态) | 不再自己记,退回全局 stub cache | 按 (map, 属性名) 哈希查一张全局表 | 基本无从下手,只能调用通用逻辑 |
单态是这套机制的甜点区。V8 为它生成的代码大致是这个形状(x86-64 伪汇编):
cmp [rax - 1], <期望的 map 地址> ; 堆对象指针带 tag,减 1 得到真实地址
jne ic_miss ; 形状不符 → 走慢路径,更新账本
mov rbx, [rax + 0x0f] ; 偏移量是编译期常量,直接取字段
```第一行的 - 1 是 V8 的指针标记(tagging)约定:堆对象指针最低位为 1,小整数(Smi)最低位为 0,因此一次位测试就能区分"这是个整数"和"这是个对象",不必额外存类型字节。在启用指针压缩的 64 位构建里,Smi 是 31 位有符号整数,直接编码在字里,不需要堆分配。
多态的上限是 4 个 map。这个数字是取舍的产物:IC 命中要线性比较记下的 map,条目太多时逐个比较的成本就超过了直接查全局哈希表;而超过四种形状,通常也说明这个访问点确实"什么都见",专门化没有意义。越过上限就变成 megamorphic——这是性能悬崖所在,不是慢一点,而是优化编译器从此对这一行无话可说。
四个档位:从 2008 年到今天
V8 在 2008 年 9 月随 Chrome 首发,由 Lars Bak 在丹麦奥胡斯带队完成。当时它的卖点恰恰是"没有解释器":所有 JavaScript 直接编译成机器码。2010 年 12 月加入 Crankshaft,V8 第一次有了带类型反馈的优化编译器和配套的去优化机制。
但"全部编译成机器码"在移动端撞了墙:机器码占内存,而绝大多数函数只跑一次。于是 V8 反向补课,做了一个解释器。字节码只有等价基线机器码的 25%–50% 大小,编译成本也低得多。2017 年 5 月,V8 5.9 / Chrome 59 用 Ignition + TurboFan 整体替换掉 full-codegen + Crankshaft,桌面与高端移动设备上 V8 的内存占用降低 5–10%,Speedometer 分数提升 5–10%。此后 2021 年补上 Sparkplug(Chrome 91),2023 年补上 Maglev(Chrome 117),形成今天的四档:
| 档位 | 输入 | 产出 | 编译成本 | 特点 |
|---|---|---|---|---|
| Ignition(解释器) | 字节码 | 无(直接执行) | 只需生成字节码 | 寄存器机 + 累加器;执行的同时把类型写进 FeedbackVector |
| Sparkplug(基线) | 字节码 | 机器码 | 单遍线性扫描,无 IR | 去掉分派开销;栈帧布局与解释器逐格兼容 |
| Maglev(中层优化) | 字节码 + 反馈 | 优化机器码 | 比 Sparkplug 慢约 10 倍,比 TurboFan 快约 10 倍 | CFG + SSA(不用 sea of nodes);做基于反馈的专门化,支持 deopt |
| TurboFan(顶层优化) | 字节码 + 反馈 | 高度优化机器码 | 最贵,在后台线程编译 | 激进内联、逃逸分析、类型收窄、载入消除、指令调度 |
Sparkplug 那一栏的"栈帧兼容"值得单独说:它的帧和解释器帧一格一格对齐(只把"字节码偏移"那一格换成反馈向量指针),于是调试器、异常展开、性能剖析器不需要认识第二种帧格式,函数执行到一半在解释器与 Sparkplug 代码之间切换(栈上替换,on-stack replacement,OSR)也几乎零成本。这是"用极少的机制换取整条流水线简化"的典型。
什么时候升档,靠的是中断预算(interrupt budget):每个函数有一个计数器,随着执行的字节码数量递减,函数返回和循环回边处检查;归零就触发一次分层决策,同时把预算重置。长循环里的函数不必等到返回——V8 可以在循环回边处做 OSR,把正在执行的函数当场换成优化版本,从对应的循环入口继续跑。
顶层的内部结构近年也在换。TurboFan 最初用 sea of nodes(海量节点图,控制流不显式存在,靠 effect 与 control 链约束)。2025 年 3 月 V8 团队公开承认这条路走得不划算:图遍历顺序糟糕导致节点反复访问、缓存局部性差(L1 数据缓存未命中约为 CFG 方案的 3 倍,个别阶段到 7 倍)。替代品 Turboshaft 是传统的 CFG 表示,编译时间减半,载入消除这一个阶段快了约 190 倍。目前 JavaScript 的后端与整个 WebAssembly 流水线已经在 Turboshaft 上,前端与内建函数仍在迁移中。
横向看,这不是 V8 独有的结构:JavaScriptCore 有 LLInt、Baseline、DFG、FTL 四层;HotSpot JVM 有解释器加 C1、C2 两个编译器的分层编译。三家在没有互相抄袭的情况下收敛到同一个形状,说明这个分层不是工程口味,而是"编译成本必须与代码热度成正比"这条约束的必然解。
乐观优化:把观察当成假设
Maglev 和 TurboFan 读 FeedbackVector 时,做了一件逻辑上不成立的事:把"至今为止见过的形状"当成"未来也会是的形状"。JavaScript 的语义完全允许下一次调用传进来一个截然不同的对象,所以这不是推理,是假设。
假设一旦成立,能拆掉的东西非常多:属性访问变成常数偏移;a + b 变成一条机器整数加法;小整数不再装箱成堆对象;被内联进来的函数不再有自己的栈帧;构造出来后没有逃逸的临时对象被逃逸分析拆成几个寄存器,根本不分配。
于是每个假设点都要配一个检查和一个失败出口。检查通常极廉价(比较 map 指针、测试 tag 位)。还有一类假设无法就地检查,比如"Array.prototype 上没有人加过索引属性""这个函数的 .length 没被改过"——它们被登记为代码依赖,挂在相应的对象或全局保护单元上;一旦有人改动,V8 批量作废所有依赖它的优化代码。一句无害的 Array.prototype.foo = 1 可以让整个页面的一批优化代码集体失效。
去优化:如何在悬崖边上装护栏
这是整套体系里最难的部分,也是 Urs Hölzle、Craig Chambers 和 David Ungar 在 1992 年 PLDI 论文里解决的问题:优化代码的栈帧和解释器的栈帧根本对不上。局部变量可能只存在于寄存器里,可能被常量折叠掉,可能压根没被计算;被内联的三层调用在机器上只有一个帧,而解释器需要三个。
V8 的做法是在编译时记账:给每个可能触发 deopt 的点保存一份"抽象的解释器帧状态"——一张映射,说明在这个程序点上,解释器的 0 号寄存器现在在 rbx、3 号寄存器是常数 42、5 号寄存器对应的那个对象已经被逃逸分析拆成了两个浮点寄存器需要重新物化。
运行时的流程是:检查失败 → 跳到去优化出口 → 按这张表把一个优化帧重建成一个或多个解释器帧(内联几层就重建几个)→ 从记录的字节码偏移处继续解释执行。程序的可观察行为完全不变,只是慢了下来。
去优化分两种:eager deopt 是检查当场失败(传进来的对象 map 不对);lazy deopt 是别处的动作让假设失效(有人改了原型链),此时代码可能正在栈上执行,V8 会把它标记为待作废,等控制流返回时才真正退回。
deopt 不是免费的:要重建帧、丢弃已编译的代码、函数回落到低档、重新积累反馈再等待升档。真正的麻烦是去优化循环——某个函数一被优化就 deopt,回落后又很快变热再被优化,如此往复,工作全花在编译上。V8 对反复 deopt 的函数会记账并最终放弃,把它长期留在低档执行。
这些都可以直接看见。Node.js 或 d8 加上 --trace-opt --trace-deopt 会打印每一次升档与退回,退回时带着原因串(wrong map、not a Smi、insufficient type feedback 之类);加 --allow-natives-syntax 后可以在代码里调用 %GetOptimizationStatus(fn) 直接查询某个函数当前处在哪一档。
一个典型的触发:
function sum(arr) { // 反复以 PACKED_SMI 数组调用,被优化成机器整数循环
let t = 0;
for (let i = 0; i < arr.length; i++) t += arr[i];
return t;
}
sum([1, 2, 3]); // 快
sum([1, 2, undefined]); // 这个数组的 elements kind 不同 → 检查失败 → 退回解释器
```什么场景下这套体系不灵
天生超态的代码。 通用序列化器、ORM、模板引擎、深比较函数——它们的职责就是处理任意形状的对象,每个属性访问点必然 megamorphic。这类代码在 JIT 下几乎拿不到专门化红利,性能主要靠减少访问次数而不是加快单次访问。
短命脚本。 编译是要还本的投资。一个只跑几毫秒的脚本,函数还没热就结束了,此时 V8 的性能约等于"解释器 + Sparkplug",与人们想象中的"JIT 性能"相去甚远。
巨型函数。 内联有预算,编译时间随函数体超线性增长;几千行的单个函数往往既进不了高档,又拖慢整条流水线。
内存受限设备。 同一个函数可能同时在堆上留有字节码、Sparkplug 代码、Maglev 代码、TurboFan 代码,加上反馈向量。Ignition 的诞生动机正是这条账。
禁止可写可执行内存的平台。 iOS 的非浏览器进程、某些主机与嵌入式环境不允许运行时生成代码。V8 为此提供 --jitless 模式,整个 JIT 关掉只跑解释器——功能完整,性能是另一个量级。
安全。 乐观优化把类型检查前移到编译期,意味着编译器一旦推错,生成的代码会在没有任何运行时检查的情况下按错误的布局访问内存。这类"类型混淆"是浏览器漏洞的主力:Google 统计 2021–2023 年间野外观察到的 Chrome 渲染进程内存破坏问题中约 60% 出在 V8;2025 年 9 月的 CVE-2025-10585 就是一个被在野利用的 V8 类型混淆零日,Chrome 在 140.0.7339.185 中紧急修复。V8 团队在 2024 年默认启用了 V8 沙箱(V8 Sandbox),以约 1% 的性能开销把 V8 堆内的内存破坏限制在沙箱之内——这是对"JIT 漏洞不可能清零"的正式承认。
所以"对 JIT 友好的代码"到底指什么
不是那些流传的微技巧。归结起来只有一条:让每个访问点见到的形状尽可能少,并让形状尽早定型。 具体落到写法上:
- 在构造函数或类里一次性初始化全部字段,顺序固定;不要事后追加属性。
class语法天然做到这点,这也是它在性能上不吃亏的原因。 - 不要
delete属性;置null或改用Map。 - 数组保持元素类型一致,避免空洞;需要预分配长度时,用
push填充比new Array(n)更能保住 packed 状态。 - 不要把一个热点函数当成万能入口,喂进几十种形状不同的对象。拆成几个专门的函数,反而让每个访问点回到单态。
TypeScript 的类型不进运行时,V8 看不见它们,但它在工程上逼你写出形状稳定的对象——这是它对性能的间接贡献,而不是直接的。
最后一句实话:绝大多数应用的瓶颈不在这里,而在 DOM 操作、网络往返、GC 压力和算法复杂度。上面这些只有在你已经用剖析器确认某个函数是热点、并用 --trace-deopt 看到它在反复退回时,才值得动手。在此之前按 JIT 的口味写代码,是把可读性押在一个未经测量的赌注上——而这套系统本身,恰恰是靠"只在有观测证据时才下注"活下来的。
跨域连接
- 认知偏误:内联缓存的工作方式和人类的启发式判断同构——都用"最近见过什么"来预测"接下来是什么",都因此换来极高的速度。区别只在纠错机制:V8 在每个假设点都强制留了一个可执行的失败出口,猜错时按预先记好的映射表把状态完整还原;人脑没有这张表,错误的预判会直接顺着流程执行下去,并且倾向于事后为它补一个合理化解释。可得性启发式的失败与 megamorphic 访问点的失败也是同一形状:样本一旦超出可缓存的少数几类,快速通道就失效,而系统往往察觉不到自己已经落回慢路径。这提示了一件事——推测机制的价值不在猜得准,而在错误可被检测且可被撤销。
- 概率论:推测优化本质上是一次频率派下注。IC 记录的是有限样本上的经验分布,优化编译器把这个分布的众数当作总体来生成代码。它敢这么赌,靠的不是分布稳定,而是把错误的代价压成有界常数:一次帧重建加一次降档,与收益不对称,于是即使命中率远低于一,期望仍然为正。多态上限设为 4、反馈向量延迟到 1KB 字节码后才分配,都是在给这场下注设置最小样本量与最大分支数——统计判断只在证据足够时才允许作出。而"去优化循环"正是期望值翻负的情形:同一个赌注反复输,系统只能停止下注。
- 电网:分层编译与电力调度面对完全相同的约束——需求瞬息万变,而产能的启动速度与运行效率成反比。电网用启动慢、边际成本低的基载机组承担稳定负荷,用秒级响应、单位成本高的调峰机组吃尖峰;V8 用解释器和 Sparkplug 覆盖只跑几次的绝大多数函数,用编译昂贵的 TurboFan 只服务真正的热点,中断预算就是那条负荷预测曲线。两者都不追求任一时刻的最优配置,而追求切换代价可控:电网靠旋转备用,V8 靠栈上替换和可逆的去优化,使得"档位选错了"永远不是灾难性事件。
- 供应链:即时编译的名字直接来自制造业的准时制生产,这不是巧合而是同一套推理。提前编译等于按预测备货:为所有可能路径生成代码,付出内存与启动时间,其中大部分永远用不上;即时编译等于按订单生产,只在函数真正变热时才投入产能,用编译延迟换取库存归零。V8 的字节码只有等价基线机器码的四分之一到二分之一,本质上就是把"半成品"存起来而不是成品。而准时制的代价在这里同样成立:库存为零意味着没有缓冲,冷启动时每一次首次调用都要现场排产——这正是浏览器要为几秒钟的页面生命周期专门设计多个中间档位的原因。
- 什么是真理:优化代码里的每一条类型断言都是可废止的(defeasible)——在当前证据下成立,新证据出现即被撤销。这套系统给出了一个工程化的可废止真理模型:断言必须显式标注它依赖的前提(map 检查、原型保护单元),并预先备好撤销后的回退状态,于是"曾经为真的东西现在为假"不再导致系统崩溃,而只是一次受控的状态还原。与之对照,静态类型系统追求的是编译期一劳永逸的证明。两条路线的分工很清楚:证明给你确定性但要求程序先接受约束,推测不要求任何前提但必须自己承担撤销的全部机械成本——JavaScript 选了后者,代价是运行时里必须永远背着一张"如何反悔"的账本。
参考文献
- Chambers, C., Ungar, D. & Lee, E. An Efficient Implementation of SELF, a Dynamically-Typed Object-Oriented Language Based on Prototypes. OOPSLA, 1989. (maps / 隐藏类的原始设计)
- Deutsch, L. P. & Schiffman, A. M. Efficient Implementation of the Smalltalk-80 System. POPL, 1984. (内联缓存与动态翻译的首次系统实现)
- Hölzle, U., Chambers, C. & Ungar, D. Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches. ECOOP, 1991. (单态缓存扩展到多态的方案)
- Hölzle, U., Chambers, C. & Ungar, D. Debugging Optimized Code with Dynamic Deoptimization. PLDI, 1992. (去优化的帧重建机制)
- Swirski, L. Sparkplug — a non-optimizing JavaScript compiler. V8 Blog, 2021-05-27. (基线编译器的设计与解释器兼容帧)
- Verwaest, T. et al. Maglev — V8's Fastest Optimizing JIT. V8 Blog, 2023-12-05. (中层优化编译器的定位与取舍)
延伸阅读
- Mercadier, D. Land ahoy: leaving the Sea of Nodes. V8 Blog, 2025-03-25. (从内部视角讲一个 IR 选型为何在十年后被推翻)
- Bynens, M. Elements kinds in V8. V8 Blog, 2017. (数组元素类型的格结构与不可逆转移,附可运行示例)
- Groß, S. The V8 Sandbox. V8 Blog, 2024. (从攻击者视角看乐观优化留下的内存安全缺口,以及为何选择缓解而非根除)