V8引擎如何执行JavaScript:编译器、解释器与JIT优化 1. 为什么要把编译器和解释器搞明白先说个挺常见的场景很多前端同学写了几年代码天天和 JavaScript 打交道但真要问一句“V8 是怎么把我这段代码跑起来的”大概率只能说个大概——浏览器打开 JS 引擎然后边读边执行完事。但这个回答是错的。V8 从来不是“边读边执行”这么简单。它内部是一条完整的流水线源码先被拆成 token再组装成 AST然后 Ignition 解释器把 AST 转成字节码并执行过程中遇到反复执行的热点函数再交给 TurboFan 编译器优化成机器码。这条链路里的每一步都在决定你的代码跑得快不快。搞懂这条链路最直接的好处就是你能反过来看懂性能报告。比如你在 Chrome Performance 面板里看到一堆 deopt 标记、看到某函数被标成“优化失败”如果你不知道反优化是什么概念根本无从下手。再比如面试的时候被问“闭包为什么会有额外开销”“为什么给对象动态加属性会影响性能”本质上都是在考你对隐藏类和内联缓存的理解。这篇文章适合所有写 JavaScript 的人看不管你是切页面的、写 Node 服务的还是转行学算法的。我会按 V8 执行代码的真实顺序往下讲每个环节都给出原理、例子和验证手段最后再分享几个我实际排查性能问题时踩过的坑。2. 编译器和解释器的区别一段代码的两种活法2.1 从源语言到机器指令中间隔着一层抽象要理解 V8 为什么既要有解释器又要有编译器得先看传统语言是怎么处理的。拿 C/C 举例。你用编译器把源码整体翻译成当前平台的机器码然后跑的是机器码。这个过程叫提前编译AOT好处是运行时没有翻译开销执行速度非常快。坏处是编译一次需要时间而且编译出来的东西只认当前 CPU 架构换个平台就得重新编译。而解释器走的是另一条路不产出目标机器码按抽象语法树或者字节码一条一条执行。启动快跨平台好但因为每条指令都要带一层“翻译逻辑”遇到循环这种反复执行的代码就会很吃亏——同一个加法操作解释器在每次执行的时候都要重复解释一遍。这两条路没有绝对好坏只有适不适合。C/C 这种静态类型语言类型在编译期就确定了可以做大量激进优化AOT 是优选。Python、Ruby 这类动态语言类型随时可变硬做 AOT 要么编译太久要么优化无效所以主流的实现都偏解释执行。2.2 JavaScript 的路线既要启动快又要跑得快JavaScript 面临的约束跟 Python 不太一样。浏览器里一段 JS 从网络到达引擎到开始执行中间能等的窗口非常短。你不可能打开一个网页先等它完整编译 500KB 的脚本再展示内容用户早就走了。所以纯 AOT 不适合前端场景。但纯解释执行也不行。还是拿循环说一个for循环体解释执行 10 万次每轮都重新查类型、查属性、解析指令时间和 CPU 都浪费得离谱。现在的前端应用哪有不跑几百毫秒级计算的。所以 V8 选择了混合路线先解释执行保证首屏启动够快同时跟踪每个函数的执行热度把热点的代码“二次编译”成机器码让重复执行的部分跑出接近静态语言的速度。这就是 JITJust-In-Time编译。V8 里这个分工特别明确——Ignition 是解释器负责启动和日常执行TurboFan 是优化编译器负责收割热点代码。这套双轨制从 V8 5.9 版本开始稳定下来。再早一点V8 用的是 FullCodegen 直接吐机器码加 Crankshaft 做优化维护两条独立的编译流水线成本极高所以后来统一收编成了 Ignition TurboFan 的组合。3. V8 执行一段 JavaScript 的完整流水线3.1 第一步源码变成 Token再变成 ASTV8 拿到一份 JS 源码第一件事不是执行而是“读懂”它。这个阶段分两步词法分析把代码拆成一个个 token。token 可以理解成最小的语法单元比如关键字function、变量名add、数字1、加号。这一步会把空格、注释这类对执行无意义的内容直接丢掉。语法分析再把 token 流组装成抽象语法树AST。AST 是一棵表达程序结构的树根是 Program下面挂着一层一层的声明和表达式。比如const sum add(1, 2)这行代码拆开就是 VariableDeclaration里面再套 CallExpression再往下是标识符和数字字面量。这里有个在前端项目里直接影响性能的细节V8 的解析器分为解析和预解析两种模式。一个脚本里有可能马上执行的代码也有暂不执行的函数定义V8 会对暂不执行的函数做 lazy parsing——只做个轻量扫描不构建完整 AST推迟到真正调用它的时候再全量解析。这个机制对应的常见坑就是 IIFE立即执行函数。你写(function(){ ... })()期望的是创建完立即执行但如果 V8 做了懒解析到真正需要执行时才临时去构建 AST等于绕了一圈远路。所以业界约定俗成的做法是给 IIFE 外面加括号强制 V8 对这个函数做 eager parse预先完整解析。早期 V8 对这类写法非常敏感现在的版本已经做了识别优化但理解这个背景对排查首屏性能问题还是有帮助。你可以用 V8 自带的调试参数看到 AST 长什么样。在 Node 里执行node --print-ast test.js终端会直接打印出语法树的展开结构。3.2 第二步Ignition 把 AST 编译成字节码AST 拿到之后轮到 Ignition 登场。Ignition 不会直接把 AST 编译成目标机器的汇编代码而是把它编译成一种中间表示字节码。字节码是一种面向虚拟机的指令集。每条字节码指令做的都是很细的操作比如把某个值加载到寄存器、执行加法、调用属性等。V8 的字节码是跨平台的不同的 CPU 架构、不同的操作系统执行的都是同一套字节码这大大降低了移植成本。这步设计很妙。原来 V8 把 AST 直接编译成机器码编译耗时长占内存也多现在改成一个“轻量编译器”生成字节码比生成机器码快一两个数量级。字节码体积也比机器码小许多对移动端内存更友好。看一个最简单的例子。假设源代码是const x 1 2;换算成 V8 字节码大致的形状是LdaSmi [1] // 把小整数 1 加载到累加器 AddSmi [2] // 累加器里的值加上小整数 2 Star r0 // 把结果存到寄存器 r0 Return // 返回不同版本 V8 的字节码指令名有出入大体的结构就是这样。每条字节码还带一个“副产品”反馈向量feedback vector。简单说执行到某个属性访问、函数调用、算术操作时V8 会把这里遇到的对象的隐藏类、值的类型范围记录下来。这些反馈信息就是后面 TurboFan 做优化判断的依据。3.3 第三步热点追踪与优化编译的交接Ignition 会带着字节码一直执行下去。执行过程中它会统计每个函数被调用的次数、每次调用传入的参数类型、每条属性访问指令遇到的隐藏类等信息。当一个函数被反复执行比如循环里的核心函数被调用了几万次V8 会把它标记为热点函数交给 TurboFan 去编译成真正的高性能机器码。TurboFan 会基于反馈向量里的历史类型信息做“大胆假设”比如“这个加法操作的两个参数一直是小整数那我可以直接用 CPU 整数加指令跳过类型检查”。假设成立的话这段机器码的运行速度可以达到字节码执行的几倍甚至几十倍。所以整个执行过程是动态的同一个函数刚开机跑的时候是字节码解释执行代码热起来之后同一份源码在执行现场被“升级”成了机器码。这也是很多性能分析工具的曲线会呈现阶段式跳变的原因。4. 隐藏类和内联缓存V8 提速的两个秘密武器4.1 动态对象的“形状”隐藏类JavaScript 对象是动态的可以在任意时候添加或者删除属性。如果属性查找真的每次都去遍历整个对象的属性列表性能会惨不忍睹。V8 的做法是给对象建立一个隐藏类Hidden Class也叫 Map。每创建一个对象V8 会分配一个 Map这个 Map 记录了对象内部有哪些属性、每个属性在内部存储区的偏移量是多少。关键是这个 Map 会被同类结构的对象共享。你的构造函数里统一写了this.name ...; this.age ...那么用这个构造函数创建的每一个实例都共享同一张 Map。属性访问从“遍历查找名字”变成了“查一次 Map按偏移量直接取”速度差别非常大。一旦你破坏了这种统一结构代价就来了function Person(name, age) { this.name name; this.age age; } const p1 new Person(a, 20); const p2 new Person(b, 30); p2.location beijing; // p2 突然多了一个属性执行到第四行时p2 的隐藏类会沿着“属性转换链”走到一个新的 Map 上去从这一刻起 p2 和 p1 的 Map 就不一样了。前面编译好的代码如果假设“Person 实例的 name 字段偏移量是固定的”到这里就会失效甚至直接触发反优化。同样的坑还有delete obj.xxx删除属性会让 Map 发生迁移慢的属性查找和频繁的 deopt 都会找上门。所以我会强烈建议构造函数里把对象需要的属性一次性初始化完整不要创建之后再往外面补非必要不加属性非必要不 delete。4.2 内联缓存记住上一次的查找结果隐藏类只是解决“对象属性存在哪儿”的问题内联缓存Inline CacheIC解决的是“这个属性访问点每次都在访问什么对象”。你可以把 IC 理解成语义缓存。每个属性访问点在字节码里都有一个对应的反馈槽feedback slot。第一次执行obj.name时V8 不知道 obj 长什么样只能走完整的属性查找路径然后把查到的结果——obj 的 Map、map 里 name 的偏移量——写到反馈槽里。后面再执行到同一个obj.nameV8 先检查当前对象的 Map 是否和反馈槽里记录的 Map 一致。一致的话直接按偏移量把 name 取出来这个路径快得接近原生字段访问。IC 的缓存状态分为几种状态含义特点monomorphic单态这个位置只见过一种 Map最快命中即按偏移量取polymorphic多态这个位置见过 2-4 种 Map需要多分支判断还能接受megamorphic超态这个位置见过的 Map 超过 4 种缓存失效退化为完整查找一个常见的多态触发场景是这样的function getName(obj) { return obj.name; } getName({ name: a, age: 1 }); getName({ name: b, age: 2, job: x }); getName({ name: c, type: 3 });三个对象结构各不相同共享函数里的obj.name访问点就会在三种 Map 之间来回切换IC 状态从 monomorphic 一路掉到 polymorphic。当你的核心循环里出现这种“一次调用传入多种对象形态”的代码性能会明显打折扣。最优的写法是在热点代码路径上保持对象形态一致同一个函数入口传入的对象尽量是同一套结构。5. TurboFan 优化编译与反优化5.1 优化假设基于历史数据做激进加速TurboFan 拿到热点函数后会根据反馈向量建立控制流图和数据流图然后生成高度优化的机器码。它的优化思路不是“把字节码翻译成机器码”而是基于历史运行数据做类型推导。举个例子function add(a, b) { return a b; }如果这个函数被反复用整数调用TurboFan 就可能假设a和b都是小整数把整个函数优化成一连串 CPU 级的整型运算指令。加法操作在 JIT 优化的机器码里代价就是一条指令的事。这种假设是 JavaScript 性能能逼近静态语言的关键。但代价也很明显——假设一旦不成立之前优化出来的机器码就不能用了。5.2 反优化假设崩塌的代价所谓的反优化deoptimization也叫 bailout就是当运行时真实数据和 TurboFan 优化时的假设不匹配时V8 不得不抛弃当前的优化机器码回退到解释器继续执行。反优化的场景很典型function add(a, b) { return a b; } for (let i 0; i 100000; i) { add(i, 1); // 让 add 积累了“参数是整数”的反馈 } add(hello, world); // 破坏假设触发 deopt第三次调用之前函数已经被编译成整数加法机器码。但现在传进来的是字符串整数加法指令完全不能处理TurboFan 只能撤掉优化退回 Ignition。更麻烦的是反优化不是只发生一次。优化被打回以后下一个热循环可能会触发它重新优化如果程序里不断改变传入类型就会陷入“优化 → 反优化 → 再优化 → 再反优化”的循环这个函数的整体性能比从一开始就解释执行还差。我在项目里见过的反优化触发原因按频率排大概是这几类同一个函数在不同调用点传入不同类型/结构的对象函数内部创建的对象结构不稳定时多一个属性时少一个属性数组存储的元素类型混用前面是整数后面加字符串在热点函数里使用arguments、eval这类难以静态分析的语言特性把 throw/try-catch 放在热循环内部要精确锁定反优化点最直接的办法是用--trace-deopt这个 flag。它会打印出哪个函数在哪个位置因为什么原因被反优化了。下面一节我会给具体用法。5.3 逃逸分析和闭包还有一个容易忽略的优化点逃逸分析。TurboFan 会分析一个对象是否“逃逸”出了当前函数。如果对象只在这个函数内部使用从不泄露到外部那它就不需要真的在堆上分配内存可以直接优化成寄存器或者栈上分配分配成本几乎为零。这就是为什么现在网上很多“闭包性能差”的说法其实已经过期。V8 做了逃逸分析之后很多闭包在优化路径上根本没有真实的对象分配开销。但前提是你的闭包没有意外逃逸比如被存进了全局数组、被返回给调用方等。一旦逃逸对象只能老实回到堆内存分配优化收益消失。6. 实战用 V8 调试参数看代码执行的真相6.1 几个必会的调试 flagV8 本身带了非常完整的“显微镜”参数Node 直接就能用不需要额外工具# 打印 AST node --print-ast test.js # 打印字节码 node --print-bytecode test.js # 追踪优化过程 node --trace-opt test.js # 追踪反优化过程 node --trace-deopt test.js # 追踪内联缓存状态变化 node --trace-ic test.jsChrome 的 JavaScript 引擎和 Node 的 V8 大体一致所以这些参数在浏览器侧也适用只是一般得通过启动参数或特殊调试模式才能打开日常排查用 Node 复现问题路径更省事。6.2 亲手看一次字节码建一个test.jsfunction add(a, b) { return a b; } add(1, 2);执行node --print-bytecode test.js你会看到一长串输出。除了我们函数的字节码V8 还会打印内置库的字节码需要自己找到add的部分。我在本地跑过很多次类似实验一个简单的a b函数核心字节码基本就是这个画风LdaNamedProperty a0, [1] // 按名字加载属性 Add r0 Return不同 V8 版本的指令名和编号会变化重点看流程不是看具体指令。再跑node --trace-opt test.js如果代码热点不够可能什么都不会打印。想看到优化过程可以把调用次数加大function add(a, b) { return a b; } for (let i 0; i 100000; i) { add(i, 1); }这次输出里会出现类似add 被优化为 TurboFan 函数的日志。这就是解释执行到 JIT 优化机器码的转折点。6.3 制造一次反优化并抓到罪魁祸首把刚才的代码改一下前面的循环保持整数调用后面突然喂一个字符串function add(a, b) { return a b; } for (let i 0; i 100000; i) { add(i, 1); } add(hello, world);执行node --trace-deopt test.js终端会打印出反优化日志内容包括退出的原因、涉及的函数以及当时的代码位置。如果你的关键业务代码里出现大量 deopt 日志基本可以断定性能瓶颈就在这里。不需要背这些日志的格式关键是养成一个习惯怀疑某个函数性能有问题的时候先用 trace 参数确认它到底有没有被优化、为什么被反优化。不要靠猜。7. 那些让 V8 放弃优化的代码写法7.1 不一致的参数类型最典型的是“万能处理函数”。比如一个内部请求处理函数既能接收字符串、又能接收数组、还能接收对象参数类型在不同的调用点换来换去。反馈向量里的类型信息越乱TurboFan 就越难做假设最后干脆不优化。我踩过的一个真实案例一个数据解析函数内部对同一个字段做value.trim()某个页面从接口拿到的数据里这个字段有时是字符串、有时是 null、有时是数字。结果就是每次运行都要走一堆类型检查性能曲线非常难看。改造方法其实简单在函数入口第一时间做类型归一化。null 和 undefined 先转成空字符串数字先格式化保证核心逻辑里拿到的永远是同一个类型。7.2 数组元素类型混用JavaScript 数组不是传统意义的“同构数组”V8 会给每种元素类型专门优化。全整数数组、全浮点数数组、全字符串数组、混合类型数组内部存储格式完全不同访问速度也差很多。一个常见的隐蔽问题const arr []; for (let i 0; i 10000; i) { arr.push(i); // 高频访问全是整数 } arr.push(end); // 末尾混入一个字符串整个数组状态降级一旦混入不同类型V8 不得不把整个数组从整数型存储迁移到通用型存储这个迁移会拖慢所有后续访问而且在优化代码里是可观测的。最好的习惯是一个数组只放一种类型的数据实在要混用考虑用两个数组或者用对象分组。7.3 稀疏数组和洞还有一类问题是稀疏数组。你创建一个长度 10000 的数组只在第 0 位和第 9999 位赋值中间全是洞。V8 对这类数组和密集数组采用完全不同的存储方案遍历速度差距能到数量级。在核心循环里永远要假定 V8 对密集、同构的数组优化最好。需要用到稀疏结构时要么用哈希表要么初始化的时候就把洞填掉。7.4 把 try/catch 塞进热循环throw 和 try/catch 对 TurboFan 来说也是优化毒药。异常路径会破坏控制流分析让优化编译器很难处理。最好的做法是把 try/catch 放在循环外部只包住最外层调用内部循环体保持干净的直线型控制流。7.5 对隐藏类不友好的动态操作前面已经讲过热路径上给对象增加、删除、重排属性都会改变隐藏类。实际项目里最需要注意的地方是表单数据拼装、接口响应的字段统一、渲染框架里频繁创建对象的过程。如果你在某个高频函数里每次都要创建一批对象请务必保证这些对象有完全一致的字段和插入顺序。这是能从 V8 身上拿回不少性能的零成本改造。8. 写在最后一点个人体会很多人觉得性能优化是后端或者 C 程序员的事前端代码反正跑在浏览器里随便写写也能用。但在真实项目跑起来以后一个反复被调用、又被反复反优化的函数真的能让整个页面卡到不可用。我自己的习惯是每次接到性能排查类的问题都会先花十分钟跑一下 trace 参数把 V8 的优化状态和数据摆出来再动手改代码。因为很多性能问题不是“代码逻辑太慢”而是“代码形态让 V8 没办法优化”。搞清楚这一点之后很多优化的改法其实特别朴素保持类型一致、保持对象结构一致、避免语言特性里那些让编译器望而却步的写法。最后再分享一个小技巧。如果你在 Chrome DevTools 的 Performance 录制结果里看到函数名字边上有类似波浪线或者黄色的提示标记大概率就是这个函数被反优化了。不用凭感觉猜直接把那段代码拿到 Node 里跑一遍配合--trace-deopt复现基本都能找到原因。V8 每天都在变具体版本的优化细节和字节码指令也会微调但这套“解释器 JIT 编译器”的骨架、隐藏类和内联缓存的设计思想相当长一段时间内都会是 JavaScript 引擎的底层主线。把这条主线理清楚以后再面对任何 JS 性能问题你就有了一套自己的排查框架而不是靠玄学调参。