来自 Web前端 2020-05-07 05:42 的文章
当前位置: 网上澳门金莎娱乐 > Web前端 > 正文

JavaScript深入V8引擎以及编写优化代码的5个技巧_javascript技巧_脚本之家

理解JavaScript的工作原理是写出高效JavaScript代码的关键。忘记那些无关紧要的毫秒级改进:错误地使用对象属性可能导致简单的一行代码速度降低7倍。

概述

考虑到JavaScript在软件堆栈所有级别中的普遍性,即使不是所有级别的基础设施,也可能会出现微不足道的减速,而不仅仅是网站的菜单动画。有许多的方法来编写高效的JavasScript代码,但在这篇文章里面,我们将着重介绍编译器友好的优化方法,这意味着源代码使编译器优化变得简单有效。我们将把讨论范围缩小到V8,即支持electron、node.js和google chrome的JavaScript引擎。为了理解编译器友好的优化,我们首先需要讨论JavaScript是如何编译的。

JavaScript引擎是执行 JavaScript 代码的程序或解释器。JavaScript引擎可以实现为标准解释器,或者以某种形式将JavaScript编译为字节码的即时编译器。

JavaScript在V8中的执行可以分为三个阶段:

以为实现JavaScript引擎的流行项目的列表:

源代码到抽象语法树:解析器将源代码生成抽象语法树(AST)抽象语法树到字节码:V8的解释器Ignition从抽象语法树生成字节码。请注意,生成字节码这一步在2017年以前是没有的。字节码到机器码:V8的编译器TurboFan从字节码生成一个图,用高度优化的机器代码替换字节码的部分。

V8 — 开源,由 Google 开发,用 C ++ 编写 Rhino — 由 Mozilla 基金会管理,开源,完全用 Java 开发 SpiderMonkey — 是第一个支持 Netscape Navigator 的 JavaScript 引擎,目前正供 Firefox 使用 JavaScriptCore — 开源,以Nitro形式销售,由苹果为Safari开发 KJS — KDE 的引擎,最初由 Harri Porten 为 KDE 项目中的 Konqueror 网页浏览器开发 Chakra  — Internet Explorer Chakra  — Microsoft Edge Nashorn, 作为 OpenJDK 的一部分,由 Oracle Java 语言和工具组编写 JerryScript —  物联网的轻量级引擎

第一个阶段超出了本文的范围,但是第二个和第三个阶段对编写优化的JavaScript有直接的影响。

为什么要创建V8引擎?

我们将讨论这些优化方法以及代码如何利用这些优化。通过了解JavaScript执行的基础知识,您不仅可以理解这些性能方面的建议,还可以学习如何发现自己的一些优化点。

由谷歌构建的V8引擎是开源的,使用c++编写。这个引擎是在谷歌Chrome中使用的,但是,与其他引擎不同的是 V8 也用于流行的 node.js。

实际上,第二和第三阶段是紧密耦合的。这两个阶段在即时范式中运行。为了理解JIT的重要性,我们将研究以前将源代码转换为机器代码的方法。

