v8引擎的运行原理

Excerpt

事实上我们编写的 JavaScript 无论你交给浏览器或者 Node 执行,最后都是需要被 CPU 执行的;但是 CPU 只认识自己的指令集,实际上是机器语言,才能被 CPU 所执行;所以我们需要 JavaScript 引擎帮助我们将 JavaScript 代码翻译


一. 认识 JavaScript 引擎

1.1. 什么是 JavaScript 引擎

当我们编写 JavaScript 代码时,它实际上是一种高级语言,这种语言并不是机器语言。

  • 高级语言是设计给开发人员使用的,它包括了更多的抽象和可读性。

  • 但是,计算机的 CPU 只能理解特定的机器语言,它不理解 JavaScript 语言。

  • 这意味着,在计算机上执行 JavaScript 代码之前,必须将其转换为机器语言。

这就是 JavaScript 引擎的作用:

  • 事实上我们编写的 JavaScript 无论你交给浏览器或者 Node 执行,最后都是需要被 CPU 执行的;

  • 但是 CPU 只认识自己的指令集,实际上是机器语言,才能被 CPU 所执行;

  • 所以我们需要 JavaScript 引擎帮助我们将 JavaScript 代码翻译成 CPU 指令来执行;

比较常见的 JavaScript 引擎有哪些呢?

  • SpiderMonkey:第一款 JavaScript 引擎,由 Brendan Eich 开发(也就是 JavaScript 作者);

  • Chakra:微软开发,用于 IT 浏览器;

  • JavaScriptCore:WebKit 中的 JavaScript 引擎,Apple 公司开发;

  • V8:Google 开发的强大 JavaScript 引擎,也帮助 Chrome 从众多浏览器中脱颖而出;

  • 等等…

1.2. 浏览器内核和 JS 引擎关系

我们前面学习了浏览器内核,那么浏览器内核和 JavaScript 引擎之间是什么样的关系呢?

  • 浏览器内核和 JavaScript 引擎之间有紧密的关系,因为 JavaScript 引擎是浏览器内核中的一个组件。

  • 浏览器内核负责渲染网页,并在渲染过程中执行 JavaScript 代码。

  • JavaScript 引擎则是负责解析、编译和执行 JavaScript 代码的核心组件。

以 WebKit 为例,它是一种开源的浏览器内核,最初由 Apple 公司开发,并被用于 Safari 浏览器中。

  • WebKit 包含了一个 JavaScript 引擎,名为 JavaScriptCore,它负责解析、编译和执行 JavaScript 代码。

WebKit 事实上由两部分组成的:

  • WebCore:负责 HTML 解析、布局、渲染等等相关的工作。

  • JavaScriptCore:解析、执行 JavaScript 代码。

WebKit 内核

看到这里,学过小程序的同学有没有感觉非常的熟悉呢?

  • 在小程序中编写的 JavaScript 代码就是被 JSCore 执行的;

小程序的架构设计

另外一个非常强大的 JavaScript 引擎就是 V8 引擎,也是我们今天要学习的重点。

二. V8 引擎的运行原理

2.1. V8 引擎的官方定义

V8 引擎是一款 Google 开源的高性能 JavaScript 和 WebAssembly 引擎,它是使用 C++编写的。

  • V8 引擎的主要目标是提高 JavaScript 代码的性能和执行速度。

  • V8 引擎可以在多种操作系统上运行,包括 Windows 7 或更高版本、macOS 10.12+以及使用 x64、IA-32、ARM 或 MIPS 处理器的 Linux 系统。

V8 引擎可以作为一个独立的应用程序运行,也可以嵌入到其他 C++应用程序中,例如 Node.js。

  • 由于 V8 引擎的开源性和高性能,许多现代浏览器都使用了 V8 引擎或其修改版本,以提供更快、更高效的 JavaScript 执行体验。

2.2. V8 引擎如何工作呢?

2.2.1. V8 引擎的工作过程

我这里先给出一副 V8 引擎的工作图:

  • 后续我们会一点点解析它的工作过程

