Skip to main content
我看 JIT 是从”为什么同一段代码越跑越快”入手的,顺着解释器、基线编译和优化编译一路看下去。

Q1: V8 编译流水线的历史?为什么需要 JIT?

1

早期: 纯解释执行(慢)

最早的 JS 引擎(如 SpiderMonkey 1.0)用解释器逐行读取字节码并执行。优点是启动快(不需要编译),缺点是执行慢——每执行一行都要重新解析。对于 1995 年的简单网页脚本来说够用了。
2

纯编译对 JS 不可行

C/C++ 可以提前编译(AOT),因为类型在编译时已知。但 JS 是动态类型语言——变量类型在运行时才确定,编译器无法在运行前生成高效的机器码。而且 JS 代码通过网络传输,用户不会等编译完再看网页。
3

2008年: V8 引入 JIT

Google 在 2008 年发布 V8,开创性地将 JIT(Just-In-Time)编译引入浏览器。核心思路:先快速解释执行,同时收集类型信息;对”热点代码”(Hot Code)即时编译成机器码,后续执行直接运行机器码。JS 引擎性能从此进入快车道。
JIT 编译解决的核心问题:
JIT 让 JS 引擎”边跑边优化”——先用解释器快速启动,再对热点代码即时编译成机器码加速。

🚫 纯解释执行

就像同声传译——每次听到一句话都要现场翻译,速度受限于翻译速度。简单对话没问题,但翻译一整本书就太慢了。

✅ JIT 编译

就像先快速翻一遍书(解释执行),发现某些章节被反复阅读(热点代码),就把这些章节提前翻译好贴在旁边(编译成机器码)。后续再读到这些章节时,直接看翻译好的版本,速度飞快。
先说”动态类型导致 AOT 不可行”这个痛点,再引出 JIT 的”运行时编译”思路。想听的是你对为什么 JS 需要 JIT的理解,而不是背诵编译器的名字。

Q2: V8 的编译流水线全景

V8 的编译流水线分为多个阶段,从源码到机器码:
1

Parser: 源码 → AST

Parser 将 JS 源码解析为抽象语法树(AST)。V8 有两个 Parser:Pre-Parser(预解析,只检查语法,延迟全量解析以加速启动)和 Parser(全量解析,生成完整 AST)。
2

Ignition: AST → 字节码

Ignition 解释器将 AST 编译为字节码(Bytecode),然后逐条解释执行。字节码比源码更紧凑,但仍需要解释器逐条翻译。Ignition 在执行过程中会收集类型反馈(Type Feedback)——记录每个操作实际接收到的类型。
3

Sparkplug: 字节码 → 无优化机器码

Sparkplug 是 V8 2021 年引入的基线编译器。它直接将字节码翻译为机器码,不做任何优化。优点是编译速度极快(几乎零延迟),生成的机器码比解释器执行字节码快,但不如 Maglev、TurboFan 生成的中层或优化机器码。
4

Maglev: 字节码 → 中层优化机器码

Maglev 是 Sparkplug 与 TurboFan 之间的中层(mid-tier)优化编译器:编译开销明显低于 TurboFan,生成代码又比 Sparkplug 快。热点函数常先进入 Maglev,只有更热的函数才继续交给 TurboFan 深度优化。
5

TurboFan: 字节码 → 优化机器码

TurboFan 是 V8 的优化编译器。它根据 Ignition 收集的类型反馈,对热点函数进行激进优化——内联、逃逸分析、冗余消除、循环优化等。生成的机器码通常比 Sparkplug 快数倍,但编译耗时更长。
V8 的编译流水线是”逐层加速”:先解释执行(Ignition),再基线编译(Sparkplug),再中层优化(Maglev),最后深度优化(TurboFan)。每多一层就多快一点,但编译成本也更高。

各阶段的性能对比


Q3: 类型反馈与内联缓存

类型反馈(Type Feedback)是 V8 优化的基础。Ignition 在解释执行时,会记录每个操作实际接收到的类型信息。

