Skip to content
🔗 内容纲要:

深入了解 V8

目录

image

什么是 V8

大部分前端开发人员都会遇到一个流行词:V8。它的流行程度很大一部分是因为它将 JavaScript 的性能提升到了一个新的水平。

是的,V8 很快。但它是如何发挥它的魔力?为什么它反映如此迅速呢?

官方文档指出:V8 是谷歌开源的高性能 JavaScript 和 WebAssembly 引擎,用 C++ 编写。它主要用在 Chrome 和 Node.js 等项目中。

换句话说,V8 是一种 C++ 开发的软件,它将 JavaScript 编译成可执行代码,即机器码(machine code)。

现在,我们开始看得更清楚,Chrome 和 Node.js 只是一个桥梁(bridges),负责把 JS 代码运送到最终的目的地:在特定机器上运行的机器码。

V8 性能的另一个重要角色是它的分代的(generational)和超精确的垃圾收集器(Garbage Collector)。它被优化为使用低内存收集 JavaScript 不再需要的对象(objects)。

除此之外,V8 还依靠一组其他的工具和特性来改进 JS 的一些固有功能(functionalities)。这些功能往往会使 JS 变慢(例如 JS 的动态特性(dynamic nature))。

在本文中,我们将更详细地探讨这些工具(Ignition 和 TurboFan)和特性。除此之外,我们还将介绍 V8 的内部功能(internal functioning)、编译(compilation)和垃圾回收(garbage collection)过程、单线程特性(single-threaded nature)等基础知识。

基础理解

机器码(machine code)是如何工作的呢?简单地说,机器代码是在机器内存的特定部分执行的一组非常低级(底层,low-level)的指令(instructions)。

生成机器码的过程,用 C++ 举例,大概像下面这样:

image

在进一步讨论之前,必须指出这是一个编译过程(compilation),它不同于 JavaScript 解释过程(interpretation)。实际上,编译器(compiler)在进程结束时生成一个完整的程序(program),而解释器(interpreter)作为一个程序本身工作,它通过读取指令(通常是脚本,如 JavaScript 脚本)并将其转换为可执行命令(executable commands)来完成任务。

解释过程可以是动态解析(on-the-fly)(解释器解析并只运行当前命令)或完全解析(fully-parsed)(即解释器在继续执行相应的机器指令之前首先完全翻译脚本)。

回到图中,编译过程通常从源代码(source code)开始。你实现代码,保存并运行。运行的进程依次从编译器开始。编译器是一个程序,和其他程序一样,运行在你的机器上。然后它遍历所有代码并生成对象文件(object files)。那些文件是机器代码。它们是在特定机器上运行的优化代码(optimized code),这就是为什么当你从一个操作系统转移到另一个操作系统时必须使用特定的编译器。

但是你不能执行单独的对象文件,你需要把它们组合成一个文件,即众所周知的.exe 文件(可执行文件)。这是 Linker(链接器)的工作。

最后,Loader 是负责将 exe 文件中的代码传输到操作系统的虚拟内存(virtual memory)中的代理人。它基本上是一个运输工具(transporter)。至此,你的程序终于开始运行了。

听起来是一个漫长的过程,不是吗?

大多数时候(除非你是在银行大型机上使用汇编(Assembly)的开发人员),你会花时间用高级语言编程:Java、C#、Ruby、JavaScript 等。

语言越高级,速度越慢。这就是为什么 C 和 C++ 速度更快,因为它们非常接近机器代码语言:汇编语言(the assembly language)。

除了性能之外,V8 的主要优点之一是超越 ECMAScript 标准并且理解 C++ 可能性。

image

JavaScript 仅限于 ECMAScript。而 V8 引擎,为了生存,必须是兼容而不受限于 ECMAScript。

具有将 C++ 特性集成到 V8 中的能力是非常棒的。由于 C++ 已经发展到非常好的 OS 操作的特殊性,如文件处理和内存 / 线程处理 —— 在 JavaScript 中拥有所有这些能力是非常有用的。

