[译] 在V8引擎中JavaScript是如何工作的

lxf2023-03-14 10:58:01

本文正在参加「 . 」

原作者:Ilya Lyamkin

原文链接:www.freecodecamp.org/news/javasc…

今天我们来深入了解JavaScript的V8引擎,并弄清楚JavaScript是如何执行的。

在之前的文章中,我们了解了浏览器的结构,并对Chromium 有了一个高层次的概览。让我们来回顾一下,这样有利于我们进行更深入的研究。

背景

Web标准是一系列浏览器实现的规则。它们定义和描述了万维网的各个方面。

W3C是一个为Web领域开发开放标准的国际组织。他们确保每个开发者都遵循相同的准则,而无须支持许多完全不同的环境。

现代浏览器是一个相当复杂的软件,它的代码库有数千万行代码。所以它被分成了很多负责不同逻辑的模块。

浏览器最重要的两个部分是JavaScript引擎和渲染引擎。

Blink是一个渲染引擎,负责整个渲染管线(包括DOM树、样式、事件和V8集成),并解析DOM树,解析样式,并确定所有元素的视觉几何形状。

在通过动画帧持续监控动态变化的同时,Blink会将内容绘制在屏幕上。JS引擎是浏览器的一个重要组成部分——但我们还没有讨论到这些细节。

JavaScript引擎101

JavaScript引擎执行JavaScript并将其编译成原生机器代码。每个主流浏览器都开发了自己的JS引擎:谷歌的Chrome使用V8,Safari使用JavaScriptCore,Firefox使用SpiderMonkey。

本文使用的是V8,因为它在Node.js和Electron中可以使用,但其他引擎的构建也是类似的。

每个步骤都有一个指向负责该步骤的代码链接,这样您就可以熟悉代码库,并且可以继续本文之外的研究。

我们使用,因为它提供了一个方便和知名的UI来浏览代码库。

准备源代码

V8需要做的第一件事是下载源代码。可以通过网络、缓存或service worker来完成。

一旦获取到代码,我们需要以编译器能够理解的方式对其进行更改。这个过程称为解析,由两部分组成:扫描器和解析器本身。

扫描器获取JS文件并将其转换为内置的令牌列表。在keywords.txt文件中有一个所有JS令牌的列表。

解析器拿到令牌列表,然后创建抽象语法树(AST):以树形来表示源代码。树的每个节点表示代码中出现的一个结构。

让我们看一个简单的例子:

function foo() {

let bar = 1;

return bar;

}

这段代码将生成以下树结构:

[译] 在V8引擎中JavaScript是如何工作的

抽象语法树示例

可以通过执行前序遍历(根,左,右)来执行这段代码:

1.  定义foo函数

2.  声明bar变量

3.  把1赋值给bar

4.  从函数中返回bar。

您还将看到VariableProxy—一个将抽象变量连接到内存中某个位置的元素。解析VariableProxy的过程称为作用域分析。

在我们的示例中,该过程的结果将是所有VariableProxys都指向相同bar变量。

即时编译机制

通常,要运行代码,就需要将编程语言转换为机器代码。对于如何以及何时发生这种转变,有几种方法

转换代码最常见的方法是执行预编译。它的工作正如它的字面意思:在编译阶段执行程序之前,代码被转换为机器代码。

这种方法被许多编程语言使用,比如C++、Java和还有一些其他语言。

此外需要说明一下:代码的每一行都将在运行时执行。动态类型语言(如JavaScript和Python)通常采用这种方法,因为在执行之前不可能知道确切的类型。

因为预编译可以一起评估所有代码,所以它可以提供更好的优化,并最终生成性能更好的代码。另一方面,解释实现起来更简单,但它通常比编译好的代码更慢。

为了更快更有效地转换动态语言的代码,创建了一种称为即时(JIT)编译的新方法。它充分结合了解释和编译。

在使用解释作为基本方法时,V8可以检测到使用频率较高的函数,并使用以前执行的类型信息编译它们。

然而,类型可能会发生变化。我们需要对编译后的代码去优化,转而回退到解释(之后,我们可以在获得新的类型反馈后重新编译函数)。

让我们来更详细地探讨JIT编译的每个部分。

解释器

V8使用一个名为Ignition的解释器。最初,它采用抽象语法树并生成字节码。

字节码指令也有元数据,例如用于将来调试的源行位置。通常,字节码指令与JS抽象相匹配。

现在让我们为上面的例子手动生成字节码:

LdaSmi #1 // write 1 to accumulator

Star r0 // read to r0 (bar) from accumulator

Ldar r0 // write from r0 (bar) to accumulator

Return // returns accumulator

Ignition有一个叫做累加器的东西——一个你可以存/取值的地方。

这个累加器避免了入栈和出栈的需要。它也是许多字节码的隐式参数,通常保存操作的结果。Return隐式返回累加器。

您可以在相应的源代码中检出所有相关字节码。如果你对其他JS概念(如循环和async/await)如何在字节码中呈现感兴趣,我发现阅读这些例子很有用。

执行

在生成后,Ignition使用一个由字节码键控的处理程序表来解释指令。对于每个字节码,Ignition可以查找相应的处理程序函数并传入提供的参数,然后执行。