V8 引擎的工作图

整体流程如下:(先简单了解)

  1. 词法分析:
  • 首先,V8 引擎将 JavaScript 代码分成一个个标记或词法单元,这些标记是程序语法的最小单元。

  • 例如,变量名、关键字、运算符等都是词法单元。

  • V8 引擎使用词法分析器来完成这个任务。

  1. 语法分析:
  • 在将代码分成标记或词法单元之后,V8 引擎将使用语法分析器将这些标记转换为抽象语法树(AST)。

  • 语法树是代码的抽象表示,它捕捉了代码中的结构和关系。

  • V8 引擎会检查代码是否符合 JavaScript 语言规范,并将其转换为抽象语法树。

  1. 字节码生成:
  • 接下来,V8 引擎将从语法树生成字节码。

  • 字节码是一种中间代码,它包含了执行代码所需的指令序列。

  • 字节码是一种抽象的机器代码,它比源代码更接近机器语言,但仍需要进一步编译成机器指令。

  1. 机器码生成:
  • 最后,V8 引擎将生成机器码,这是一种计算机可以直接执行的二进制代码。

  • V8 引擎使用即时编译器(JIT)来将字节码编译成机器码。

  • JIT 编译器将字节码分析为代码的热点部分,并生成高效的机器码,以提高代码的性能。

2.2.2. V8 引擎的架构设计

V8 引擎本身的源码非常复杂,大概有超过 100w 行 C++代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:

Parse 模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;

Ignition 是一个解释器,会将 AST 转换成 ByteCode(字节码)

  • 同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);

  • 如果函数只调用一次,Ignition 会执行解释执行 ByteCode;

  • Ignition 的 V8 官方文档:https://v8.dev/blog/ignition-interpreter

TurboFan 是一个编译器,可以将字节码编译为 CPU 可以直接执行的机器码;

  • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan 转换成优化的机器码,提高代码的执行性能;

  • 但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;

  • TurboFan 的 V8 官方文档:https://v8.dev/blog/turbofan-jit

另外,V8 引擎还包括了垃圾回收机制,用于自动管理内存的分配和释放。V8 引擎使用了一种名为“分代式垃圾回收”(Generational Garbage Collection)的技术,它将堆区分成新生代和老年代两个部分,分别使用不同的垃圾回收策略,以提高垃圾回收的效率。

  • 内存管理我们后续再单独来讨论学习。

2.3. V8 的转化代码过程

比如我们有如下一段代码,V8 引擎是如何一步步帮我们转化的呢?

1
2
3
4
5
6
7
8
9
const name = "coderwhy";

console.log(name);

function sayHi(name) {
console.log("Hi " + name)
}

sayHi(name)

下面是官方给出的一个图解:

官方图例

2.3.1. 词法分析的过程

词法分析是将 JavaScript 代码转换成一系列标记的过程,它是编译过程的第一步。

  • 在 V8 引擎中,词法分析器会将 JavaScript 代码分解成一系列标识符、关键字、操作符和字面量等基本元素,以供后续的语法分析和代码生成等步骤使用。

这里仅仅举一个例子,作为参考即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Token(type='const', value='const')
Token(type='identifier', value='name')
Token(type='operator', value='=')
Token(type='string', value='"coderwhy"')
Token(type='operator', value=';')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='function', value='function')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value='{')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='string', value='"Hi "')
Token(type='operator', value='+')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='operator', value='}')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')

2.3.2. 语法分析的过程

接下来我们可以根据上面得到的 tokens 代码,进行语法分析,生成对应的 AST 树。

在 V8 引擎中,语法分析的过程可以分为两个阶段:解析(Parsing)和预处理(Pre-parsing)。

解析阶段是将 tokens 转换成抽象语法树(AST)的过程,而预处理阶段则是在解析阶段之前进行的,用于预处理一些代码,如函数和变量声明等。

对于你提供的 JavaScript 代码,V8 引擎的解析和预处理过程如下所示:

V8 引擎的解析和预处理过程如下所示:

  1. 预处理阶段
  • 在预处理阶段,V8 引擎会扫描整个代码,查找函数和变量声明,并将其添加到当前作用域的符号表中。

  • 在这个过程中,V8 引擎会同时进行词法分析和语法分析,生成一些中间表示,以便后续使用。

  • 对于我们的代码,预处理阶段不会生成任何 AST 节点,因为它只包含了一个常量声明和一个函数声明,而没有变量声明(var 声明的变量)。

  1. 解析阶段
  • 在解析阶段,V8 引擎会将 tokens 转换成 AST 节点,生成一棵抽象语法树(AST)。

  • AST 是一种树形结构,用于表示程序的语法结构,它包含了多种类型的节点,如表达式节点、语句节点和声明节点等。

转化的 AST 树代码参考:

1
2
3
4
5
6
7
Program 
└── VariableDeclaration (const name = "coderwhy"
└── ExpressionStatement (console.log(name)) 
└── FunctionDeclaration (function sayHi(name) { ... })     
└── BlockStatement         
└── ExpressionStatement (console.log("Hi " + name)) 
└── ExpressionStatement (sayHi(name))

从 AST 树中可以看出,整个程序由一个 Program 节点和三个子节点组成。

  • 其中,第一个子节点是一个 VariableDeclaration 节点,表示常量声明语句;

  • 第二个子节点是一个 ExpressionStatement 节点,表示 console.log 语句;

  • 第三个子节点是一个 FunctionDeclaration 节点,表示函数声明语句。

  • FunctionDeclaration 节点包含一个 BlockStatement 子节点,表示函数体,其中包含一个 ExpressionStatement 节点,表示 console.log 语句。

  • 最后一个子节点是一个 ExpressionStatement 节点,表示调用函数语句。

2.3.3. 转化的字节码(了解)

根据上面得到的 AST 树,我们可以将其转换成对应的字节码。在 V8 引擎中,字节码是一种中间表示,用于表示程序的执行流程和指令序列。

V8 引擎会将 AST 树转换成如下的字节码序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## 字节码指令集
[Constant name="coderwhy"]
[SetLocal name]
[GetLocal name]
[LoadProperty console]
[LoadProperty log]
[Call 1]
[Constant Hi]
[GetLocal name]
[BinaryOperation +]
[Call 1]
[SetLocal sayHi]
[GetLocal name]
[GetLocal sayHi]
[Call 1]
[Return]

根据上面生成的字节码,我们可以看到 V8 引擎生成的字节码指令集,每个指令都对应了一种操作,如 Constant、SetLocal、GetLocal 等等。下面是对字节码指令集的解释:

  • Constant:将常量值压入操作数栈中。

  • SetLocal:将操作数栈中的值存储到本地变量中。

  • GetLocal:将本地变量的值压入操作数栈中。

  • LoadProperty:从对象中加载属性值,并将其压入操作数栈中。

  • Call:调用函数,并将返回值压入操作数栈中。

  • BinaryOperation:对两个操作数执行二元运算,并将结果压入操作数栈中。

  • Return:从当前函数中返回,并将返回值压入操作数栈中。

由于字节码是一种中间表示,它可以跨平台运行,在不同的操作系统和硬件平台上都可以执行。这种跨平台的特性,使得 V8 引擎成为了一款非常流行的 JavaScript 引擎。

在 Node 环境中,我们可以通过如下命令查看到字节码:

  • 但是默认 Node 环境下是打印所有的字节码的,所以内容会非常多(了解即可)
1
node --print-bytecode test.js

2.3.4. 生成的机器码(了解)

在 V8 引擎中,机器码是通过即时编译(Just-In-Time Compilation,JIT)技术生成的。

  • JIT 编译是一种动态编译技术,它将字节码转换成本地机器码,并将其缓存起来以提高代码的执行速度和性能。

  • JIT 编译器可以根据运行时信息对代码进行优化,并且可以根据不同的平台和硬件生成对应的机器码。

在 V8 引擎中,机器码的生成过程分为两个阶段:

  • 预编译(pre-compilation)和优化(optimization)。

  • 预编译阶段会生成一些简单的机器码,用于快速执行代码;

  • 优化阶段则会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。

具体的生成过程如下:

  1. 预编译阶段
  • 在预编译阶段,V8 引擎会生成一些简单的机器码,用于快速执行代码。

  • 这些机器码是基于字节码生成的,它们可以直接执行,并且具有一定的优化效果。

  • 在这个阶段,V8 引擎会根据代码的运行时信息生成一些简单的机器码,如对象和数组的存取、字符串的拼接、函数的调用等。

  1. 优化阶段
  • 在优化阶段,V8 引擎会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。

  • 在这个阶段,V8 引擎会通过分析代码的执行路径、类型信息、控制流程等,生成一些高效的机器码,并且可以进行多次优化,以获得更高的性能。

在优化阶段,V8 引擎会使用 TurboFan 编译器来生成机器码。

  • TurboFan 是一个基于中间表示(Intermediate Representation,IR)的编译器,它可以将字节码转换成高效的机器码,并且可以进行多层次的优化,包括基于类型的优化、内联优化、控制流优化、垃圾回收优化等。

通过机器码的生成过程,我们可以看到 V8 引擎是如何根据代码的运行时信息生成高效的机器码,并且可以多次优化,以获得更高的性能。

  • 在后续的执行过程中,V8 引擎会将机器码缓存起来,以提高代码的执行速度和性能。

三. V8 引擎的内存管理

3.1. 认识内存管理

不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。

不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期

  • 第一步:分配申请你需要的内存(申请);

  • 第二步:使用分配的内存(存放一些东西,比如对象等);

  • 第三步:不需要使用时,对其进行释放;

不同的编程语言对于第一步和第三步会有不同的实现:

  • 手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);

  • 这种方式需要程序员手动管理内存,容易出现内存泄漏和野指针等问题,程序的稳定性和安全性有一定的风险。

  • 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们有自动帮助我们管理内存;

  • 在这些语言中,存在垃圾回收机制来自动回收不再使用的内存空间,程序员只需要正确地使用变量和对象等引用类型数据,垃圾回收器就会自动进行内存管理,释放不再被引用的内存空间。

  • 这种方式可以避免内存泄漏和野指针等问题,提高了程序的稳定性和安全性。

