V8 是一套可以嵌入其他程序的 JavaScript 与 WebAssembly 引擎。Chrome 的 DOM、网络请求和页面渲染由 Blink 及浏览器的其他模块负责;Node.js 的文件系统、定时器与事件循环则来自自身的 C++ 绑定和 libuv。V8 专注于语言核心:解析 ECMAScript 源码、执行代码、管理 JavaScript 对象,并在适当的时候把热点代码编译成机器指令。

因此,fetchdocument.querySelectorsetTimeout 都由宿主环境提供。对象模型、闭包、Promise 的语言语义、垃圾回收和即时编译,才是 V8 的工作,也是本文讨论的范围。

V8 的名字来自大排量 V8 发动机。2008 年,它凭借直接生成机器码的设计进入浏览器竞赛。此后十多年,这台引擎重新引入了解释器,又陆续增加三层编译器。不同代码的运行寿命相差太大:有些函数只运行一次,有些循环却会执行数百万次。让它们走同一条编译路径,要么启动太慢、占用太多内存,要么无法获得足够好的峰值性能。

从浏览器大战开始:V8 的早期架构

2008 年 9 月 2 日,Google 在发布 Chrome 的同一天开源了 V8。Firefox 3.5 当时准备采用 TraceMonkey,WebKit 团队也在开发 SquirrelFish Extreme,后来在 Safari 中更名为 Nitro。网页应用日益复杂,JavaScript 的执行速度开始影响浏览器能承载怎样的应用。

初版 V8 没有字节码解释器。源码经过词法分析和语法分析形成抽象语法树(AST),编译器随即生成本机机器码。V8 还使用隐藏类记录动态对象逐渐稳定下来的结构,以内联缓存加速重复的属性访问,再由精确的分代垃圾回收器管理堆内存。这些设计缩短了常见动态操作的访问路径,也让 Chrome 在当时的基准测试中取得了明显优势。

快速生成的代码来不及做太多优化。2010 年,V8 形成了 Full-codegen 与 Crankshaft 配合的两级流水线。Full-codegen 从 AST 生成未优化机器码,让函数尽早开始执行。运行时分析器随后找出频繁调用的热点函数,把收集到的类型反馈交给 Crankshaft,生成执行效率更高的版本。

这套流程确立了 V8 延续至今的基本思路:先运行代码,观察它的实际行为,再决定如何优化。

JavaScript 源码
  -> Parser
  -> AST
  -> Full-codegen
  -> 未优化机器码 + 运行时反馈
  -> Crankshaft
  -> 优化机器码

2013 年末,V8 团队开始设计 TurboFan。初始版本于 2014 年进入代码库,2015 年起承担部分 JavaScript 的优化编译。TurboFan 把编译过程拆成多层中间表示,将高层 JavaScript 操作逐步转换为接近硬件的低层操作,最后映射到具体 CPU 指令。它的早期版本以 Sea of Nodes 图为核心,添加语言特性、优化阶段和硬件后端都比 Crankshaft 更容易。

这条两级路线在脚本规模增长后暴露出不少问题。Full-codegen 必须为每个实际运行的函数生成机器码,哪怕函数只执行一次。V8 团队后来披露,这些基线代码一度占到 Chrome JavaScript 堆的近三分之一。惰性编译可以推迟开销,却省不掉已经运行过的冷代码;现代网页又包含大量只偶尔触发的模块和嵌套函数。

Crankshaft 只能优化 JavaScript 的一部分语法。trycatchfinally 等结构长期无法顺利进入优化路径,class、生成器、解构和词法作用域等 ES2015 特性又需要大量特判。它还为不同 CPU 架构维护着上万行平台相关代码。某些语法或对象形态会突然退出优化路径,甚至在优化和去优化之间反复切换。

V8 需要一个更紧凑的初始代码表示,也需要一套更容易适配新语法和新硬件的编译器架构。

一场自救:字节码与解释器

V8 团队在 2015 年启动 Ignition 项目,并于 2016 年 8 月正式介绍这套解释器。Ignition 把 AST 编译成紧凑的字节码,再逐条执行。官方当时测得,字节码大小约为对应基线机器码的四分之一到二分之一。Chrome 53 首先在内存不超过 512 MB 的 Android 设备上启用 Ignition,单个标签页的内存平均下降约 5%。

