JS V8 引擎|周末学习

636 阅读11分钟

「本文已参与 周末学习计划,点击查看详情 」

概述

JavaScript引擎是一个程序或执行JavaScript代码的解释器。JavaScript 引擎可以实现为标准解释器,或以某种形式将 JavaScript 编译为字节码的即时编译器。 这是正在实现 JavaScript 引擎的流行项目列表:

  1. V8 — 开源,由 Google 开发,用 C++ 编写
  2. Rhin o — 由 Mozilla 基金会管理,开源,完全用 Java 开发
  3. SpiderMonkey — 第一个 JavaScript 引擎,过去支持 Netscape Navigator,今天支持 Firefox
  4. JavaScriptCore — 开源,作为 Nitro 销售,由 Apple 为 Safari 开发
  5. KJS — KDE 引擎最初由 Harri Porten 为 KDE 项目的 Konqueror Web 浏览器开发
  6. 脉轮(JScript9) — Internet Explorer
  7. Chakra (JavaScript) — Microsoft Edge
  8. Nashorn,作为 OpenJDK 的一部分开源,由 Oracle Java Languages and Tool Group 编写
  9. JerryScript — 是一个轻量级的物联网引擎。 为什么要创建 V8 引擎? 谷歌构建的 V8 引擎是开源的,用C++编写。该引擎在 Google Chrome 中使用。然而,与其他引擎不同,V8 也用于流行的 Node.js 运行时。

V8 最初旨在提高 Web 浏览器中 JavaScript 执行的性能。为了获得速度,V8 将 JavaScript 代码翻译成更高效的机器代码,而不是使用解释器。它通过实现JIT(即时)编译器,在执行时将 JavaScript 代码编译为机器代码,就像许多现代 JavaScript 引擎所做的那样,例如 SpiderMonkey 或 Rhino (Mozilla)。这里的主要区别是 V8 不产生字节码或任何中间代码。 V8 曾经有两个编译器 在 V8 的 5.9 版本出来(今年早些时候发布)之前,引擎使用了两个编译器: full-codegen — 一种简单且非常快速的编译器,可生成简单且相对较慢的机器代码。 Crankshaft — 一种更复杂的(Just-In-Time)优化编译器,可生成高度优化的代码。 V8 引擎也在内部使用了几个线程: 主线程做你所期望的:获取你的代码,编译它然后执行它 还有一个单独的编译线程,这样主线程可以在主线程优化代码的同时继续执行 一个 Profiler 线程,它将告诉运行时我们在哪些方法上花费了大量时间,以便 Crankshaft 可以优化它们 处理垃圾收集器扫描的几个线程 当第一次执行 JavaScript 代码时,V8 利用full-codegen直接将解析的 JavaScript 转换为机器代码,无需任何转换。这允许它非常快速地开始执行机器代码。请注意,V8 不以这种方式使用中间字节码表示,从而消除了对解释器的需求。 当您的代码运行一段时间后,探查器线程收集了足够的数据来判断应该优化哪个方法。 接下来,曲轴优化在另一个线程中开始。它将 JavaScript 抽象语法树转换为称为Hydrogen的高级静态单赋值 (SSA) 表示,并尝试优化该 Hydrogen 图。大多数优化都是在这个级别完成的。 内联 第一个优化是提前内联尽可能多的代码。内联是用被调用函数的主体替换调用点(调用函数的代码行)的过程。这个简单的步骤使后续优化变得更有意义。

隐藏类

