JavaScript是如何工作的:深入V8引擎&编写优化代码的5个技巧

11次阅读

共计 4625 个字符,预计需要花费 12 分钟才能阅读完成。

本系列的 第一篇文章 主要介绍引擎、运行时和调用堆栈。第二篇文章将深入谷歌 V8 的 JavaScript 引擎的内部。
概述
JavaScript 引擎是执行 JavaScript 代码的程序或解释器。JavaScript 引擎可以实现为标准解释器,或者以某种形式将 JavaScript 编译为字节码的即时编译器。
以为实现 JavaScript 引擎的流行项目的列表:

V8 — 开源,由 Google 开发,用 C ++ 编写

Rhino — 由 Mozilla 基金会管理,开源,完全用 Java 开发

SpiderMonkey — 是第一个支持 Netscape Navigator 的 JavaScript 引擎,目前正供 Firefox 使用

JavaScriptCore — 开源,以 Nitro 形式销售,由苹果为 Safari 开发

KJS — KDE 的引擎,最初由 Harri Porten 为 KDE 项目中的 Konqueror 网页浏览器开发

Chakra (JScript9) — Internet Explorer

Chakra (JavaScript) — Microsoft Edge

Nashorn, 作为 OpenJDK 的一部分,由 Oracle Java 语言和工具组编写

JerryScript —  物联网的轻量级引擎

为什么要创建 V8 引擎?
由谷歌构建的 V8 引擎是开源的,使用 c ++ 编写。这个引擎是在谷歌 Chrome 中使用的,但是,与其他引擎不同的是 V8 也用于流行的 node.js。
V8 最初被设计用来提高 web 浏览器中 JavaScript 执行的性能。为了获得速度,V8 将 JavaScript 代码转换成更高效的机器码,而不是使用解释器。它通过实现 JIT (Just-In-Time) 编译器将 JavaScript 代码编译为执行时的机器码,就像许多现代 JavaScript 引擎 (如 SpiderMonkey 或 Rhino (Mozilla)) 所做的那样。这里的主要区别是 V8 不生成字节码或任何中间代码。
V8 曾有两个编译器
在 V8 的 5.9 版本出来之前,V8 引擎使用了两个编译器:

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

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

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

当第一次执行 JavaScript 代码时,V8 利用 full-codegen 编译器,直接将解析的 JavaScript 翻译成机器代码而不进行任何转换。这使得它可以非常快速地开始执行机器代码。请注意,V8 不使用中间字节码,从而不需要解释器。
当代码已经运行一段时间后,分析线程已经收集了足够的数据来判断应该优化哪个方法。
接下来,Crankshaft  从另一个线程开始优化。它将 JavaScript 抽象语法树转换为被称为 Hydrogen 的高级静态单分配(SSA)表示,并尝试优化 Hydrogen 图,大多数优化都是在这个级别完成的。
内联代码
第一个优化是提前内联尽可能多的代码。内联是用被调用函数的主体替换调用点(调用函数的代码行)的过程。这个简单的步骤允许下面的优化更有意义。