给 V8 装回解释器并非简单地回到旧式逐行执行。Ignition 是一台寄存器机:大部分指令显式读写虚拟寄存器,另有一个累加器保存表达式计算中的临时结果。字节码处理器也不用为每种 CPU 手写一套汇编,而是通过 TurboFan 的架构无关低层指令生成。这样既缩小了函数的初始表示,也减少了跨架构维护工作。

字节码很快成为执行系统的共同语言。Ignition 在解释字节码时收集类型与对象形状反馈,优化编译器直接读取字节码和反馈;优化假设失效后,运行状态也能还原到对应的字节码位置。Full-codegen、Crankshaft 和 TurboFan 之间原本复杂的切换逻辑随之简化。

2017 年 5 月,V8 5.9 随 Chrome 59 推出,Ignition 与 TurboFan 全面接管 JavaScript 的执行。Full-codegen 和 Crankshaft 随后从代码库中删除,累计移除了约 13 万行代码。在当时的 ARM64 低内存设备上,未优化代码占用的空间缩小到原来的九分之一;桌面与高端移动设备上的 V8 总体内存占用也下降了约 5% 至 10%。这些结果来自特定版本和负载,却清楚反映了字节码带来的收益。

多快好省:现代 V8 的分层执行

Ignition 启动很快,但每条指令都要经过字节码解码和分派。TurboFan 生成的机器码质量高,编译过程却昂贵。只有足够热、并且还会继续运行的代码,才值得支付这笔成本。两者之间的跨度仍然太大。

2021 年,V8 9.1 引入基线编译器 Sparkplug。它线性遍历 Ignition 字节码,不建立复杂的中间表示,直接生成语义等价的机器码。Sparkplug 沿用与解释器兼容的栈帧,调试器、异常栈、性能分析器和在栈替换机制都能继续工作。编译过程足够便宜,许多函数可以在变热后很快摆脱解释器开销。

Chrome 117 在 2023 年加入 Maglev,继续填补 Sparkplug 与 TurboFan 之间的空档。Maglev 使用控制流图和静态单赋值(SSA)表示,根据已有反馈生成专门化代码,同时保持较短的编译流程。V8 团队的测试显示,它的编译速度约为 TurboFan 的十倍,同时比 Sparkplug 慢约十倍。中等热度的函数由此可以较早获得优化,而不用立即承担 TurboFan 的完整成本。

截至 2025 年,公开的 JavaScript 执行架构可以画成下面这样:

源码
  -> Scanner / Parser / PreParser
  -> AST
  -> BytecodeGenerator
  -> Ignition 字节码
       |  执行并收集反馈
       v
     Sparkplug       快速生成基线机器码
       |
       v
     Maglev          快速优化,覆盖中等热度代码
       |
       v
     TurboFan        端层优化,追求峰值性能
       |
       +-- 后端 CFG IR:Turboshaft
                       |
                       v
                  目标 CPU 机器码

多数函数不会走完整条流水线。一次性的事件分支可能始终由 Ignition 解释,短暂变热的函数也许只到 Sparkplug。V8 会结合运行次数、反馈稳定性和预期收益选择层级,具体阈值随版本、平台和运行参数变化。

图中的 Turboshaft 属于 TurboFan 内部。TurboFan 仍是端层优化编译器;Turboshaft 是 JavaScript 后端使用的控制流图中间表示,用来替换 Sea of Nodes。V8 团队在 2025 年宣布,JavaScript 后端已经全部迁移到 Turboshaft,前端和内建函数中的剩余部分仍在改造。

让 V8 读懂代码:从字符到字节码

源码进入 V8 后,Scanner 先把字符流切成关键字、标识符、字面量和标点等 token。Parser 再按 ECMAScript 文法建立 AST,并分析词法作用域。它还要判断变量应该留在当前栈帧,还是放进堆上的 Context。内部函数一旦捕获外层变量,外层函数返回后仍可能需要这份数据。

