深入了解V8

2020-07-02 00:37:27

大多数前端开发人员一直都在使用这个流行语: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文件中的代码传输到操作系统虚拟内存的代理。它基本上是一个运输机。在这里,您的程序终于启动并运行了。

大多数时间(除非您是在银行大型机中使用汇编语言的开发人员),您将花费时间用高级语言进行编程: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的主要任务之一。

从2017年5月发布的5.9版开始,V8附带了一个新的JavaScript执行管道,该管道构建在V8的解释器Ignition之上。它还包括一个更新、更好的优化编译器⁠-turbofan。

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

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

现在,根据最大的基准运行新引擎时,我们可以看到巨大的改进:

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

由于其特殊性质,JavaScript解释器通常根据哈希函数执行字典查找,以确切地知道这个变量或那个对象在内存中的分配位置。

这对最后的过程来说成本很高。在其他语言中,当创建对象时,它们会接收一个地址(指针)作为其隐式属性之一。这样,我们就可以准确地知道它们在内存中的位置以及要分配的空间大小。

使用JavaScript,这是不可能的,因为我们不能映射还不存在的内容。这就是隐藏类占据主导地位的地方。

隐藏类几乎与Java中的相同:静态类和固定类,并使用唯一的地址来定位它们。然而,V8不是在程序执行之前执行,而是在运行时执行,每次我们在对象的结构中有“动态更改”时都会这样做。

让我们看一个例子来澄清一下。请考虑以下代码片段:

功能用户(姓名、姓名、地址){

这个。名称=名称。

这个。电话=电话。

这个。地址=地址。

}。

在JavaScript的基于原型的特性中,每次我们实例化新的User对象时,假设:

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

当您执行此函数中的第一行代码时,将基于前一个隐藏类(这次是_User1)创建一个新的隐藏类。

它基本上是具有Name属性的用户的内存地址。在我们的示例中,我们使用的不只是具有名称的用户作为属性,但是每次这样做时,这是V8将作为引用加载的隐藏类。

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

V8还将向_User0隐藏类添加一个转换值。这有助于解释器理解,每次将Name属性添加到User对象时,都必须处理从_User0到_User1的转换。

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

您可以看到隐藏的类跟踪堆栈。一个隐藏类通向由过渡值维护的链中的另一个隐藏类。

添加属性的顺序决定了V8将创建多少隐藏类。如果更改我们创建的代码片段中各行的顺序,还将创建不同的隐藏类。这就是为什么一些开发人员试图保持重用隐藏类的顺序,从而降低开销。

这是JIT(准时制)编译器世界中非常常见的术语。它与隐藏类的概念直接相关。

例如,每次调用将对象作为参数传递的函数时,V8都会查看此操作并认为:“嗯,此对象被成功地作为参数传递给此函数…两次或更多次。为什么不将其存储在我的缓存中以供将来调用,而不是再次执行整个耗时的隐藏类验证过程呢?“

函数USER(Name,Fone,Address){//Hidden CLASS_User0。

这个。名称=名称//隐藏的CLASS_User1。

这个。Phone=Phone//Hidden CLASS_User2。

这个。ADDRESS=ADDRESS//Hidden CLASS_User3。

}

在向函数发送了两次使用任何值实例化的User对象作为参数之后,V8将跳过隐藏类查找并直接转到偏移量的属性。这要快得多。

但是,请记住,如果更改函数中任何属性赋值的顺序,将会产生不同的隐藏类,因此V8将不能使用内联缓存特性。

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

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

V8使用众所周知的“标记和清除”策略来收集内存中的死对象和旧对象。在此策略中,GC扫描内存对象以“标记”它们以进行收集的阶段有点慢,因为它会暂停执行以实现这一点。

但是,V8是递增执行的,也就是说,对于每个GC停止,V8尝试标记尽可能多的对象。它使一切变得更快,因为在收集完成之前不需要停止整个执行。在大型应用程序中,性能的提高会带来很大的不同。

我希望你喜欢这首曲子。我们的目标是澄清一下V8的结构细节,这些细节经常被误解或忽略。

整件事自然很复杂。而且它还在不断进化。但这些几乎都是核心概念。

在撰写本文时,GitHub回购的数量为15.3k星和2.9k叉。您也可以随意派生开放源代码和更改V8引擎。添加您自己的自定义C++指令,改变处理隐藏类的方式,发挥您的想象力。

但首先,不要忘记仔细阅读官方文件。读得好!

附注:如果你喜欢这篇文章,请订阅我们新的JavaScript魔法列表,每月深入研究更多神奇的JavaScript提示和技巧。

附注:如果您喜欢Node的一体式APM,或者您已经熟悉AppSignal,请访问AppSignal for Node.js。

十多年来,Diogo Souza一直热衷于干净的代码、软件设计和开发。如果他不是在编程或写这些东西,你通常会发现他在看动画片。