隐藏类
JavaScript 是一种基于原型的语言:没有使用克隆过程创建类和对象。JavaScript 也是一种动态编程语言,这意味着可以在实例化后轻松地在对象中添加或删除属性。
大多数 JavaScript 解释器使用类似字典的结构(基于哈希函数)来存储对象属性值在内存中的位置,这种结构使得在 JavaScript 中检索属性的值比在 Java 或 C# 等非动态编程语言中的计算成本更高。
在 Java 中,所有对象属性都是在编译之前由固定对象布局确定的,并且无法在运行时动态添加或删除(当然,C#具有动态类型,这是另一个主题)。
因此,属性值(或指向这些属性的指针)可以作为连续缓冲区存储在存储器中,每个缓冲区之间具有固定偏移量,可以根据属性类型轻松确定偏移的长度,而在运行时可以更改属性类型的 JavaScript 中这是不可能的。
由于使用字典查找内存中对象属性的位置效率非常低,因此 V8 使用了不同的方法:隐藏类。隐藏类与 Java 等语言中使用的固定对象(类)的工作方式类似,只是它们是在运行时创建的。现在,让我们看看他们实际的例子:

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

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

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

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

现在,假设对于 p1 和 p2,将使用相同的隐藏类和转换。那么,对于“p1”,首先添加属性“a”,然后添加属性“b”。然而,“p2”首先分配“b”,然后是“a”。因此,由于不同的转换路径,“p1”和“p2”以不同的隐藏类别结束。在这种情况下,以相同的顺序初始化动态属性好得多,以便隐藏的类可以被重用。
内联缓存
V8 利用了另一种优化动态类型语言的技术,称为内联缓存。内联缓存依赖于这样一种观察,即对同一方法的重复调用往往发生在同一类型的对象上。这里可以找到对内联缓存的深入解释。
接下来将讨论内联缓存的一般概念(如果您没有时间通过上面的深入了解)。
那么它是如何工作的呢? V8 维护了在最近的方法调用中作为参数传递的对象类型的缓存,并使用这些信息预测将来作为参数传递的对象类型。如果 V8 能够很好地预测传递给方法的对象的类型,它就可以绕过如何访问对象属性的过程,而是使用从以前的查找到对象的隐藏类的存储信息。
那么隐藏类和内联缓存的概念如何相关呢?无论何时在特定对象上调用方法时,V8 引擎都必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。在同一个隐藏类的两次成功的调用之后,V8 省略了隐藏类的查找,并简单地将该属性的偏移量添加到对象指针本身。对于该方法的所有下一次调用,V8 引擎都假定隐藏的类没有更改,并使用从以前的查找存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。
内联缓存也是为什么相同类型的对象共享隐藏类非常重要的原因。如果你创建两个相同类型和不同隐藏类的对象(正如我们之前的例子中所做的那样),V8 将无法使用内联缓存,因为即使这两个对象属于同一类型,它们对应的隐藏类为其属性分配不同的偏移量。

这两个对象基本相同,但是“a”和“b”属性的创建顺序不同。
编译成机器码
一旦 Hydrogen 图被优化,Crankshaft 将其降低到称为 Lithium 的较低级表示。大部分的 Lithium 实现都是特定于架构的。寄存器分配往往发生在这个级别。
最后,Lithium 被编译成机器码。然后就是 OSR:on-stack replacement(堆栈替换)。在我们开始编译和优化一个明确的长期运行的方法之前,我们可能会运行堆栈替换。V8 不只是缓慢执行堆栈替换,并再次开始优化。相反,它会转换我们拥有的所有上下文(堆栈,寄存器),以便在执行过程中切换到优化版本上。这是一个非常复杂的任务,考虑到除了其他优化之外,V8 最初还将代码内联。V8 不是唯一能够做到的引擎。
有一种叫去优化的安全措施来进行相反的转换,并在假设引擎无效的情况下返回未优化的代码。
垃圾收集
对于垃圾收集,V8 采用传统的 mark-and-sweep 算法 来清理旧一代。标记阶段应该停止 JavaScript 执行。为了控制 GC 成本并使执行更稳定,V8 使用增量标记:不是遍历整个堆,尝试标记每个可能的对象,它只是遍历堆的一部分,然后恢复正常执行。下一个 GC 停止将从上一个堆行走停止的位置继续,这允许在正常执行期间非常短暂的暂停,如前所述,扫描阶段由单独的线程处理。
如何编写优化的 JavaScript

对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏的类和随后优化的代码。

动态属性:因为在实例化之后向对象添加属性将强制执行隐藏的类更改,并降低之前隐藏类所优化的所有方法的执行速度,所以在其构造函数中分配所有对象的属性。

方法:重复执行相同方法的代码将比仅执行一次的多个不同方法(由于内联缓存)的代码运行得更快。

数组:避免稀疏数组,其中键值不是自增的数字,并没有存储所有元素的稀疏数组是哈希表。这种数组中的元素访问开销较高。另外,尽量避免预分配大数组。最好是按需增长。最后,不要删除数组中的元素,这会使键值变得稀疏。

标记值:V8 使用 32 位表示对象和数值。由于数值是 31 位的,它使用了一位来区分它是一个对象(flag = 1)还是一个称为 SMI(SMall Integer)整数(flag = 0)。那么,如果一个数值大于 31 位,V8 会将该数字装箱,把它变成一个双精度数,并创建一个新的对象来存放该数字。尽可能使用 31 位有符号数字,以避免对 JS 对象的高开销的装箱操作。

Ignition and TurboFan
随着 2017 年早些时候发布 V8 5.9,引入了新的执行管道。这个新的管道在实际的 JavaScript 应用程序中实现了更大的性能提升和显着节省内存。
新的执行流程是建立在 Ignition(V8 的解释器)和 TurboFan(V8 的最新优化编译器)之上的。
自从 V8 5.9 版本问世以来,由于 V8 团队一直努力跟上新的 JavaScript 语言特性以及这些特性所需要的优化,V8 团队已经不再使用 full-codegen 和 Crankshaft(自 2010 年以来为 V8 技术所服务)。
这意味着 V8 整体上将有更简单和更易维护的架构。

这些改进只是一个开始。新的 Ignition 和 TurboFan 管道为进一步优化铺平了道路,这些优化将在未来几年内提升 JavaScript 性能并缩小 V8 在 Chrome 和 Node.js 中的占用空间。
原文:https://blog.sessionstack.com…
你的点赞是我持续分享好东西的动力,欢迎点赞!
一个笨笨的码农,我的世界只能终身学习!
更多内容请关注公众号《大迁世界》!

正文完
 0