网页通常会下载大量并非立即使用的函数。若一开始就为所有函数建立完整 AST 并生成字节码,冷代码会白白消耗启动时间和内存。V8 会让 PreParser 先检查这类函数的语法,记录编译外层作用域所需的信息,暂时跳过完整 AST。函数第一次调用时,Parser 才回来完成余下工作。

PreParser 不能只寻找下一个右花括号。箭头函数、解构、变量遮蔽和闭包捕获都会影响外层变量应当如何分配。它必须记录声明与引用,才能在栈变量和堆上 Context 之间作出正确选择。现代 V8 还会保存紧凑的作用域元数据,避免深层嵌套函数被反复预解析。

完整 AST 随后交给 BytecodeGenerator。它生成 BytecodeArray,同时准备常量池、源码位置表等元数据。常量池保存字面量和字节码引用的对象;源码位置表把字节码偏移映射回源文件的行列,供异常栈和调试器使用。网络脚本可以边下载边解析,大部分解析与字节码编译工作还能放到后台线程,主线程只负责最后的收尾。

执行的艺术:反馈、隐藏类与内联缓存

a + b 翻译成 CPU 加法指令很容易。麻烦在于,V8 在运行前无法知道 ab 是小整数、浮点数、字符串、BigInt,还是会触发自定义类型转换的对象。point.x 也可能是对象内部的固定字段、原型链上的属性、getter,甚至由 Proxy 临时接管。

V8 会先按完整的 JavaScript 语义执行,再记录实际遇到的值。Ignition 字节码关联的 FeedbackVector 保存调用目标、运算类型和属性访问过的对象形状。V8 源码把隐藏类称为 Map;一个 Map 描述对象有哪些属性、属性存在哪里,以及添加新属性后会转移到哪个 Map。

下面的函数第一次执行时必须保留完整的 JavaScript 语义:

function total(point) {
  return point.x + point.y;
}

for (let i = 0; i < 10_000; i++) {
  total({ x: i, y: i + 1 });
}

循环中的对象都按相同顺序创建 xy,通常会共享同一个 Map。point.x 的内联缓存起初没有任何信息。多次看到同一个 Map 后,它会记住一条快速路径:先检查对象形状,再从固定偏移读取字段。只遇到一种形状的调用点称为单态;少量形状可以保留多条多态路径;形状继续增多,调用点便会改用更通用的访问方式。

加法反馈还表明两个字段一直是小整数。Maglev 或 TurboFan 可以据此生成一段很短的机器码:检查对象 Map,从固定偏移取出两个整数,完成加法并检查溢出。原本复杂的动态语义被几条守卫保护起来,常见路径因而跑得很快。

Map 主要记录对象布局,关联的属性描述符还可能保存字段表示和稳定性等信息。它仍是一份运行时结构信息,并不会给对象规定永久不变的静态类型。属性添加顺序不同会产生新的 Map,字段表示变化可能触发泛化,频繁增删属性还会让对象转入字典式存储。优化编译器只为已经观察到的情况生成专门化代码,并用守卫检查这些假设是否仍然成立。

最终优化之后:去优化与在栈替换

假设前面的循环结束后又出现一次调用:

total({ x: "V", y: "8" });

从 JavaScript 代码看,这个对象仍然有 xy 两个属性,字段值却从整数变成了字符串。V8 内部可能需要泛化字段表示或切换 Map,+ 也要改为执行字符串拼接。依赖整数假设的守卫随即失败,V8 放弃当前的优化机器码。这个过程称为去优化(deoptimization)。

Maglev 与 TurboFan 会在潜在的去优化位置附带帧状态元数据,记录怎样把机器寄存器和栈槽里的值还原成解释器使用的虚拟寄存器。守卫失败后,deoptimizer 重建未优化状态,让程序回到 Ignition 或较低层级继续执行。新的类型反馈积累起来后,函数还可能按更宽松的假设再次优化。

去优化是推测优化正常的一环。它允许编译器大胆处理常见情况,同时保证少见输入仍符合 JavaScript 语义。若同一假设反复失效,V8 会调整反馈和分层决策,以免把时间浪费在无效的重新编译上。