内联缓存(Inline Cache, IC)

内联缓存是 JIT 优化的核心技术。当一个函数被反复调用时,V8 会缓存属性查找的结果,避免每次都遍历原型链。
IC 的三种状态:
内联缓存就像快递柜的常用格子记忆。第一次取件要扫码查找(遍历原型链),快递柜记住你常用 3 号格子(缓存属性偏移)。下次取件直接开 3 号格子(直接偏移访问)。但如果每次来的快递都放不同格子(MEGAMORPHIC),快递柜就没法记忆了,只能每次都扫码查找。

Q4: Hidden Class(隐藏类)与对象布局

V8 用隐藏类(Hidden Class,在 V8 内部叫 Map)来优化对象属性访问。JS 对象在 V8 中不是哈希表,而是通过隐藏类实现类似 C 结构体的固定偏移访问。
V8 用**转换链(Transition Chain)**来管理隐藏类的演变。当给对象添加新属性时,V8 会从当前隐藏类”转换”到一个新的隐藏类,而不是重新创建。

属性添加顺序影响 Hidden Class

隐藏类就像房子的”户型图”——同样户型的房子(同类对象),知道客厅在偏移 0、卧室在偏移 1,直接走过去就行(偏移访问)。不同户型的房子(不同隐藏类)需要看不同的图纸。所以尽量让对象保持相同的”户型”(形状),V8 就能用同一张图纸(隐藏类)快速定位。

Q5: TurboFan 的优化编译

TurboFan 是 V8 的优化编译器,对热点函数进行激进优化。它基于 Ignition 收集的类型反馈和图表示(Sea of Nodes)进行优化。
TurboFan 的主要优化手段:

反优化(Deoptimization)

当 TurboFan 的假设被违反时,V8 会”反优化”——丢弃优化后的机器码,回退到 Ignition 解释执行。反优化有性能代价,应尽量避免。
反优化不只是”慢一点”——它会丢弃整个优化后的机器码,函数必须重新从字节码开始执行,重新收集类型反馈,再排队等待下一次优化。这个过程本身不是免费的,如果一个热点函数频繁反优化,它的平均性能可能比从未优化过的函数还差。

Q6: 逃逸分析与栈上分配

逃逸分析(Escape Analysis)是 TurboFan 的重要优化。它分析一个对象是否”逃出”了当前函数作用域。如果没有逃出,对象可以直接分配在栈上(函数返回时自动释放),不需要 GC 回收。
没逃逸的对象就像你在便签纸上写的临时计算结果(栈上分配),用完直接扔。逃逸的对象就像要存档的文件(堆上分配),需要放进文件柜(堆),以后还得定期清理文件柜(GC)。所以尽量让临时对象”用完就扔”,不要让它们逃逸。

单态/多态调用对内联的影响


Q7: V8 对数字的优化

JavaScript 只有一种数字类型 Number(64 位浮点数,IEEE 754),但 CPU 处理整数比浮点数快得多。V8 内部对数字做了精细的类型区分和优化。

Q8: 实际开发中的 JIT 友好写法

保持类型一致

使用单态函数

避免 delete 操作

提前声明所有属性


底层源码:V8 是如何实现 JIT 的?

V8 的 Ignition 解释器使用寄存器式字节码(详见 V8 源码:src/interpreter/bytecodes.h):
这段代码做了 3 件事:(1)Ignition 使用寄存器式字节码,累加器(Accumulator)是核心寄存器;(2)字节码约 200 种,覆盖所有 JS 操作;(3)字节码比 AST 更紧凑,解释执行更快。 TurboFan 使用 Sea of Nodes 图表示进行优化(详见 V8 源码:src/compiler/node.h):
这段代码做了 3 件事:(1)Sea of Nodes 用图代替线性指令序列,优化更灵活;(2)数据依赖关系通过边显式表示,优化器可以自动发现并行机会;(3)多轮优化 Pass 在图上进行,逐步提升代码质量。