正如前面提到的,执行阶段还提供了代码的类型反馈。让我们来搞明白它是如何收集和管理的。

首先,我们应该讨论JavaScript对象是如何在内存中表示的。一个简单的方法是,可以为每个对象创建一个字典并将其链接到内存。

[译] 在V8引擎中JavaScript是如何工作的

第一个存储对象的方法

然而,我们通常有很多具有相同结构的对象,因此存储大量重复的字典效率不高。

为了解决这个问题,V8使用Object Shapes (或内部的映射)和内存中的值向量将对象的结构与值本身分离。

例如,我们创建一个对象字面值:

let c = { x: 3 }

let d = { x: 5 }

c.y = 4

第一行中,生成一个结构Map[c],其属性为x,偏移量为0。

第二行中,V8将为一个新变量重用相同的结构。

第三行中,为属性y创建一个偏移量为1的新结构Map[c1],并创建一个到前一个结构Map[c]的链接。

[译] 在V8引擎中JavaScript是如何工作的

物体结构示例

在上面的例子中,你可以看到每个对象都有一个指向对象形状的链接,对于每个属性名,V8可以在内存中找到值的偏移量。

对象结构本质上是链表。如果你写c.x, V8会去到列表的头,在那里找到y,移动到连接的结构,最后获取x并从中读取偏移量。然后它会去内存向量并返回它的第一个元素。

可想而知,在大型web应用中,你会看到大量相互连接的形状。同时,在链表中搜索需要线性时间,这使得属性查找成为非常昂贵的操作。

为了在V8中解决这个问题,你可以使用内联缓存(Inline Cache, IC)。它会记住在哪里查找对象属性的信息,以减少查找的次数。

您可以将其视为代码中的监听站点:它跟踪函数中的所有CALL、STORE和LOAD事件,并记录所有经过的形状。

保存IC的数据结构称为反馈向量。它只是一个数组,用来保存函数的所有IC。

function load(a) {

return a.key;

}

对于上面的函数,反馈向量看起来像这样:

[{ slot: 0, icType: LOAD, value: UNINIT }]

这是一个简单的函数,只有一个IC,其类型为LOAD,值为UNINIT。这意味着它是未初始化的,我们不知道接下来会发生什么。

用不同的参数调用这个函数,看看内联缓存将如何改变。

let first = { key: 'first' } // shape A

let fast = { key: 'fast' } // the same shape A

let slow = { foo: 'slow' } // new shape B

load(first)

load(fast)

load(slow)

在第一次调用load函数之后,我们的内联缓存将得到一个更新的值:

[{ slot: 0, icType: LOAD, value: MONO(A) }]

这个值现在变成了单态的,这意味着这个缓存只能解析成结构A。

在第二次调用之后,V8将检查IC的值,它将看到它是单态的,并且具有与快速变量相同的形状。它会很快返回offset并解析它。

第三次,结构与存储的不同。因此V8将手动对其进行解析,并将值更新为具有两种可能结构的数组的多态状态。

[{ slot: 0, icType: LOAD, value: POLY[A,B] }]

现在,每当我们调用这个函数时,V8不仅需要检查一个结构,还需要遍历几种可能性。

为了代码更快,可以初始化具有相同类型的对象,而不需要过多地更改它们的结构。

注意:您可以记住这一点,但如果它会导致代码重复或表达性较差的代码,就不要这样做。

内联缓存还会跟踪调用它们的频率,以决定它是否是优化编译器的良好候选者——Turbofan。

编译器

Ignition就到此为止。如果一个函数会被调用多次,这个函数会在编译器Turbofan中进行优化,使其变得更快。

Turbofan从Ignition获取字节码,并将类型反馈(反馈向量)用于函数,在此基础上应用一系列缩减,并生成机器代码。

正如我们前面看到的,类型反馈并不能保证它在将来不会改变。

例如,Turbofan优化的代码基于一个假设,即某些加法总是加整数。

但是如果它接收到一个字符串会发生什么呢?这个过程被称为去优化。丢弃优化的代码,回到解释代码,继续执行,并更新类型反馈。

总结

在本文中,我们讨论了JS引擎的实现以及如何执行JavaScript的确切步骤。

总之,让我们从头再来看一看编译管线。

[译] 在V8引擎中JavaScript是如何工作的

V8概览

我们来逐步地回顾:

1.  一切都从从网络获取JavaScript代码开始。

2.  V8解析源代码并将其转换为抽象语法树(AST)。

3.  基于这个AST, Ignition解释器可以开始做它的事情并产生字节码。

4.  此时,引擎开始运行代码并收集类型反馈。

5.  为了使它运行得更快,可以将字节代码与反馈数据一起发送到优化编译器。优化编译器在此基础上进行某些假设,然后生成高度优化的机器代码。

6.  如果在某个时刻,其中一个假设被证明是不正确的,优化编译器就会去优化并返回到解释器过程。

完结撒花!如果您对某个特定阶段有任何疑问或想了解更多细节,您可以深入源代码或在Twitter上与我联系。

进一步阅读

  • “Life of a script” video from Google
  • A crash course in JIT compilers from Mozilla
  • Nice explanation of Inline Caches in V8
  • Great dive in Object Shapes