层级切换也可以朝相反方向发生。一个函数也许只调用一次,内部循环却要持续很久。等待整个函数返回再使用优化代码已经来不及了。在栈替换(On-Stack Replacement,OSR)可以把当前局部变量和执行位置迁移到高层级代码,让循环从中途开始加速。

代码之外:对象堆与 Orinoco

代码执行时会不断创建对象。JavaScript 开发者不用手动释放它们,V8 只能从栈、全局对象等根节点出发,追踪仍然可达的对象,再回收失去引用的内存。

V8 按对象年龄划分堆。新对象先进入新生代,因为临时数组、闭包和计算中的中间对象往往很快就会失去引用。Minor GC 只关注这一区域;在经典的 Scavenger 路径中,存活对象会被复制到新的空间,多次存活后再晋升到老生代。写屏障会记录从老对象指向新对象的引用,Minor GC 因而不用反复扫描整个老生代。

Major GC 处理整个堆。它标记可达对象,回收死亡对象留下的空间,并根据碎片情况压缩部分页面。Orinoco 项目把许多原本集中在主线程上的工作拆成增量、并行或并发阶段。部分标记可以穿插在 JavaScript 执行间隙,后台线程也能参与标记、清扫和新生代复制。

垃圾回收仍会短暂停住 JavaScript,例如更新根引用或完成部分压缩时。Orinoco 设法缩短并分散这些停顿,同时兼顾吞吐量与内存占用。优化编译器也要准确标出寄存器和栈槽中的堆指针。对象移动后,相关引用必须更新;写入对象字段时,还可能触发写屏障。

64 位平台上的指针压缩进一步降低了对象引用的成本。许多堆指针会保存成相对 4 GiB 基址的 32 位偏移,读取时再恢复为完整地址。JavaScript 源码不会因此变小,堆里的对象、Map 和内部元数据却能少占不少空间。

两种容易混淆的缓存

谈到 V8 缓存时,经常有人说浏览器会“把编译后的二进制文件存进内存或硬盘”。这句话把两套机制混到了一起:代码缓存和启动快照。

Chrome 中的代码缓存也叫字节码缓存。同一个 Isolate 可以按源码复用已经编译的字节码;Chrome 还能把序列化的编译结果作为脚本资源的元数据写入磁盘缓存。再次加载脚本时,V8 反序列化这份数据,省去部分解析和字节码编译。源码、URL、V8 版本或资源缓存状态发生变化,都可能让缓存失效。哪些函数会被收录,也取决于浏览器当时的缓存策略。

TurboFan 为某次运行生成的优化机器码通常不会这样长期保存。它依赖当次运行观察到的对象形状和类型反馈,也受 CPU 特性与引擎内部布局影响,很难直接搬到另一个会话。代码缓存保存的是可以安全恢复的解析与编译结果,主要用来缩短下次启动时间。

启动快照省下的是运行环境初始化。新的 V8 Context 需要内建对象、原型和标准库函数,逐项创建会花费不少时间。V8 会预先构造一份堆状态并序列化为 snapshot,新 Context 启动时直接恢复它。嵌入 V8 的应用也能制作自定义快照,把自己的初始化脚本固化进去。文件句柄、回调以及依赖当前时间的值无法随堆图一起保存。

代码缓存复用某段脚本的编译结果,启动快照复用一套已经初始化的堆。两者都能加快启动,作用对象和失效条件各不相同。

一段 JavaScript 代码的完整旅程

现在可以沿着执行顺序,把这些部件连在一起:

  1. 宿主把源码交给 V8。网络脚本可以在下载尚未结束时开始流式扫描和解析。
  2. Scanner 产生 token,Parser 完整处理立即需要的代码,PreParser 跳过适合惰性处理的函数体。
  3. BytecodeGenerator 把 AST 转为 Ignition 字节码,同时生成常量池、源码位置和反馈槽等元数据。
  4. Ignition 开始执行。对象分配进入 V8 堆,属性访问、算术运算和调用目标的实际行为被记录下来。
  5. 达到相应热度后,Sparkplug 快速把字节码变为基线机器码;更稳定、更热的函数可能继续进入 Maglev 或 TurboFan。
  6. 优化编译器依据 Map 和类型反馈生成专门化代码,并用守卫保护推测。TurboFan 的后端通过 Turboshaft 等阶段完成低层优化、指令选择和寄存器分配。
  7. 假设失效时,V8 根据帧状态去优化;长循环则可能通过 OSR 在执行中途进入更高层级。
  8. Orinoco 持续追踪对象可达性,在新生代和老生代中回收空间。脚本的字节码也可能在长期不用时被清除,之后需要时重新解析编译。
  9. 在合适的宿主策略下,字节码与编译元数据可以进入内存或磁盘代码缓存,为下一次加载减少工作。