十、及格回答 vs 满分回答

问题:“说说 V8 是怎么执行 JavaScript 的”

及格回答(60-70分):
“V8 先把 JS 编译成字节码,然后用解释器执行。热点代码会用 TurboFan 编译成机器码加速。”
问题出在:知道流程但不理解优化细节。 满分回答(90-100分):
V8 的编译流水线是分层设计的:Parser 将源码解析为 AST,Ignition 将 AST 编译为字节码并解释执行,同时收集类型反馈。被调用几次的函数会通过 Sparkplug(基线编译器)转为无优化机器码;更热的函数先交给 Maglev 做中层优化;真正的高频热点才交给 TurboFan(优化编译器)激进优化。[1]
TurboFan 优化的核心是类型特化。它基于 Ignition 收集的类型反馈,假设操作数类型固定,生成针对性的机器码。比如整数加法直接用 CPU 的 ADD 指令,不需要运行时类型检查。关键的优化手段包括内联(Inlining)、逃逸分析、冗余消除、循环优化等。如果假设被违反(比如变量突然变成不同类型),会触发反优化(Deoptimization),回退到解释执行。[2]
内联缓存(IC)和隐藏类(Hidden Class)是 V8 对象访问优化的基础。V8 给每个对象分配一个隐藏类(描述对象的属性布局),相同形状的对象共享同一个隐藏类。IC 缓存属性查找结果——如果连续访问的对象都是同一隐藏类,直接用偏移访问,跳过原型链查找。保持对象形状一致是写出 JIT 友好代码的关键。[3]
实际开发建议:保持变量类型一致(避免反优化)、构造函数中一次性声明所有属性(稳定隐藏类)、避免 delete 操作(改变对象形状)、使用密集数组而非稀疏数组(Packed > Holey)。这些写法看起来是微优化,但在高频调用路径上差距会被明显放大。

常见追问

答题要点: 不能只说”一个逐行执行一个整体编译”,要从性能特征分析。高分回答: “解释器逐条读取字节码并执行,不需要编译阶段,启动快但执行慢——每条指令都要经过’读取-解码-执行’循环。 编译器把整个程序翻译成机器码,启动慢但执行快——翻译完后直接运行机器码,没有解释开销。JIT 是两者的结合:先用解释器快速启动(零编译延迟),同时收集运行时信息;对热点代码用编译器生成优化机器码(高执行速度)。V8 的分层设计(Ignition → Sparkplug → Maglev → TurboFan)就是这个思路的极致体现——简单函数用解释器就够了,只有真正的热点才值得花时间编译优化。”
答题要点: 字节码是中间表示,兼顾启动速度和内存占用。高分回答: “直接从源码编译成机器码有两个问题:
  1. 编译耗时:编译整个页面的 JS 为机器码需要时间,用户会看到白屏
  2. 内存占用:机器码通常比字节码大,移动设备内存有限
字节码是折中方案:比源码紧凑(压缩后更小),比机器码小得多(节省内存),解释执行够快(对非热点代码足够)。只有真正的热点函数才值得编译成机器码。这也是为什么 V8 有 Pre-Parser——首次加载时只做轻量级预解析(检查语法),函数真正被调用时才做全量解析。这样可以更快地开始执行代码。”
答题要点: 三种状态对应不同的优化程度,重点说清状态之间的退化关系。高分回答: “内联缓存(IC)记录了一个属性访问操作见过的类型数量:MONOMORPHIC(单态):只见过 1 种类型。IC 直接缓存该类型的隐藏类和属性偏移,下次访问直接读偏移,不需要查找。性能最好。POLYMORPHIC(多态):见过 2-4 种类型。IC 用线性表存储多种类型的缓存,逐个检查。性能中等。MEGAMORPHIC(超态):见过 5+ 种类型。IC 放弃缓存,退化为通用的属性查找(遍历原型链)。性能最差。所以在热点路径上,保持类型一致(MONOMORPHIC)能省掉反复的类型判断和原型链查找,差距随调用次数被放大。”
答题要点: 反优化是 JIT 的”安全阀”,但频繁反优化比不优化更慢。高分回答: “反优化是指 TurboFan 发现其优化假设被违反时,丢弃优化后的机器码,回退到 Ignition 解释执行。常见触发条件:
  1. 类型变化:TurboFan 假设变量是 int32,实际收到 string
  2. 形状变化:对象新增了属性,隐藏类发生变化
  3. 原型链修改:Object.prototype 被修改
  4. arguments 对象:使用了 arguments 导致优化受限