对于开发者来说,JavaScript 的内存管理是自动的、无形的。

  • 我们创建的原始值、对象、函数……这一切都会占用内存;

  • 但是我们并不需要手动来对它们进行管理,JavaScript 引擎会帮助我们处理好它;

3.2. JS 的内存管理

在 JavaScript 中,内存分为栈内存和堆内存两种类型。

  • 栈内存用于存储基本数据类型和引用类型的地址,它具有自动分配和自动释放的特点。

  • 堆内存用于存储引用类型的对象和数组等数据结构,它需要手动分配和释放内存。

在 JavaScript 中,使用 var、let 和 const 声明的变量都是存在栈内存中的。

  • 当我们声明一个变量时,JavaScript 引擎会在栈内存中为其分配一块空间,并将变量的值存储在该空间中。

  • 当变量不再被引用时,JavaScript 引擎会自动将其释放掉,以回收其空间。

在 JavaScript 中,创建的对象和数组等引用类型数据都是存在堆内存中的。

  • 当我们创建一个对象时,JavaScript 引擎会在堆内存中为其分配一块空间,并将其属性存储在该空间中。

  • 当对象不再被引用时,垃圾回收器会自动将其标记为垃圾,并回收其空间。

为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。

在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如 free 函数:

  • 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;

  • 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露和野指针的情况;

  • 影响程序的稳定性和安全性,同时也会影响编写逻辑代码的效率;