V8最初被设计用来提高web浏览器中JavaScript执行的性能。为了获得速度,V8 将 JavaScript 代码转换成更高效的机器码,而不是使用解释器。它通过实现 JIT 编译器将 JavaScript 代码编译为执行时的机器码,就像许多现代 JavaScript 引擎(如SpiderMonkey或Rhino 所做的那样。这里的主要区别是 V8 不生成字节码或任何中间代码。

Just-in-Time (JIT) 范式

V8 曾有两个编译器

为了执行任意一段程序,计算机必须将源代码转换成机器可以运行的代码。

在 V8 的 5.9 版本出来之前,V8 引擎使用了两个编译器:

有两种方法可以进行转换。

full-codegen — 一个简单和非常快的编译器,产生简单和相对较慢的机器码。 Crankshaft — 一种更复杂的优化编译器,生成高度优化的代码。

第一种选择是使用解释器。解释器可以有效地逐行翻译和执行。

V8 引擎也在内部使用多个线程:

第二种方法是使用编译器。编译器在执行之前立即将所有源代码转换为机器语言。

主线程执行你所期望的操作:获取代码、编译代码并执行它 还有一个单独的线程用于编译,因此主线程可以在前者优化代码的同时继续执行 一个 Profiler 线程,它会告诉运行时我们花了很多时间,让 Crankshaft 可以优化它们 一些线程处理垃圾收集器

下面,我们将阐述两种方法的优点和缺点。

当第一次执行 JavaScript 代码时,V8 利用 full-codegen 编译器,直接将解析的 JavaScript 翻译成机器代码而不进行任何转换。这使得它可以非常快速地开始执行机器代码。请注意,V8 不使用中间字节码,从而不需要解释器。

解释器的优点、缺点

当代码已经运行一段时间后,分析线程已经收集了足够的数据来判断应该优化哪个方法。

解释器使用read-eval-print loop(REPL,交互式解释器)的方式工作 —— 这种方式有许多的优点:

接下来,Crankshaft  从另一个线程开始优化。它将 JavaScript 抽象语法树转换为被称为 Hydrogen 的高级静态单分配表示,并尝试优化 Hydrogen 图,大多数优化都是在这个级别完成的。

易于实现和理解及时反馈更合适的编程环境

内联代码

然而,这些好处是以缓慢执行为代价的:

第一个优化是提前内联尽可能多的代码。内联是用被调用函数的主体替换调用点的过程。这个简单的步骤允许下面的优化更有意义。

eval的开销,而不是运行机器代码。

隐藏类

无法跨程序的对各个部分进行优化。

JavaScript是一种基于原型的语言:没有使用克隆过程创建类和对象。JavaScript也是一种动态编程语言,这意味着可以在实例化后轻松地在对象中添加或删除属性。

更正式地说,解释器在处理不同的代码段时不能识别重复的工作。如果你通过解释器运行同一行代码100次,解释器将翻译并执行同一行代码100次,没有必要地重新翻译了99次。

大多数 JavaScript 解释器使用类似字典的结构来存储对象属性值在内存中的位置,这种结构使得在 JavaScript 中检索属性的值比在 Java 或 C# 等非动态编程语言中的计算成本更高。

总结一下,解释器简单、启动快,但是执行慢。

在Java中,所有对象属性都是在编译之前由固定对象布局确定的,并且无法在运行时动态添加或删除。

编译器的优点、缺点

因此,属性值可以作为连续缓冲区存储在存储器中,每个缓冲区之间具有固定偏移量, 可以根据属性类型轻松确定偏移的长度,而在运行时可以更改属性类型的 JavaScript 中这是不可能的。

编译器会在执行前翻译所有的源代码。

由于使用字典查找内存中对象属性的位置效率非常低,因此 V8 使用了不同的方法:隐藏类。隐藏类与 Java 等语言中使用的固定对象的工作方式类似,只是它们是在运行时创建的。现在,让我们看看他们实际的例子:

随着复杂性的增加,编译器可以进行全局优化。这为编译器提供了比解释器唯一的优势 —— 更快的执行时间。

一旦 “new Point” 调用发生,V8 将创建一个名为 “C0” 的隐藏类。

总结一下,编译器是复杂的、启动慢,但是执行快。

尚未为 Point 定义属性,因此“C0”为空。

即时编译(JIT)

一旦第一个语句“this.x = x”被执行,V8 将创建一个名为 “C1” 的第二个隐藏类,它基于“C0”。 “C1”描述了可以找到属性 x 的存储器中的位置。

即时编译器尝试结合了解释器和编译器的优点,使代码转换和执行都变得更快。

在这种情况下,“x”存储在偏移0处,这意味着当将存储器中的 point 对象视为连续缓冲区时,第一偏移将对应于属性 “x”。 V8 还将使用 “类转换” 更新 “C0” ,该类转换指出如果将属性 “x” 添加到 point 对象,则隐藏类应从 “C0” 切换到 “C1”。 下面的 point 对象的隐藏类现在是“C1”。

基本思想是避免重复转换。首先,探查器会通过解释器先跑一遍代码。在代码执行期间,探查器会跟踪运行几次的热代码段和运行很多次的热代码段。

每次将新属性添加到对象时,旧的隐藏类都会更新为指向新隐藏类的转换路径。隐藏类转换非常重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。如果两个对象共享一个隐藏类并且同一属性被添加到它们中,则转换将确保两个对象都接收相同的新隐藏类以及随其附带的所有优化代码。

JIT将热代码片段发送给基线编译器,尽可能的复用编译后的代码。

当语句 “this.y = y” 被执行时,会重复同样的过程(在 “Point” 函数内部,“this.x = x”语句之后)。

JIT同时将热代码片段发送给优化编译器。优化编译器使用解释器收集的信息来进行假设,并且基于这些假设进行优化。

一个名为“C2”的新隐藏类会被创建,如果将一个属性 “y” 添加到一个 Point 对象,一个类转换会添加到“C1”,则隐藏类应该更改为“C2”,point 对象的隐藏类更新为“C2”。

但是,如果这些假设无效,优化编译器将执行 去优化,丢弃优化的代码。

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

优化和去优化的过程是昂贵的。由此产生了一类JavaScript的优化方法,下面将详细描述。

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

JIT需要存储优化的机器代码和探查器的执行信息等,自然会引入内存开销。尽管这一点无法通过优化的JavaScript来改善,但激发了V8的解释器。

内联缓存

V8的编译

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

V8的解释器和编译器执行以下功能:

接下来将讨论内联缓存的一般概念。

解释器将抽象语法树转换为字节码。字节码队列随后会被执行,并且通过内联缓存收集反馈。这些反馈会被解释器本身用于随后的解析,同时,编译器会利用这些反馈来做推测性的优化。编译器根据反馈将字节码转换为特定于体系结构的机器码,从而推测性地优化字节码。V8的解释器

那么它是如何工作的呢? V8 维护了在最近的方法调用中作为参数传递的对象类型的缓存,并使用这些信息预测将来作为参数传递的对象类型。如果 V8 能够很好地预测传递给方法的对象的类型,它就可以绕过如何访问对象属性的过程,而是使用从以前的查找到对象的隐藏类的存储信息。

  • Ignition

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

JIT编译器显示了开销内存消耗。Ignition通过实现三个目标来解决这个问题:减少内存使用、减少启动时间和降低复杂性。

内联缓存也是为什么相同类型的对象共享隐藏类非常重要的原因。 如果你创建两个相同类型和不同隐藏类的对象,V8将无法使用内联缓存,因为即使这两个对象属于同一类型,它们对应的隐藏类为其属性分配不同的偏移量。

这三个目标都是通过将AST转换为字节码并在程序执行期间收集反馈来实现的。

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

字节码被当做源代码对待,省去了在编译期间重新解析JavaScript的需要。这意味着使用字节码,TurboFan的去优化过程不再需要原始的代码了。作为基于程序执行反馈的优化示例,内联缓存允许V8优化对具有相同类型参数的函数的重复调用。具体来说,内联缓存存储函数的输入类型。类型越少,需要的类型检查就越少。减少类型检查的数量可以显著提高性能。

编译成机器码

网上澳门金莎娱乐,AST和字节码都会暴露给TurboFan。

一旦 Hydrogen 图被优化,Crankshaft 将其降低到称为 Lithium 的较低级表示。大部分的 Lithium 实现都是特定于架构的。寄存器分配往往发生在这个级别。

V8的编译器 - TurboFan

最后,Lithium 被编译成机器码。然后就是 OSR :on-stack replacement。在我们开始编译和优化一个明确的长期运行的方法之前,我们可能会运行堆栈替换。 V8 不只是缓慢执行堆栈替换,并再次开始优化。相反,它会转换我们拥有的所有上下文,以便在执行过程中切换到优化版本上。这是一个非常复杂的任务,考虑到除了其他优化之外,V8 最初还将代码内联。 V8 不是唯一能够做到的引擎。

在2008年发布时,V8引擎最初直接将源代码编译为机器代码,跳过了中间字节码表示。在发布时,V8就比竞争对手快了10倍。

有一种叫去优化的安全措施来进行相反的转换,并在假设引擎无效的情况下返回未优化的代码。

然而,到今天,TurboFan接受了Ignition的字节码,比它发布的时候快了10倍。V8的编译器经过了一系列的迭代:

垃圾收集

2008 – Full-Codegen

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

具有隐藏类和内联缓存,快速遍历AST的编译器缺点:无优化的即时编译

如何编写优化的 JavaScript

2010 – Crankshaft

对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏的类和随后优化的代码。 动态属性: 因为在实例化之后向对象添加属性将强制执行隐藏的类更改,并降低之前隐藏类所优化的所有方法的执行速度,所以在其构造函数中分配所有对象的属性。 方法:重复执行相同方法的代码将比仅执行一次的多个不同方法的代码运行得更快。 数组:避免稀疏数组,其中键值不是自增的数字,并没有存储所有元素的稀疏数组是哈希表。这种数组中的元素访问开销较高。另外,尽量避免预分配大数组。最好是按需增长。最后,不要删除数组中的元素,这会使键值变得稀疏。 标记值:V8 使用 32 位表示对象和数值。由于数值是 31 位的,它使用了一位来区分它是一个对象还是一个称为 SMI整数。那么,如果一个数值大于 31 位,V8会将该数字装箱,把它变成一个双精度数,并创建一个新的对象来存放该数字。尽可能使用 31 位有符号数字,以避免对 JS 对象的高开销的装箱操作。

使用类型反馈和去优化,优化即时编译器。缺点: 不能扩展到现代JavaScript,严重依赖去优化,有限的静态类型分析,与Codegen紧密耦合,高移植开销

Ignition and TurboFan

2015 – TurboFan

随着2017年早些时候发布V8 5.9,引入了新的执行管道。 这个新的管道在实际的JavaScript应用程序中实现了更大的性能提升和显着节省内存。

本文由网上澳门金莎娱乐发布于Web前端,转载请注明出处:JavaScript深入V8引擎以及编写优化代码的5个技巧_javascript技巧_脚本之家

关键词: