ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

V8引擎如何执行JavaScript:编译器、解释器与JIT优化

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 选择了混合路线:先解释执行,保证首屏启动够快;同时跟踪每个函数的执行热度,把热点的代码“二次编译”成机器码,让重复执行的部分跑出接近静态语言的速度。这就是 JIT(Just-In-Time)编译。V8 里这个分工特别明确——Ignition 是解释器,负责启动和日常执行;TurboFan 是优化编译器,负责收割热点代码。

这套双轨制从 V8 5.9 版本开始稳定下来。再早一点,V8 用的是 FullCodegen 直接吐机器码加 Crankshaft 做优化,维护两条独立的编译流水线成本极高,所以后来统一收编成了 Ignition + TurboFan 的组合。

3. V8 执行一段 JavaScript 的完整流水线

3.1 第一步:源码变成 Token,再变成 AST

V8 拿到一份 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 Cache,IC)解决的是“这个属性访问点每次都在访问什么对象”。

你可以把 IC 理解成语义缓存。每个属性访问点在字节码里都有一个对应的反馈槽(feedback slot)。第一次执行obj.name时,V8 不知道 obj 长什么样,只能走完整的属性查找路径,然后把查到的结果——obj 的 Map、map 里 name 的偏移量——写到反馈槽里。

后面再执行到同一个obj.name,V8 先检查当前对象的 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 几个必会的调试 flag

V8 本身带了非常完整的“显微镜”参数,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.js

Chrome 的 JavaScript 引擎和 Node 的 V8 大体一致,所以这些参数在浏览器侧也适用,只是一般得通过启动参数或特殊调试模式才能打开,日常排查用 Node 复现问题路径更省事。

6.2 亲手看一次字节码

建一个test.js:

function 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 性能问题,你就有了一套自己的排查框架,而不是靠玄学调参。

返回列表