所以大部分现代的编程语言都是有自己的垃圾回收机制:

  • 垃圾回收的英文是 Garbage Collection,简称 GC;

  • 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;

  • 而我们的语言运行环境,比如 Java 的运行环境 JVM,JavaScript 的运行环境 js 引擎都会内存 垃圾回收器

  • 垃圾回收器我们也会简称为 GC,所以在很多地方你看到 GC 其实指的是垃圾回收器;

但是这里又出现了另外一个很关键的问题:GC 怎么知道哪些对象是不再使用的呢? 这里就要用到 GC 的实现以及对应的算法;

3.3. 常见的 GC 算法

3.3.1. 引用计数(Reference counting)

引用计数(Reference counting)是一种常见的垃圾回收算法。

  • 它的基本思想是在对象中添加一个引用计数器。

  • 每当有一个指针引用该对象时,引用计数器就加一。

  • 当指针不再引用该对象时,引用计数器就减一。

  • 当引用计数器的值为 0 时,表示该对象不再被引用,可以被回收。

引用计数算法的优点是实现简单,垃圾对象的回收及时,可以避免内存泄漏。

但是引用计数算法也有一些缺点。

  • 最大的缺点是很难解决循环引用问题。

  • 如果两个对象相互引用,它们的引用计数器永远不会为 0,即使它们已经成为垃圾对象。

  • 这种情况下,引用计数算法就无法回收它们,导致内存泄漏。

循环引用循环引用

3.3.2. 标记清除(mark-Sweep)

标记清除(mark-Sweep)是一种常见的垃圾回收算法,其核心思想是可达性(Reachability)。算法的实现过程如下:

  1. 设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象。

  2. 对于每一个找到的对象,标记为可达(mark),表示该对象正在使用中。

  3. 对于所有没有被标记为可达的对象,即不可达对象,就认为是不可用的对象,需要被回收。

  4. 回收不可达对象所占用的内存空间,并将其加入空闲内存池中,以备将来重新分配使用。

标记清除算法可以很好地解决循环引用的问题,因为它只关注可达性,不会被循环引用的对象误判为可用对象。

标记清除算法

但是这种算法也有一些缺点,最主要的是它的效率不高,因为在标记可达对象和回收不可达对象的过程中需要遍历整个对象图。

此外,标记清除算法还会造成内存碎片的问题,因为回收的内存空间不一定是连续的,导致大块的内存无法被分配使用。

3.3.3. 其他算法优化补充

S 引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于 V8 引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。

标记整理(Mark-Compact)

  • 和“标记-清除”相似;

  • 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;

分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。

  • 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;

  • 那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;

增量收集(Incremental collection)

  • 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。

  • 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;

闲时收集(Idle-time collection)

  • 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

  • 这种算法通常用于移动设备或其他资源受限的环境,以确保垃圾收集对用户体验的影响最小。

3.3.4. V8 引擎的内存图

事实上,V8 引擎为了提供内存的管理效率,对内存进行非常详细的划分。(详细参考视频学习)

这幅图展示了一个堆(heap)的内存结构,下面是对每个内存块的解释:

  • Old Space(老生代):分配的内存较大,存储生命周期较长的对象,比如页面或者浏览器的长时间使用对象;

  • New Space(新生代):分配的内存较小,存储生命周期较短的对象,比如临时变量、函数局部变量等;

  • Large Object Space(大对象):分配的内存较大,存储生命周期较长的大型对象,比如大数组、大字符串等;

  • Code Space(代码空间):存储编译后的函数代码和 JIT 代码;

  • Map Space(映射空间):存储对象的属性信息,比如对象的属性名称、类型等信息;

  • Cell Space(单元格空间):存储对象的一些元信息,比如字符串长度、布尔类型等信息。

这些不同的内存块都有各自的特点和用途,V8 引擎会根据对象的生命周期和大小将它们分配到不同的内存块中,以优化内存的使用效率。

V8 引擎的内存图

更多内容,关注公众号:coderwhy 或者添加我的微信:coderwhy666


文章转载于coderwhy | JavaScript 高级系列(二) - V8 引擎的运行原理