如果你仔细想想,Node.js 它本身也是以类似的方式诞生的。它遵循与 V8 相似的路径,并且添加了服务器(server)和网络功能(networking capabilities)。

单线程

如果你是一个 Node 开发者,你应该很熟悉 V8 的单线程(single-threaded)特性。一个 JS 执行上下文(execution context)与线程(thread)数量成正比。

当然,V8 在后台管理操作系统线程机制(threading mechanism)。它可以多线程工作,因为它是一个复杂的软件,可以同时执行许多任务。

我们有执行代码的主线程(main thread),另一个线程负责编译代码(是的,我们不能在每次编译新代码时都停止执行),还有一些线程负责处理垃圾收集,等等。

但是,V8 为每个 JavaScript 的执行上下文只创建一个单线程(main thread)的环境。其余的都在 V8 的控制之下。

想象一下 JavaScript 代码应该进行的函数调用堆栈。JavaScript 的工作原理是将一个函数堆叠在另一个函数之上,遵循每个函数的插入 / 调用(inserted/called)顺序。在到达每个函数的内容之前,我们无法知道它是否调用其他函数。如果发生这种情况,那么被调用的函数将被放在堆栈中调用者的后面。

例如,当涉及回调(callbacks)时,它们被放在堆栈(pile)的末尾。

管理堆栈组织和进程所需内存是 V8 的主要任务之一。

Ignition 和 TurboFan

自 2017 年 5 月发布的 5.9 版以来,V8 附带了一个新的 JavaScript 执行管道(execution pipeline),它构建在 V8 的解释器 Ignition 之上。它还包括一个更新和更好的优化编译器(optimizing compiler)——TurboFan

这些变化完全集中在整体性能上,以及 Google 开发人员在调整引擎以适应 JavaScript 领域带来的所有快速而显著的变化时所面临的困难上。

从项目一开始,V8 的维护人员就一直在担心如何在 JavaScript 不断发展的同时,找到一种提高 V8 性能的好方法。

现在,我们可以看到新引擎的 Benchmarks 测试结果,已经有了巨大提升:

image

Hidden Classes(隐藏类)

这是 V8 的另一个魔术。JavaScript 是一种动态语言(dynamic language)。这意味着可以在执行期间(execution time)添加、替换和删除新属性。例如,在 Java 这样的语言中,这是不可能的,在 Java 中,所有的东西(类、方法、对象和变量)都必须在程序执行之前定义,并且在应用程序启动后不能动态更改。

由于它的特殊性质,JavaScript 解释器通常基于散列函数(hash function)执行字典查找,以准确地知道这个变量或对象在内存中的分配位置。

这对最后一道工序(final process)来说代价很大。在其他语言中,当对象被创建时,它们接收一个地址(指针,pointer)作为其隐式属性之一。这样,我们就可以准确地知道它们在内存中的位置以及要分配多少空间。

对于 JavaScript,这是不可能的,因为我们无法映射出不存在的内容。这就是 Hidden Classes 发挥作用的地方。

隐藏类与 Java 中的类几乎相同:静态类和固定类(static and fixed classes)具有唯一的地址来定位它们。然而,V8 并不是在程序执行之前执行,而是在运行过程中,每次对象结构发生 “动态变化”(dynamic change)时执行。

让我们看一个例子来说明问题。考虑以下代码片段:

js
function User(name, fone, address) {
   this.name = name
   this.phone = phone
   this.address = address
}
1
2
3
4
5

在 JavaScript 基于原型(prototype-based)的特性中,每次实例化(instantiate)一个新的用户对象时,假设:

js
var user = new User("John May", "+1 (555) 555-1234", "123 3rd Ave")
1

然后 V8 创建一个新的隐藏类。我们称之为 _User0

image

每个对象在内存中都有一个对其类表示(class representation)的引用。它是类指针(class pointer)。此时,由于我们刚刚实例化了一个新对象,所以在内存中只创建了一个隐藏类。它现在是空的。

当你在这个函数中执行第一行代码时,将在上一个基础上创建一个新的隐藏类,这次是 _User1

image

