一段 JavaScript 代码的一生:V8 完整工作流
你每天写 JavaScript但有没有想过当你敲下一行let x a 1到它真正在 CPU 上跑起来、再到它占用的内存被自动回收中间到底发生了什么这个问题的核心的就是javasacript引擎当前v8引擎是javascript引擎中使用最广的引擎Chrome 和 Node.js都用它先用一句话概括整个工作流源码经 Scanner 切成 token、Parser 建成 AST懒解析省启动→ Ignition 生成字节码并解释执行、同时把类型和形状记进 Feedback Vector → 代码变热则沿 Sparkplug→Maglev→TurboFan 逐级投机优化靠隐藏类、elements kind、内联缓存让优化可持续→ 假设被打破则 deopt 回退 → 全程对象存在堆上、由分代并发 GCOrinoco自动回收指针压缩省内存。① 词法分析源码变成 token你的 JS 源码本质上只是一长串字符。引擎要理解它第一步是词法分析lexical analysis / scanning——由Scanner扫描器逐字符读源码切分成一个个有意义的最小单元叫token词法单元。举例源码let x a 1;会被切成这些 tokenlet → 关键字 x → 标识符 → 赋值运算符 a → 标识符 → 加法运算符 1 → 数字字面量 ; → 分号Scanner 干的就是把字符流变成 token 流滤掉空格、注释这些无意义的东西。这一步看似简单但因为它要处理源码的每一个字符V8 在这里做了不少优化比如对常见 token 的快速识别、UTF-8/UTF-16 编码处理等。名词澄清词法分析Scanner 产出 token和语法分析Parser 消费 token是两个连续的步骤合起来常被笼统称为解析parsing。但严格说 Scanner 在前、Parser 在后。② 语法分析token 变成 ASTParser解析器拿到 token 流按 JS 的语法规则组装成一棵ASTAbstract Syntax Tree抽象语法树。AST 是代码的结构化表示。还是a 1它会变成这样一棵小树BinaryExpression () / \ Identifier NumericLiteral (a) (1)树的结构精确表达了这是一个加法运算左操作数是变量 a右操作数是数字 1。整个程序就是一棵大 AST。懒解析lazy parsing网页里大量函数在启动时根本不会立刻执行。如果启动就完整解析每个函数建完整 AST是浪费。所以 V8 对暂时不执行的函数只做预解析pre-parse由更快的Preparser快速略读一遍只搞清函数边界在哪、有无语法错误、声明了哪些变量但不建完整 AST。等函数真正被调用时才做完整解析。如果一个被懒解析的函数最终还是执行了就得再解析一遍有额外成本。V8 用启发式判断哪些该懒、哪些该立刻解析尽量避免解析两遍。从 IIFE 到 Explicit Compile Hints——要不要急切编译的控制权演进这里有一条值得展开的脉络。V8 有一条括号启发式看到函数被小括号包起来也就是 IIFE 的写法就猜这个函数很可能马上执行于是对它急切解析编译而非懒解析。这个机制官方叫PIFEPossibly Invoked Function Expression可能被调用的函数表达式。(function(){...})()// 括号让 V8 猜这个要立即执行急切编译它急切编译为什么划算关键在编译发生在哪个线程急切编译能放在后台线程、和网络下载并行进行等你要用时代码已就绪而懒编译会在函数首次被调用时卡在主线程上现场编译阻塞交互。页面启动时主线程本就繁忙这一卡就影响首屏。但 PIFEIIFE 那套有明显缺陷它靠括号这个脆弱的词法特征触发容易被打包/压缩工具改写破坏而且它强迫你用函数表达式而非函数声明多一次赋值、略慢还不能用于 ES6 的 class 方法。所以 PIFE 从来不是个可靠的优化手段。为此V8 在Chrome 1362025 年 4 月推出了它的正规替代品——Explicit Compile Hints显式编译提示。你在文件顶部加一行魔法注释就能明确告诉 V8这个文件里的函数加载时都会用到请急切编译//# allFunctionsCalledOnLoadfunctionfoo(){...}// 会被急切解析和编译functionbar(){...}// 同样它比 IIFE/PIFE 更明确、无副作用可以正常写函数声明、可用于 class 方法。V8 团队的实测20 个流行网页里 17 个有提升前台解析编译时间平均减少约 630ms。但必须省着用它是用少量前期急切编译成本换加载收益无脑给所有文件都加会把加载时根本不调用的函数也急切编译了浪费 CPU 和内存、适得其反。只对几乎所有函数都会在加载时调用的核心文件应用主入口、关键库用它。另外三个注意点目前只能到文件级函数级方案还在开发未来会配合 PGO 画像引导它是Chromium 独有、非标准Firefox/Safari 会忽略这行注释且很多打包工具会删注释用前要确认工具链保留了它。所以这条线的准确说法是懒解析是 V8 的默认省启动策略IIFE/PIFE 曾是覆盖这个默认、强制急切编译的技巧但脆弱且有副作用Explicit Compile Hints 是它的正规化替代品——更明确、更可靠但仍需谨慎使用。③ 生成字节码AST 变成 bytecodeIgnitionAST 还是太抽象不能直接执行。V8 的解释器Ignition把 AST 转成字节码bytecode——一种介于源码和机器码之间的中间指令。为什么要字节码不直接生成机器码因为编译成机器码很花时间还要分析整个脚本、推断所有类型。对只执行一两次的代码如页面初始化逻辑不划算。字节码生成快、体积小比机器码小得多省内存——这正是 Ignition 当初为省安卓内存而设计的初衷非常适合快速启动。字节码长什么样Ignition 是基于寄存器的解释器——它有一组虚拟寄存器注意不是 CPU 物理寄存器还有一个特殊的累加器accumulator寄存器存中间结果。字节码由 BytecodeGenerator 遍历 AST 生成。一个简单的a b大致会编译成这样的字节码简化示意Ldar a // Load Accumulator from Register把 a 加载进累加器 Add b, [0] // 把 b 和累加器相加[0] 是反馈向量槽位用来记录这次运算遇到的类型 Return // 返回累加器的值注意Add后面那个[0]——它是反馈向量Feedback Vector的槽位索引。字节码在执行加法的同时会把这次运算的操作数是什么类型记进这个槽位供后续优化编译器参考这正好引出下一节的收集反馈。很多人以为解释器只是傻跑其实它边跑边埋优化的种子。每条字节码指令都很小、很快。Ignition 就是逐条读取、分发、执行这些指令——这个逐条解释的过程比跑机器码慢但胜在启动即用。④ 解释执行 收集反馈埋下优化的种子Ignition 执行字节码时不只是跑还同时在观察和记录——记录每个操作实际遇到的类型、每个对象的形状、每个函数调用的目标等。这些运行时反馈存在一个叫Feedback Vector反馈向量旧称 Type Feedback Vector的结构里挂在每个函数上。为什么要收集反馈因为 JS 是动态类型的——a b里的 a、b 可能是数字、字符串、对象……引擎没法静态知道。但如果它观察到这个函数被调用一万次每次 a、b 都是数字它就能大胆假设以后大概也是数字据此生成只处理数字的高效代码。这种基于观察到的运行时行为做假设的优化叫投机优化speculative optimization。Feedback Vector 就是投机优化的依据来源。它记录的关键信息包括属性访问遇到的对象隐藏类、属性在内存的偏移量、函数调用的实际目标、运算遇到的类型等。后面的优化编译器Maglev、TurboFan都要读它来做投机优化。注Sparkplug 不做投机优化但它生成的基线代码里仍内嵌了靠 Feedback Vector 工作的内联缓存——所以它不是完全不碰 Feedback Vector只是不用它做投机。这一步是承上启下的关键解释执行既完成了让代码先跑起来又为后续怎么优化攒下了数据。⑤ 分层编译代码变热逐级升级四层流水线V8 一边执行、一边统计每段代码的执行次数。执行得越多的代码越热越值得花成本优化。V8 按热度把代码沿着四层流水线逐级升级这个过程叫分层递进优化tiering up。四层的定位阈值是近似的传统默认值仅供建立数量级直觉见表后说明层级角色特点升级阈值近似、非固定Ignition解释器启动快、省内存、执行慢起点Sparkplug基线编译器极快编译、不优化调用约 8 次Maglev中层优化编译器快速编译、中等优化调用约 500 次且反馈稳定TurboFan顶级优化编译器慢编译、深度优化调用约 6000 次升级阈值有三点需要注意这些是传统默认阈值不是固定不变的硬规则。它们随 V8 版本、平台、以及是否触发其他策略而变。不同资料给出的数字也不完全一致有的写成 ~10–100 / ~100–1000 / ~1000 的区间。所以请把它们当作数量级直觉个位数 → 几百 → 几千而不是可依赖的精确数值。升级到 Maglev/TurboFan 还要求反馈稳定不只看次数。如果调用过程中反馈变了比如出现了新的对象形状计数器会清零重来。所以次数是必要条件、不是充分条件。2025 年 V8 引入了 Profile-Guided Tiering画像引导分层正在改变这套固定阈值——它记录上一轮运行里哪些函数变热升级了下一轮让这些函数更早甚至第一次调用就升级而不再让每个函数都从头傻等固定次数。所以上面这些数字是它对比和优化的旧基线不是新规则。Sparkplug 为什么快它从字节码而非源码编译且不生成任何中间表示IR一趟过直接出机器码。本质是把 Ignition 字节码转译成 CPU 机器码消除了解释器逐条分发指令的开销。它不优化所以能大范围铺开、稳定提速。Maglev 用什么优化它建一个SSA静态单赋值 CFG控制流图的中间表示读 Feedback Vector 做投机优化编译速度约比 Sparkplug 慢 10 倍、比 TurboFan 快 10 倍。TurboFan 用什么优化它历史上用Sea of Nodes节点之海这种把控制流和数据流统一成一张图的灵活 IR做最激进的优化内联、死代码消除、类型特化等生成接近手写 C 的机器码。代价是编译最慢所以只用在最热的代码上。但用了十多年后V8 团队认为 Sea of Nodes 对 JS 和 Wasm 而言弊大于利它结构复杂、对 CPU 缓存不友好、可读性差、还会打乱指令调度从大约 2022 年起开始用一个更传统的CFG控制流图based IR——代号 Turboshaft来替换它。到 2025 年TurboFan 的整个 JavaScript 后端已改用 TurboshaftWebAssembly 全流程也用 Turboshaft。Turboshaft 架构更现代、更易扩展新优化某些场景编译速度快一倍还带来省电。关键名词——投机优化speculative optimization与内联inlining投机优化基于 Feedback Vector 的观察做假设。比如观察到某函数总收到数字就生成假定是数字的快速路径跳过通用类型检查。内联inlining把被频繁调用的小函数的代码直接塞进调用处省去函数调用的开销调用栈切换等。这是 TurboFan 最重要的优化之一——所以小而高频、类型稳定的函数最容易被优化。⑤隐藏类、elements kind、内联缓存优化能生效的地基上面说优化依赖 Feedback Vector 里记录的对象形状但形状是怎么被高效表示和利用的这就是对象模型三件套。隐藏类Hidden Class / 源码里叫 MapV8 给每个对象关联一个隐藏类记录它的形状——有哪些属性、每个属性的内存偏移量。形状相同的对象共享同一个隐藏类。加属性时隐藏类沿一条转换链演进{}是 C0加 x 到 C1加 y 到 C2另一个对象若以相同顺序加相同属性会走完全相同的链、最终共享 C2。共享隐藏类V8 才能把它们当作同一种结构来优化。elements kind数组元素类型隐藏类的数组版。V8 追踪数组元素类型从快到慢PACKED_SMI紧凑小整数→PACKED_DOUBLE紧凑浮点→PACKED_ELEMENTS紧凑任意对象每种还有带洞的HOLEY_*版本。两条单向降级规则类型只会从具体降到通用、不可逆紧凑一旦出现洞就降级成 HOLEY、也不可逆。内联缓存Inline Cache, IC这是隐藏类的最佳搭档也是 Feedback Vector 记录信息的主要用途之一。设想反复执行point.x。第一次执行时V8 查了一遍“这个对象的隐藏类是 C2x 在偏移量 0。”内联缓存把这个结果缓存下来下次再执行到这行如果传进来的对象还是 C2 隐藏类就跳过查找、直接用缓存的偏移量取值——这叫缓存命中极快。IC 有几种状态反映了它见过的形状数量monomorphic单态只见过一种隐藏类。最快缓存稳定命中。polymorphic多态见过几种通常 2-4 种隐藏类。V8 缓存多个稍慢。megamorphic巨态见过太多种隐藏类V8 放弃缓存退回慢速的通用查找。最慢。所以隐藏类和 IC 是共生的隐藏类让形状相同的对象可识别IC 则赌下次还是同一个形状来跳过查找。你的代码越是让相同形状的对象走同一段逻辑IC 越倾向 monomorphic性能越好反之形状五花八门IC 退化成 megamorphic优化全失效。⑥ deopt 去优化假设被打破时的回退投机优化建立在假设上而 JS 是动态的假设随时可能被打破。这时必须去优化deoptimizationdeopt——丢弃优化过的机器码退回到字节码由 Ignition 重新解释执行。什么会触发 deopt类型突变某函数一直收到数字突然传了个字符串——假定是数字的机器码失效。对象形状突变优化代码假定对象是隐藏类 C2突然来了个不同形状的对象——形状守卫map check失败。数组 elements kind 突变优化代码假定是紧凑数组突然出现了洞或混入了别的类型。deopt 的代价不只是退回慢速执行这一下。退回后V8 可能要重新收集反馈、重新优化一来一回成本不小。更糟的是如果代码反复在优化 → deopt → 再优化 → 再 deopt之间横跳性能会持续糟糕——这种情况叫deopt loop去优化循环是性能杀手。名词澄清deopt 里有个概念叫guard守卫——优化后的机器码里会插入一些检查点如检查这个对象的隐藏类是不是 C2。守卫通过就继续跑快速路径守卫失败就触发 deopt。所以优化代码不是无脑快而是带着守卫地快。⑦ 内存管理栈、堆与垃圾回收代码执行的全程都伴随内存的分配和回收。栈Stackvs 堆Heap栈存放函数调用的执行上下文、局部的基本类型值如数字、布尔、以及对堆对象的引用指针。栈是自动管理的——函数调用就压栈、返回就弹栈不需要 GC。速度极快。堆存放对象object、array、function 等引用类型。堆内存由GC 自动回收。我们讨论的所有 GC、隐藏类、指针压缩管的都是堆。一句话基本类型和引用在栈上对象实体在堆上栈随函数调用自动清理堆靠 GC 回收。V8 有逃逸分析等优化某些对象可能被优化成栈上分配或标量替换但心智模型上先这样理解。GC 的核心标准——可达性从一组根全局对象、当前调用栈上的变量等出发凡是能通过引用链触达的对象就是活的触达不到的就是垃圾。内存泄漏的本质就是——你以为不用了的对象却仍被某条引用链意外连着导致 GC 不敢回收。分代回收Orinoco基于大多数对象朝生暮死的观察堆分两代新生代新对象、预期短命空间小。用ScavengeCheney 算法——把新生代分两半只用一半回收时把存活对象复制到另一半、整块清空原来那半。“空间换时间”因为存活对象少、要复制的不多所以极快。熬过几轮还活着的对象晋升到老生代。老生代长期存活的对象空间大。用Mark-Sweep标记-清除 Mark-Compact标记-整理——从根标记所有可达对象、清除未标记的、再把存活对象挪到一端消除碎片。减少全停顿的三招务必分清全停顿Stop-The-WorldGC 时冻结整个程序为的是拿到稳定一致的堆来安全清点。它保证正确性但导致卡顿。增量incremental把 GC 拆成小片穿插进主线程空隙把一次长停顿变成多次短停顿。并行parallel主线程 多个辅助线程同时做 GC主线程仍停但停得短。并发concurrent辅助线程在主线程照常跑 JS 的同时后台做标记主线程几乎不停。靠三色标记 写屏障write barrier保证正确对象分白未访问灰访问中黑访问完主线程每次改引用触发写屏障通知 GC防止漏标。现代 V8 组合使用这些新生代并行 Scavenge老生代并发标记 并行清理整理把卡顿压到极低。指针压缩64 位系统指针占 8 字节很费内存。V8 发现堆通常不超 4GB于是只存 32 位偏移量相对基地址指针压到 4 字节堆内存降约 40%。配套的SmiSmall Integer把小整数用带标记的 31 位直接内联不在堆上分配。可变堆数字Mutable Heap Numbers2025延续 Smi 的思路V8 近年又优化了浮点数的存储。JS 里不是 Smi 小整数的那些浮点数要作为对象存在堆上叫HeapNumber过去每次数字变化都可能要新建一个 HeapNumber有开销。V8 让某些场景如 script context 里的变量的堆数字变得可原地修改mutable避免反复分配新对象减少内存和 GC 压力。V8 的这套垃圾回收器如今已不只服务 JavaScript2023 年起的WasmGC让 WebAssembly 也能用 V8 的 GC 管理对象于是 Java、Kotlin、Dart、C# 这些带 GC 的语言能高效编译到 Wasm 跑在 web 上而不必各自打包一个垃圾回收器。V8 早已是JS WebAssembly双栖引擎底层大量共享包括 GC 在内的基础设施。写 JS 代码的实操技巧理解了上面这套工作流就能推导出一批顺着引擎写的技巧。每条都标注了它对应工作流的哪个环节。1. 保持对象形状稳定对应隐藏类 / IC在构造函数或对象字面量里一次性、按固定顺序初始化所有属性别东一个西一个地后加。顺序一致才能共享隐藏类。别用delete删属性——它破坏对象形状导致隐藏类失效甚至降级成字典模式。要清空就赋nullobj.x null不改变形状。别给对象动态加删属性到热路径上。一个常见的真实事故给配置对象有条件地加一个可选属性凭空制造出一种新形状污染了内联缓存逼 V8 丢弃优化代码、CPU 飙升。2. 让函数专一参数类型稳定对应投机优化 / deopt尽量让一个函数每次接收的参数类型一致。一会儿传数字、一会儿传字符串会让投机优化失效、触发 deopt。避免在同一个函数里让同名变量在数字和其他类型间反复横跳。3. 数组要紧凑和专一对应 elements kind别在一个数组里混放类型别[1, 2, three, {}]这样混保持元素类型一致全数字、或全对象。别制造洞别跳索引赋值arr[100] x而前面是空的、别用delete arr[i]、别随意设arr.length撑大数组。这些都会让紧凑数组降级成 HOLEY、且不可逆。预先知道大小时用new Array(n)后顺序填充或直接用字面量。4. 善用小而高频的函数对应内联TurboFan 会内联小而高频、类型稳定的函数。所以不必为了减少函数调用而手动把代码堆成一大坨——写清晰的小函数引擎会帮你内联。5. 破除一些过时的优化迷信对应架构演进老经验里别在热函数里用try/catch来自 2017 年前的老架构。现代 V8 里错误处理已高度优化应该先写正确清晰的代码只在 profiler 证明它是瓶颈时才改。别用某某语法因为慢这类经验很多已随架构演进失效。以实际 profiling 为准别迷信过时结论。6. 警惕内存泄漏对应 GC 可达性顺着该断的引用链没断排查这四类闭包意外长期持有大对象、忘记removeEventListener、游离的 DOM 引用DOM 移除了但 JS 变量还引用着、忘记clearInterval的定时器。用 Chrome DevTools 的 Memory 面板抓堆快照看哪类对象只增不减用 Retainers 顺引用链找到是谁还拽着它。7. 最重要的一条先写对再优化上面所有技巧的前提是——优化的第一顺位永远是算法和数据结构而不是迎合引擎。这些 V8 层面的技巧只在热路径 profiler 证明是瓶颈时才值得动用。为了微优化而牺牲代码可读性绝大多数时候得不偿失。附关键术语速查这篇文章出现了不少 V8 术语集中列一下方便回查也是面试前速记的好材料AST抽象语法树源码解析后的结构化表示。IgnitionV8 的解释器把 AST 转成字节码并解释执行。字节码bytecode介于源码和机器码之间的中间指令生成快、体积小。Feedback Vector反馈向量记录运行时类型、对象形状等反馈是投机优化的依据。分层递进优化tiering up代码越热越沿 Ignition → Sparkplug → Maglev → TurboFan 逐级升级。Sparkplug / Maglev / TurboFan基线编译器 / 中层优化编译器 / 顶级优化编译器。Sea of Nodes / TurboshaftTurboFan 的旧 IR / 正在替换它的新 CFG-based IR。投机优化speculative optimization基于反馈做类型假设生成快速路径。deopt去优化假设被打破时丢弃优化代码、退回字节码。隐藏类Hidden Class / Map记录对象形状形状相同的对象共享它。elements kind数组元素类型越具体紧凑越快降级不可逆。内联缓存IC缓存属性访问的查找结果状态分单态 / 多态 / 巨态。Scavenge / Mark-Sweep-Compact新生代 / 老生代的垃圾回收算法。全停顿Stop-The-WorldGC 时冻结程序用增量 / 并行 / 并发来削减它。Smi / 指针压缩小整数内联表示 / 把指针从 8 字节压到 4 字节的内存优化。一句话记住整条主线V8 用解释器保证启动快 分层 JIT 按热度递进优化 隐藏类和内联缓存让优化可持续 分代并发 GC 自动管理内存让动态、解释型的 JavaScript 跑出了接近编译型语言的速度。而你的代码写法直接决定能不能吃到这些优化红利。

相关新闻