JavaScript 是一种基于原型的语言:没有类,对象是使用克隆过程创建的。JavaScript 也是一种动态编程语言,这意味着可以在实例化后轻松添加或删除对象的属性。 大多数 JavaScript 解释器使用类似字典的结构(基于哈希函数)来存储对象属性值在内存中的位置。这种结构使得在 JavaScript 中检索属性的值比在非动态编程语言(如 Java 或 C#)中检索的计算成本更高。在Java中,所有的对象属性在编译前都是由一个固定的对象布局决定的,不能在运行时动态添加或删除(好吧,C#有动态键入这是另一个主题)。因此,属性值(或指向这些属性的指针)可以作为连续缓冲区存储在内存中,每个值之间具有固定的偏移量。可以根据属性类型轻松确定偏移量的长度,而这在 JavaScript 中是不可能的,因为属性类型可以在运行时更改。 由于使用字典查找对象属性在内存中的位置效率非常低,V8 使用了不同的方法:隐藏类。隐藏类的工作方式类似于 Java 等语言中使用的固定对象布局(类),不同之处在于它们是在运行时创建的。现在,让我们看看它们的实际外观: 函数点(x,y){ this.x = x; this.y = y; } var p1 = new Point(1, 2); 一旦“new Point(1, 2)”调用发生,V8 将创建一个名为“C0”的隐藏类。

尚未为 Point 定义任何属性,因此“C0”为空。 一旦执行了第一条语句“this.x = x”(在“Point”函数内部),V8 将创建基于“C0”的名为“C1”的第二个隐藏类。“C1”描述了在内存中可以找到属性 x 的位置(相对于对象指针)。在这种情况下,“x”存储在偏移量0处,这意味着当将内存中的点对象视为连续缓冲区时,第一个偏移量将对应于属性“x”。V8 还将使用“类转换”更新“C0”,这表明如果将属性“x”添加到点对象,隐藏类应从“C0”切换到“C1”。下面点对象的隐藏类现在是“C1”。

每次向对象添加新属性时,旧隐藏类都会更新为新隐藏类的过渡路径。隐藏类转换很重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。如果两个对象共享一个隐藏类并且为它们添加了相同的属性,则转换将确保两个对象都收到相同的新隐藏类和所有优化的代码。 当执行语句“this.y = y”时(同样在 Point 函数内部,在“this.x = x”语句之后)重复此过程。 创建了一个名为“C2”的新隐藏类,将类转换添加到“C1”,说明如果将属性“y”添加到 Point 对象(已包含属性“x”),则隐藏类应更改为“C2”,点对象的隐藏类更新为“C2”。

隐藏的类转换取决于将属性添加到对象的顺序。看看下面的代码片段:

Point(x,y){ 
    this.x = x; 
    this.y = y; 
}
var p1 = new Point(1, 2); 
p1.a = 5; 
p1.b = 6;
var p2 = new Point(3, 4); 
p2.b = 7; 
p2.a = 8;

现在,您假设 p1 和 p2 将使用相同的隐藏类和转换。嗯,不是真的。对于“p1”,首先添加属性“a”,然后添加属性“b”。然而,对于“p2”,首先分配的是“b”,然后是“a”。因此,由于不同的转换路径,“p1”和“p2”最终具有不同的隐藏类。在这种情况下,最好以相同的顺序初始化动态属性,以便可以重用隐藏的类。

内联缓存

V8 利用了另一种优化动态类型语言的技术,称为内联缓存。内联缓存依赖于对同一方法的重复调用往往发生在同一类型的对象上的观察。可以在此处找到对内联缓存的深入解释。

我们将讨论内联缓存的一般概念(以防您没有时间阅读上面的深入解释)。

那么它是怎样工作的?V8 维护在最近的方法调用中作为参数传递的对象类型的缓存,并使用此信息来假设将来将作为参数传递的对象类型。如果 V8 能够对将传递给方法的对象类型做出很好的假设,它就可以绕过确定如何访问对象属性的过程,而是使用从先前查找到对象属性的存储信息。隐藏类。

那么隐藏类和内联缓存的概念有什么关系呢?每当在特定对象上调用方法时,V8 引擎都必须查找该对象的隐藏类,以确定访问特定属性的偏移量。在对同一个隐藏类两次成功调用同一个方法后,V8 省略了隐藏类查找,只是简单地将属性的偏移量添加到对象指针本身。对于该方法的所有未来调用,V8 引擎假定隐藏类没有改变,并使用先前查找存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。

内联缓存也是相同类型的对象共享隐藏类如此重要的原因。如果你创建了两个相同类型的对象和不同的隐藏类(就像我们在前面的例子中所做的那样),V8 将无法使用内联缓存,因为即使这两个对象是相同的类型,它们对应的隐藏类为其属性分配不同的偏移量。

这两个对象基本相同,但“a”和“b”属性的创建顺序不同。

编译成机器码 最后,Lithium 被编译成机器码。然后发生了其他称为 OSR 的事情:堆栈上替换。在我们开始编译和优化一个明显需要长时间运行的方法之前,我们很可能正在运行它。V8 不会忘记它刚刚缓慢执行的内容以优化版本重新开始。相反,它将转换我们拥有的所有上下文(堆栈、寄存器),以便我们可以在执行过程中切换到优化版本。这是一项非常复杂的任务,请记住,在其他优化中,V8 最初已经内联了代码。V8 并不是唯一能够做到这一点的引擎。

有一种称为去优化的保护措施可以进行相反的转换,并在引擎做出的假设不再成立时恢复到未优化的代码。

垃圾收集 对于垃圾收集,V8 使用传统的标记和清除的分代方法来清理老年代。标记阶段应该停止 JavaScript 执行。为了控制GC成本,让执行更稳定,V8使用增量标记:不是遍历整个堆,尝试标记每个可能的对象,它只遍历堆的一部分,然后恢复正常执行。下一个 GC 停止将从前一个堆遍历停止的位置继续。这允许在正常执行期间非常短的暂停。如前所述,扫描阶段由单独的线程处理。

随着 2017 年早些时候 V8 5.9 的发布,引入了新的执行管道。这个新管道在现实世界的JavaScript 应用程序中实现了更大的性能改进和显着的内存节省。 新的执行管道建立在V8 的解释器Ignition和V8 的最新优化编译器TurboFan 之上。

自从 V8 5.9 版问世以来,V8 不再使用 full-codegen 和 Crankshaft(自 2010 年以来为 V8 服务的技术)执行 JavaScript,因为 V8 团队一直在努力跟上新的 JavaScript 语言特性和这些功能所需的优化。 这意味着整个 V8 将拥有更简单、更易于维护的架构。

Web 和 Node.js 基准测试的改进 这些改进只是一个开始。新的 Ignition 和 TurboFan 管道为进一步优化铺平了道路,这些优化将在未来几年提高 JavaScript 性能并缩小 V8 在 Chrome 和 Node.js 中的占用空间。

如何编写优化的 JavaScript

对象属性的顺序:始终以相同的顺序实例化您的对象属性,以便可以共享隐藏的类和随后优化的代码。 动态属性:在实例化后向对象添加属性将强制隐藏类更改并减慢为先前隐藏类优化的任何方法。相反,在其构造函数中分配对象的所有属性。 方法:重复执行相同方法的代码比只执行一次许多不同方法的代码运行得更快(由于内联缓存)。 数组:避免键不是增量数字的稀疏数组。里面没有每个元素的稀疏数组是一个哈希表。访问此类数组中的元素的成本更高。此外,尽量避免预先分配大数组。最好是边走边成长。最后,不要删除数组中的元素。它使密钥稀疏。 标记值:V8 用 32 位表示对象和数字。它使用一个位来知道它是一个对象(标志 = 1)还是一个整数(标志 = 0),因为它有 31 位,所以称为 SMI(SMall Integer)。然后,如果一个数值大于 31 位,V8 会将数字装箱,将其转换为 double 并创建一个新对象将数字放入其中。尽量使用 31 位有符号数字,以避免对 JS 对象进行昂贵的装箱操作。