V8 是怎样执行 JavaScript 的?Ignition、TurboFan、隐藏类和去优化分别是什么?
V8 先把源码解析成抽象语法树,没调用的函数只做预解析;Ignition 把语法树编译成字节码并解释执行,同时收集类型反馈;热点函数交给 Sparkplug、Maglev、TurboFan 这几层编译器生成机器码。对象按属性的名字和添加顺序共享隐藏类,内联缓存靠隐藏类加速属性访问;优化代码遇到不符合假设的对象就去优化,回到解释器继续执行。
简答
一段 JavaScript 交给 V8,先被解析成抽象语法树。V8 不会把所有函数都完整解析,暂时没调用的函数只做预解析,检查语法、跳过函数体,等第一次调用时再完整解析和编译,这样页面启动更快、占的内存更少。
接着 Ignition 解释器把语法树编译成字节码并逐条执行,执行时把看到的对象形状记录在反馈向量里。函数跑得多了,V8 会把它交给更快的编译层:Sparkplug 几乎不做优化,直接把字节码翻成机器码;Maglev 是 Chrome M117 加入的中间层,编译快、代码也够快;TurboFan 编译最慢,但生成的代码峰值性能最高。
优化编译器能变快,靠的是隐藏类和内联缓存。属性名字和添加顺序都一样的对象,会共用同一个隐藏类,隐藏类记录了每个属性存在哪个位置。某处代码总是遇到同一个隐藏类,V8 就能把“查属性”变成“检查形状,再直接按偏移读取”。
这些优化都基于假设。优化后的代码先检查对象形状,遇到没见过的形状,就会去优化,丢掉这份机器码,回到解释器继续执行,之后可能再重新优化。所以写代码时,让同一处代码处理的对象保持相同的属性和顺序,性能更稳定。
解析:为什么不一次把代码全部编译完
页面加载时,解析和编译都在启动的关键路径上。可是浏览器下载下来的函数,很多在启动时根本用不上。如果全部完整编译,CPU 时间花在了暂时用不到的代码上,生成的代码对象还要占内存。
所以 V8 的解析器遇到函数时,可以只做预解析。预解析器是解析器的精简版本,只做跳过函数所需的最少工作,同时检查被跳过的函数在语法上是否正确。等这个函数第一次被调用,才完整解析,并把语法树编译成字节码。V8 博客把这叫惰性解析,它能加快启动、减少内存占用。
Ignition:字节码和反馈
Ignition 是 V8 的解释器,所有 JavaScript 代码都先编译成 Ignition 字节码,再由它解释执行。Ignition 是寄存器机,每条字节码用寄存器操作数写明输入和输出,另外有一个累加器寄存器作为很多字节码隐含的输入和输出。
解释执行还有一个任务:收集反馈。比如执行 o.x 时,V8 会记下 o 是什么形状。Sparkplug 那篇博客把存这些数据的地方叫反馈向量,用来保存对象形状的信息。后面的优化编译器就靠这些反馈来猜测“这里以后大概还是这种对象”。
从解释器到优化编译器
按 V8 博客的介绍,现在的执行管线分四层:
| 层 | 作用 |
|---|---|
| Ignition | 解释执行字节码,收集反馈 |
| Sparkplug | V8 v9.1 加入,不做优化,几乎立刻把字节码翻成等价的机器码 |
| Maglev | Chrome M117 加入,编译速度约为 Sparkplug 的十分之一、TurboFan 的十倍,生成“足够好”的代码 |
| TurboFan | 着重峰值性能的优化编译器 |
为什么要这么多层?因为编译越慢,代码越快,两头都要顾。只在解释器里跑,热点代码太慢;什么函数都用 TurboFan 编译,编译本身又太费时间。Maglev 在中间,可以比 TurboFan 更早启用,让 TurboFan 推迟到真正值得的时候。V8 博客给出的数据是,这样在 JetStream 上节省了 3.5% 的能耗,在 Speedometer 上节省了 10%。
隐藏类:对象的形状
JavaScript 对象可以随时加删属性,如果每次读属性都按名字去查字典,会很慢。V8 给每个对象关联一个隐藏类(V8 内部叫 Map),里面记录对象有哪些属性、每个属性存在什么位置。V8 博客写明,属性名字相同、添加顺序也相同的对象,共用同一个隐藏类。
给对象加一个新属性,隐藏类就沿着一条“转换”边变成下一个隐藏类。从同一个起点、按同样顺序加属性的对象,会走同一条路,最后停在同一个隐藏类上。顺序不同就会分叉,产生另一串隐藏类。
用 Node.js 22.23.3(V8 12.4)加 --allow-natives-syntax 参数实测,{ x: 1, y: 2 } 和 { x: 3, y: 4 } 的隐藏类相同,{ y: 5, x: 6 } 的不同。这个参数打开的是 V8 内部调试函数,只用于观察,不能写进业务代码。
内联缓存:记住上次在哪里找到属性
隐藏类本身只描述形状,真正让属性访问变快的是内联缓存。代码里每个读写属性的位置,都会记住自己见过哪些隐藏类,以及对应属性的位置。下次遇到同样的隐藏类,就不用再查,直接按记住的位置读。
V8 的博客把只见过一种形状的访问点叫单态(monomorphic),见过多种的叫多态(polymorphic),多态比单态慢。Maglev 那篇文章举了例子:如果 o.x 一直只见过一种形状,Maglev 就生成一个检查,确认 o 还是那个形状,然后直接用便宜的 LoadField 读字段。
去优化:假设不成立时怎么办
优化后的代码是按反馈猜出来的,所以必须能退回去。V8 在可能失败的检查点上保存了解释器的状态信息,检查失败时,去优化器用这些信息重建解释器的栈帧,让函数在 Ignition 里接着执行,结果不会出错,只是慢了。
还是用 Node.js 22.23.3 实测:一个 getX(p) 函数先只用 { x, y } 形状的对象调用,再强制 TurboFan 优化;之后传入 { y, x } 形状的对象,--trace-deopt 输出了一条 reason: wrong map 的去优化记录,函数也不再处于 TurboFan 层。wrong map 就是“隐藏类不对”。
去优化本身不可怕,偶尔发生 V8 会重新收集反馈、重新优化。要注意的是让热点代码反复遇到不同形状。实践中能做的事情很简单:在构造函数或工厂函数里一次把属性按固定顺序初始化好,不要在之后零零散散地加属性;同一个数组里尽量放同一类元素。
常见追问
delete 一个属性会影响性能吗?
V8 的 Fast properties 文章说明,频繁增删属性的对象可能退化成字典模式,属性存在一个哈希表里,而内联缓存对字典模式的属性不起作用,所以访问更慢。需要频繁增删键时,用
Map更合适。
V8 只在浏览器里用吗?
不是。V8 官网写明它用在 Chrome 和 Node.js 等环境中,所以上面说的执行管线、隐藏类和去优化,在 Node.js 里同样存在。不同版本的层级和触发条件会变,具体以对应版本的 V8 博客为准。
垃圾回收也是 V8 负责的吗?
是的,V8 自带垃圾回收器,名为 Orinoco,具体机制和常见泄漏见《垃圾回收为什么无法自动解决所有内存泄漏?》。