反优化的代价不只是’慢一点’——它丢弃整个优化机器码,函数要从 Ignition 重新开始,重新收集类型反馈,重新触发 TurboFan。频繁反优化的函数可能比从未优化的函数还慢。
答题要点: 要解释复用机制和共享条件。高分回答: “V8 不是为每个对象创建独立的隐藏类,而是用转换链来管理隐藏类的演变。当给一个对象添加新属性时,V8 会检查:是否已经存在一个从当前隐藏类出发、添加该属性名的转换?如果有,复用目标隐藏类;如果没有,创建一个新的隐藏类。
关键:必须按相同顺序添加相同名称的属性,才能共享隐藏类。如果 b 先加 y 再加 x,会走不同的转换路径,得到不同的隐藏类。这也是为什么推荐用字面量 { x, y } 一次创建对象——V8 会为字面量直接创建完整的隐藏类。”
答题要点: 区分 Packed 和 Holey 数组,以及元素种类。高分回答: “V8 内部用’元素种类’(Elements Kind)来优化数组。不同种类的数组使用不同的访问策略:
  • Packed SMI:全是小整数,最快(直接用 CPU 指令)
  • Packed Double:有浮点数,较快
  • Packed Elements:有对象,一般
  • Holey SMI/Double/Elements:有空洞,最慢(每次访问要检查空洞)
数组种类会’降级’但不会’升级’:Packed SMI → Packed Double → Packed Elements → Holey Elements。一旦降级就无法恢复。所以尽量保持数组元素类型一致、避免空洞。”
答题要点: 不是所有场景都慢,要分情况讨论。高分回答: “在理想情况下(类型稳定、形状一致),V8 JIT 生成的机器码性能接近 C++。但在实际场景中,JS 有几个固有的性能开销:
  1. 类型检查开销:即使 TurboFan 做了类型特化,IC 和 Hidden Class 检查仍然有开销(1-5ns/次)
  2. 反优化风险:C++ 编译后类型不会变,JS 随时可能触发反优化
  3. GC 开销:C++ 手动管理内存,JS 依赖 GC,GC 停顿会影响延迟
  4. 边界检查:数组访问需要检查越界,C++ 不做这个检查
  5. 函数调用开销:JS 函数调用需要处理 this、arguments、闭包等
但对于 I/O 密集型应用(如 Web 服务器),JS 的瓶颈在网络而非 CPU,JIT 的性能完全够用。V8 的目标不是追平 C++,而是让 JS 快到’不再是瓶颈’。”
答题要点: 给出具体可执行的优化清单,不是泛泛而谈。高分回答: “核心原则:让 V8 的假设不被违反。
  1. 类型稳定:一个函数的参数始终传同一类型。不要同一个函数既传数字又传字符串。
  2. 形状一致:同一用途的对象保持相同的属性和顺序。用 class 或工厂函数统一创建。
  3. 避免 delete:用 obj.x = undefined 替代 delete obj.x。
  4. 避免 arguments:用剩余参数 ...args 替代。
  5. 密集数组:避免空洞,避免类型混杂。
  6. 不要修改原型:Object.prototype.x = ... 会导致所有 IC 失效。
  7. 用 for 替代 for...in:for...in 需要遍历原型链。
  8. 热路径用 class:class 的实例有稳定的隐藏类。

延伸阅读与引用参考