V8 工作原理(V8 Engine)

JavaScript 引擎 V8 如何编译和执行代码

学习重点

  • 编译器和解释器的区别
  • 抽象语法树(AST)的生成
  • 字节码和即时编译(JIT)
  • V8 的执行流程

1. 编译器和解释器

1.1 为什么需要编译器和解释器

原因:机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码”翻译”成机器能读懂的机器语言。

按语言的执行流程分类

  • 编译型语言:在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件
  • 解释型语言:在每次执行时都需要通过解释器对程序进行动态解释和执行

示例

  • 编译型语言:C/C++、GO、Java(Java 先编译成字节码,然后通过 JVM 解释执行)
  • 解释型语言:Python、JavaScript

1.2 编译器和解释器的执行流程

编译器的执行流程

源代码 → [词法分析] → [语法分析] → AST → [优化代码] → 机器码 → 可执行文件

解释器的执行流程

源代码 → [词法分析] → [语法分析] → AST → [字节码] → [解释执行] → 输出结果

对比

  • 编译器:一次性编译,生成可执行文件,执行效率高
  • 解释器:每次执行都需要解释,执行效率相对较低

2. V8 的执行流程

2.1 V8 的整体架构

V8 的组成

  • 解析器(Parser):将 JavaScript 代码转换为抽象语法树(AST)
  • 解释器(Ignition):解释执行字节码
  • 编译器(TurboFan):将热点代码编译为机器码

V8 的执行流程

JavaScript 代码 → [Parser] → AST → [Ignition] → 字节码 → [TurboFan] → 机器码

工作方式

  1. 生成 AST:解析器将 JavaScript 代码转换为 AST
  2. 生成字节码:解释器 Ignition 根据 AST 生成字节码
  3. 执行字节码:解释器逐条解释执行字节码
  4. 编译热点代码:编译器 TurboFan 将热点代码编译为机器码

2.2 生成抽象语法树(AST)

2.2.1 什么是 AST

定义:抽象语法树(Abstract Syntax Tree,简称 AST)是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构。

特点

  • AST 是编译器或解释器能够理解的结构
  • 无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST
  • 这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似

示例

var myName = '极客时间'
function foo() {
  return 23
}

AST 结构(简化):

Program
├── VariableDeclaration
│   ├── Identifier: myName
│   └── Literal: '极客时间'
└── FunctionDeclaration
    ├── Identifier: foo
    └── BlockStatement
        └── ReturnStatement
            └── Literal: 23

2.2.2 AST 的生成过程

第一阶段:分词(Tokenize)

定义:分词又称为词法分析,其作用是将一行行的源码拆解成一个个 token。

什么是 token:token 指的是语法上不可能再分的、最小的单个字符或字符串。

示例

var myName = '极客时间'

分词结果

[
  { type: 'Keyword', value: 'var' },
  { type: 'Identifier', value: 'myName' },
  { type: 'Punctuator', value: '=' },
  { type: 'String', value: '极客时间' }
]

第二阶段:解析(Parse)

定义:解析又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。

过程

  • 如果源码符合语法规则,这一步就会顺利完成
  • 但如果源码存在语法错误,这一步就会终止,并抛出一个”语法错误”

AST 生成

Token 数据 → [语法分析] → AST

示例

// 语法错误
var myName = '极客时间'
function foo() {
  return 23  // 缺少分号
}

结果:语法分析会终止,抛出语法错误。


2.3 AST 的应用

1. Babel

  • Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转换为 ES5 代码
  • Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码

2. ESLint

  • ESLint 是一个用来检查 JavaScript 编写规范的插件
  • 其检测流程也是需要将源代码转换为 AST,然后再利用 AST 来检查代码规范化的问题

3. Prettier

  • Prettier 是一个代码格式化工具
  • 也是通过 AST 来分析和格式化代码

2.4 生成字节码

2.4.1 什么是字节码

定义:字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

对比

高级代码 → AST → 字节码 → 机器码

字节码的优势

  • 字节码所占用的空间远远小于机器码
  • 使用字节码可以减少系统的内存使用
  • 解释执行字节码的效率比直接解释执行源代码高

示例

源代码大小: 100KB
机器码大小: 1000KB
字节码大小: 200KB

2.4.2 V8 为什么使用字节码

历史原因

  • 一开始 V8 并没有字节码,而是直接将 AST 转换为机器码
  • 由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的
  • 但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了
  • 因为 V8 需要消耗大量的内存来存放转换后的机器码

解决方案

  • V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器
  • 最终花了将近四年的时间,实现了现在的这套架构

优势

  • 使用字节码可以减少系统的内存使用
  • 解释执行字节码的效率比直接解释执行源代码高
  • 可以更好地支持代码优化

2.5 执行代码

2.5.1 解释执行字节码

过程

  • 如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行
  • 在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码
  • 那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码
  • 然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率

示例

function add(a, b) {
  return a + b
}
 
// 第一次执行
add(1, 2)  // 解释执行字节码
 
// 多次执行后,成为热点代码
add(1, 2)  // 执行编译后的机器码(更快)
add(3, 4)  // 执行编译后的机器码(更快)

2.5.2 即时编译(JIT)

定义:即时编译(Just-In-Time compilation,简称 JIT)是指解释器在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

JIT 的工作过程

字节码 → [解释执行] → [收集代码信息] → [发现热点代码] → [编译为机器码] → [保存机器码] → [下次执行机器码]

其他语言

  • Java:使用 JIT 编译技术
  • Python:PyPy 也使用了 JIT 编译技术
  • JavaScript:V8、SpiderMonkey、SquirrelFish Extreme 都使用了 JIT 编译技术

V8 的命名

  • 解释器 Ignition:点火器的意思,寓意代码启动时通过点火器慢慢发动
  • 编译器 TurboFan:涡轮增压的意思,寓意一旦启动,涡轮增压介入,执行效率随着执行时间越来越高效

3. V8 的执行流程总结

3.1 完整的执行流程

流程图

JavaScript 代码
    ↓
[词法分析] → Token
    ↓
[语法分析] → AST
    ↓
[生成执行上下文]
    ↓
[Ignition 解释器] → 字节码
    ↓
[解释执行字节码]
    ↓
[发现热点代码]
    ↓
[TurboFan 编译器] → 机器码
    ↓
[执行机器码]

详细步骤

  1. 词法分析:将 JavaScript 代码拆解成一个个 token
  2. 语法分析:将 token 数据根据语法规则转为 AST
  3. 生成执行上下文:创建执行上下文,包含变量环境、词法环境等
  4. 生成字节码:解释器 Ignition 根据 AST 生成字节码
  5. 解释执行:解释器逐条解释执行字节码
  6. 收集信息:在执行过程中收集代码信息
  7. 编译热点代码:编译器 TurboFan 将热点代码编译为机器码
  8. 执行机器码:下次执行时直接执行编译后的机器码

3.2 性能优化建议

1. 提升单次脚本的执行速度

  • 避免 JavaScript 的长任务霸占主线程
  • 这样可以使得页面快速响应交互

2. 避免大的内联脚本

  • 在解析 HTML 的过程中,解析和编译也会占用主线程
  • 内联脚本会阻塞 HTML 解析

3. 减少 JavaScript 文件的容量

  • 更小的文件会提升下载速度
  • 占用更低的内存

4. 代码优化

  • 避免使用 evalwith 语句(会影响性能优化)
  • 使用 letconst 代替 var(有助于性能优化)
  • 避免在循环中创建函数(会影响性能优化)

4. JavaScript 的性能优化

4.1 V8 的优化策略

过去

  • 在 V8 诞生之初,出现过一系列针对 V8 而专门优化 JavaScript 性能的方案
  • 比如隐藏类、内联缓存等概念都是那时候提出来的

现在

  • 随着 V8 的架构调整,你越来越不需要这些微优化策略了
  • 相反,对于优化 JavaScript 执行效率,你应该将优化的中心聚集在单次脚本的执行时间和脚本的网络下载上

建议

  1. 提升单次脚本的执行速度:避免 JavaScript 的长任务霸占主线程
  2. 避免大的内联脚本:在解析 HTML 的过程中,解析和编译也会占用主线程
  3. 减少 JavaScript 文件的容量:更小的文件会提升下载速度,并且占用更低的内存

4.2 代码优化技巧

1. 避免使用 eval 和 with

// 不好的做法
eval('var x = 1')
with (obj) {
  x = 1
}
 
// 好的做法
var x = 1
obj.x = 1

2. 使用 let 和 const

// 不好的做法
var x = 1
var y = 2
 
// 好的做法
let x = 1
const y = 2

3. 避免在循环中创建函数

// 不好的做法
for (var i = 0; i < 10; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}
 
// 好的做法
for (let i = 0; i < 10; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000)
}

5. 总结

5.1 核心要点

  1. 编译器和解释器:编译器一次性编译,解释器每次执行都需要解释
  2. AST:源代码的语法结构表示,是编译器或解释器能够理解的结构
  3. 字节码:介于 AST 和机器码之间的一种代码,占用空间小,执行效率高
  4. JIT:解释器在解释执行的同时,收集代码信息,将热点代码编译为机器码
  5. V8 的执行流程:词法分析 → 语法分析 → AST → 字节码 → 解释执行 → 编译热点代码

5.2 学习建议

  1. 理解执行流程:理解 V8 如何编译和执行代码
  2. 性能优化:基于执行流程进行性能优化
  3. 工具使用:使用 Chrome DevTools 查看 AST 和字节码

6. 参考资源


javascript V8引擎 编译器 解释器 AST 字节码 JIT