大多数前端开发人员一直在处理这个流行词:V8。它之所以受欢迎,很大一部分原因在于它将 JavaScript 带到了一个新的性能水平。V8快吗,的确非常快。但是,它是如何发挥它的魔力的,为什么它反应如此迅速?
官方文档称“ V8 是 Google 的开源高性能 JavaScript 和 WebAssembly 引擎,用 C++ 编写。它用于 Chrome 和 Node.js 等”。换句话说,V8 是一个用 C++ 开发的软件,它把 JavaScript 翻译成可执行代码,即机器码。
在这个顿悟的时刻,我们开始更清楚地看待事物。Google Chrome 和 Node.js 都只是将 JavaScript 代码传输到其最终目的地的桥梁:在该特定机器上运行的机器代码。
V8性能发挥的另一个重要作用,就是它的代际超准垃圾收集器。它经过优化以使用低内存收集 JavaScript 不再需要的对象。
除此之外,V8 依靠一组其他工具和特性来改进一些固有的 JavaScript 功能,这些功能在历史上会使语言变慢(例如它的动态特性)。
在本文中,我们将更详细地探讨这些工具(Ignition 和 TurboFan)和功能。不仅如此,我们还将介绍 V8 的内部功能、编译和垃圾收集过程、单线程特性等基础知识。
从基础开始
机器码是如何工作的?简而言之,机器代码是在机器内存的特定部分执行的一组非常低级的指令。
生成它的过程,使用C++语言作为参考,类似这样:
在继续之前,重要的是要指出这是一个编译过程,它不同于 JavaScript 解释过程。事实上,编译器在进程结束时生成一个完整的程序,而解释器本身作为一个程序工作,它通过读取指令(通常作为脚本,如 JavaScript 脚本)并将它们翻译成可执行命令来完成工作。
解释过程可以即时发生(解释器解析并仅运行当前命令)或完全解析(即解释器在继续执行相应的机器指令之前首先完全翻译脚本)。
回到图中,如您所知,编译过程通常从源代码开始。您实现代码,保存并运行。反过来,运行过程从编译器开始。编译器是一个程序,就像任何其他程序一样,在您的机器上运行。然后它遍历所有代码并生成目标文件。这些文件是机器代码。它们是在该特定机器上运行的优化代码,这就是为什么当您从一个操作系统迁移到另一个操作系统时必须使用特定编译器的原因。
但是您不能执行单独的目标文件,您需要将它们组合成一个文件,即众所周知的.exe文件(可执行文件)。这是链接器的工作。
最后,加载程序是负责将该exe文件中的代码传输到操作系统的虚拟内存的代理。它基本上是一个运输工具。在这里,您的程序终于启动并运行了。
听起来是一个漫长的过程,不是吗?
大多数时候(除非您是在银行大型机中使用 Assembly 的开发人员),您会花时间使用高级语言进行编程:Java、C#、Ruby、JavaScript 等。
语言越高,速度越慢。这就是 C 和 C++ 快得多的原因,它们非常接近机器代码语言:汇编语言。
除了性能之外,V8 的主要优势之一是可以超越ECMAScript 标准并理解例如 C++:
JavaScript 仅限于 ECMAScript。而V8,为了存在,必须兼容但不限于此。
能够将 C++ 特性整合到 V8 中是很棒的。由于 C++ 已经发展到非常擅长操作系统的特殊性——比如文件操作和内存/线程处理——将所有这些功能掌握在 JavaScript 手中是非常有用的。
如果你仔细想想,Node.js 本身就是以类似的方式诞生的。它遵循与 V8 类似的路径,加上服务器和网络功能。
单线程
如果您是一名 Node 开发人员,您将熟悉 V8 的单线程特性。每个 JavaScript 执行上下文都与一个线程成正比。
当然,V8 在后台管理 OS 线程机制。它可以与多个线程一起工作,因为它是一个复杂的软件,并且可以同时执行很多东西。
我们有一个执行代码的主线程,另一个编译代码(是的,我们不能在每次编译新代码时停止执行),还有一些处理垃圾收集,等等。
然而,V8 为每个 JavaScript 的执行上下文创建了一个单线程环境。其余的都在它的控制之下。
想象一下您的 JavaScript 代码应该进行的函数调用堆栈。JavaScript 的工作原理是将一个函数堆叠在另一个函数之上,按照每个函数被插入/调用的顺序。在到达每个函数的内容之前,我们无法知道它是否调用了其他函数。如果发生这种情况,那么被调用的函数将被放置在堆栈中调用者之后。
例如,当涉及回调时,它们被放置在堆的末尾。
管理此堆栈组织和进程所需的内存是 V8 的主要任务之一。
Ignition 和 TurboFan
这些变化完全集中在整体性能和 Google 开发人员在调整引擎以适应 JavaScript 世界带来的所有快速且相当大的变化时所面临的困难。
从项目一开始,V8 维护者就一直担心找到一种好的方法来提高 V8 的性能,同时 JavaScript 也在不断发展。
现在,在针对最大基准运行新引擎时,我们可以看到巨大的改进:
自2017 年 5 月发布的5.9 版本以来,V8 附带了一个新的 JavaScript 执行管道,该管道构建在V8 的解释器Ignition之上。它还包括一个更新更好的优化编译器— TurboFan。
您可以在此处和此处阅读有关 Ignition 和 TurboFan的更多信息。
隐藏类
这是 V8 的又一魔术。JavaScript 是一种动态语言。这意味着可以在执行期间添加、替换和删除新属性。这对于像 Java 这样的语言是不可能的,例如,所有东西(类、方法、对象和变量)都必须在程序执行之前定义,并且在应用程序启动后不能动态更改。
由于其特殊性质,JavaScript 解释器通常基于散列函数执行字典查找,以准确了解该变量或该对象在内存中的分配位置。
这对最终过程来说成本很高。在其他语言中,当对象被创建时,它们会接收一个地址(一个指针)作为它们的隐含属性之一。这样,我们就可以准确地知道它们在内存中的位置以及要分配多少空间。
使用 JavaScript,这是不可能的,因为我们无法映射尚不存在的内容。这就是隐藏阶级统治的地方。
隐藏类与 Java 中的几乎相同:静态类和固定类,它们具有唯一的地址来定位它们。但是,V8 不会在程序执行之前执行此操作,而是会在运行时执行此操作,每次我们对对象的结构进行“动态更改”时。
让我们看一个例子来澄清事情。考虑以下代码片段:
function User(name, fone, address) {
this.name = name;
this.phone = phone;
this.address = address;
}
在 JavaScript 基于原型的特性中,每次我们实例化一个新User
对象时,假设:
var user = new User("John May", "+1 (555) 555-1234", "123 3rd Ave");
然后 V8 创建一个新的隐藏类。让我们称之为_User0
。
个对象都有一个对其在内存中的类表示的引用。这是类指针。此时,由于我们只是实例化了一个新对象,因此在内存中只创建了一个隐藏类。它现在是空的。
当你在这个函数中执行第一行代码时,将在前一个的基础上创建一个新的隐藏类,这次是_User1
.
它基本上是该name
属性被添加到内存缓冲区的偏移量 0 中,这意味着这将被视为最终顺序中的第一个属性。
V8 还会为_User0
隐藏类添加一个过渡值。这有助于解释器理解,每次将name
属性添加到对象时,都必须处理User
从_User0
to的转换。_User1
当函数中的第二行被调用时,同样的过程再次发生并创建了一个新的隐藏类:
User
具有name
属性的 a 的内存地址。在我们的示例中,我们不使用仅将名称作为属性的用户,但每次执行此操作时,V8 都会加载隐藏类作为引用。
您可以看到隐藏的类跟踪堆栈。一个隐藏类导致由转换值维护的链中的另一个隐藏类。
添加属性的顺序决定了 V8 将创建多少隐藏类。如果您更改我们创建的代码片段中的行顺序,也会创建不同的隐藏类。这就是为什么一些开发人员试图维护重用隐藏类的顺序,从而减少开销。
内联缓存
这是 JIT(即时)编译器世界中非常常见的一个术语。它与隐藏类的概念直接相关。
每次调用函数传递一个对象作为参数,例如,V8 看看这个动作就会想:“嗯,这个对象被成功地作为参数传递给这个函数两次或更多......为什么不把它存储在我的缓存以供将来调用,而不是再次执行整个耗时的隐藏类验证过程?”
让我们回顾一下我们的最后一个例子:
编译器世界中非常常见的一个术语。它与隐藏类的概念直接相关。
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
}
在将该User
对象以任何值作为参数作为参数发送两次后,V8 将跳转隐藏类查找并直接转到偏移量的属性。这要快得多。
但是,请记住,如果您更改函数中任何属性分配的顺序,则会导致不同的隐藏类,因此 V8 将无法使用内联缓存功能。
这是一个很好的例子,表明开发人员不应该避免更深入地了解引擎。相反,拥有这些知识将有助于您的代码更好地执行。
User
对象以任何值作为参数作为参数发送两次后,V8 将跳转隐藏类查找并直接转到偏移量的属性。这要快得多。
垃圾收集
你还记得我们提到 V8 在不同的线程中收集内存垃圾吗?所以,这很有帮助,因为我们的程序执行不会受到影响。
V8 使用众所周知的“ mark-and-sweep ”策略来收集内存中的死对象和旧对象。在这种策略中,GC 扫描内存对象以“标记”它们以进行收集的阶段有点慢,因为它会暂停执行以实现它。
但是,V8 是增量式的,也就是说,对于每次 GC 停止,V8 都会尝试标记尽可能多的对象。它使一切变得更快,因为在收集完成之前无需停止整个执行。在大型应用程序中,性能改进会产生很大的不同。