它基本上是具有 name 属性的 User 的内存地址。在我们的示例中,我们没有使用仅将 name 作为属性的 user,但每次这样做时,这就是 V8 将作为引用加载的隐藏类。

name 属性被添加到内存缓冲区(memory buffer)的偏移量 0,这意味着这将被视为最后顺序中的第一个属性。

V8 还将向 _User0 隐藏类添加一个转换值(transition value)。这有助于解释器理解:每次向 User 对象添加 name 属性时,必须处理从 _User0_User1 的转换。

当调用函数中的第二行时,同样的过程再次发生,并创建一个新的隐藏类:

image

你可以看到隐藏类跟踪堆栈。在由转换值(transition values)维护的链中,一个隐藏类通向另一个。

属性添加的顺序决定了 V8 将要创建多少个隐藏类。如果您更改我们所创建的代码段中的行的顺序,那么也将创建不同的隐藏类。这就是为什么有些开发人员试图维护顺序以重用隐藏类,从而减少开销。

Inline Caching(内联缓存)

这是 JIT(Just-in-Time)编译器中非常常见的一个术语。它与隐藏类的概念直接相关。

例如,每当你调用一个函数,将一个对象作为参数传递时,V8 会看到这个动作,然后想:“嗯,这个对象作为参数成功地传递了两次或更多次…… 为什么不把它存储在我的缓存中以备将来调用呢,而不是再次执行整个耗时的隐藏类验证过程(time-consuming-hidden-class-validation process)?”

让我们回顾上一个例子:

js
function User(name, fone, address) { // Hidden class _User0
   this.name = name // Hidden class _User1
   this.phone = phone // Hidden class _User2
   this.address = address // Hidden class _User3
}
1
2
3
4
5

当我们再次将以任意值实例化 User 对象作为参数传递给函数后,V8 将跳转到隐藏类查找并直接转到偏移量的属性。这要快得多。

但是,请记住,如果更改函数中任何属性赋值(attribute assignment)的顺序,则会导致不同的隐藏类,因此 V8 将无法使用内联缓存功能。

这是一个很好的例子,说明开发人员应该更深入地了解引擎。相反,拥有这些知识将有助于代码更好地执行。

Garbage Collecting(垃圾回收)

你还记得我们提到过 V8 在另一个线程中收集内存垃圾吗?这很有帮助,因为我们的程序执行不会受到影响。

V8 使用众所周知的 “标记和扫描(mark-and-sweep,追踪垃圾回收)” 策略来收集内存中的旧的废弃对象。在这种策略中,GC 扫描内存对象以 “标记” 它们以进行收集的阶段有点慢,因为这需要暂停代码执行。

更多详细内容,参见:[Web] Chrome V8 引擎初探 - 夏夜・梦无眠

追踪垃圾回收

在计算机编程中,跟踪垃圾收集(英语: Tracing garbage collection )是一种自动内存管理的算法,该算法通过分析某些 **“根” 对象的引用关系 **,来确定需要保留的可访问对象,并释放其余的不可访问对象的内存空间。该算法在实际的软件工程中得到了广泛的应用。

跟踪垃圾收集是最常见的垃圾收集方式,以至于 “垃圾收集” 通常是指跟踪垃圾收集,而不是引用计数之类的其他方法。

对象的可达性:

单地说,如果可以从任何一个已经定义的变量开始,直接或者通过其他对象的引用来访问到某个对象,则该对象是可访问的。更准确地说,只有以下两种对象是可达的:

  • 程序代码直接定义的变量与对象都是可达的。通常这些对象包括从调用堆栈中任何位置引用的所有对象(即当前正在调用的函数中的所有局部变量和参数)以及任何全局变量
  • 可访问对象引用的任何对象都是可达的。也就是说,可达性是传递闭包的。

但是,V8 是递增的(incrementally),也就是说,对于每个 GC 停顿,V8 尝试标记尽可能多的对象。它使一切变得更快,因为没有必要停止整个执行过程直到收集完成。在大型应用程序中,性能的提高有很大的不同。

参考

版权声明

本文翻译自 A Deep Dive Into V8,版权归原作者所有。

Released under the MIT License.