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

本文正在参加「金石计划 . 瓜分6万现金大奖」 原作者:Ilya Lyamkin 原文链接:https://www.freecodecamp.org/news/javascript-under-the-hood-v8 今天我们将深入了解JavaScript的V8引擎,并弄清楚JavaScript是如何执行的。 在中,我们了解了浏览器的结构,并对。让我们来回顾一下,这样有利于我们进行更深入的研究。
背景
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
抽象语法树示例 可以通过执行前序遍历(根,左,右)来执行这段代码: 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是如何工作的 插图2
第一个存储对象的方法 然而,我们通常有很多具有相同结构的对象,因此存储大量重复的字典效率不高。 为了解决这个问题,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是如何工作的 插图3
物体结构示例 在上面的例子中,你可以看到每个对象都有一个指向对象形状的链接,对于每个属性名,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是如何工作的 插图4
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 compilersfrom Mozilla
  • Nice explanation ofInline Caches in V8
  • Great dive inObject Shapes
------本页内容已结束,喜欢请分享------

感谢您的来访,获取更多精彩文章请收藏本站。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容