同一个函数在一次运行中可能换用多种代码表示:先由 Ignition 解释,变热后进入 Sparkplug,反馈稳定后再交给优化编译器,假设失效时又退回低层级。对象会晋升或被回收,长期不用的字节码也可能从内存中清除。V8 始终根据刚刚观察到的行为调整下一步。

对 JavaScript 开发者意味着什么

这些内部机制不适合直接当成编码规则。隐藏类、分层阈值和缓存策略都可能变化,SpiderMonkey 与 JavaScriptCore 也有自己的实现。针对当前 V8 做晦涩的微优化,很容易损害可读性,换来的性能却未必能保持几个版本。

优化应该先从工作量入手。少发送一段 JavaScript,就能省下一段确定的下载、解析和编译时间。热点循环里少创建一些临时对象,也会减轻执行与垃圾回收压力。结构相近的对象通常更容易被引擎优化,同时也能让数据模型保持清楚。

遇到性能问题,应先用 Chrome DevTools 或 Node.js profiler 找出时间究竟花在脚本执行、编译、布局、I/O 还是垃圾回收上。函数停留在 Ignition 或 Sparkplug 未必是坏事。优化编译本身需要时间和内存,短任务常常来不及收回这笔成本。Maglev 的存在,正说明现实程序中有大量函数处在“解释太慢、完整优化又太贵”的中间地带。

某些平台不允许程序在运行时创建可执行内存,V8 为此提供了 --jitless 模式。用户 JavaScript 此时主要由 Ignition 解释执行,以运行速度换取平台兼容性和更小的攻击面。V8 的能力无法只用 JIT 概括,解释器、对象模型和垃圾回收器同样不可缺少。

始与末

2008 年的 V8 用直接生成机器码证明了 JavaScript 可以跑得很快。引入字节码和解释器后,这个目标没有改变,V8 开始区分哪些代码应当立刻运行,哪些代码值得花时间优化。

一段 JavaScript 的起点很明确,就是宿主交给 V8 的源码字符。此后的路径取决于它自己的表现。冷函数可能始终留在字节码里,热点函数会逐层变成机器码;类型突然变化,优化代码又会被撤销。函数、字节码和对象最终都可能从内存中消失。

回头看 V8 的架构演变,它解决的始终是同一个取舍:让代码尽快开始执行,只在能够收回成本的地方追求更高速度。不同代码需要不同路径,这构成了现代 V8 分层架构的基础。

参考文献

  1. Mathias Bynens. Celebrating 10 years of V8. V8 Blog, 2018
  2. Ben L. Titzer. Digging into the TurboFan JIT. V8 Blog, 2015
  3. Ross McIlroy. Firing up the Ignition interpreter. V8 Blog, 2016
  4. The V8 team. Launching Ignition and TurboFan. V8 Blog, 2017
  5. Leszek Swirski. Sparkplug — a non-optimizing JavaScript compiler. V8 Blog, 2021
  6. Toon Verwaest et al.. Maglev - V8's Fastest Optimizing JIT. V8 Blog, 2023
  7. Darius Mercadier. Land ahoy: leaving the Sea of Nodes. V8 Blog, 2025
  8. Toon Verwaest and Marja Hölttä. Blazingly fast parsing, part 2: lazy parsing. V8 Blog, 2019
  9. Peter Marshall. Trash talk: the Orinoco garbage collector. V8 Blog, 2019
  10. Leszek Swirski. Code caching for JavaScript developers. V8 Blog, 2019
  11. Yang Guo. Custom startup snapshots. V8 Blog, 2015