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

7. 前端开发 专栏收录该内容
8 篇文章 0 订阅

注:本文翻译自网上的文章,原文地址:https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e

本系列的第一篇文章重点介绍了引擎、运行时和调用堆栈。 第二篇文章将深入到Google的V8 JavaScript引擎的内部。我们还将提供一些关于如何编写更好的JavaScript代码的技巧。

概述

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

下面是广为流行的实现了JavaScript引擎的项目列表:

  • V8 - 开源,由Google开发,用C++编写
  • Rhino - 由Mozilla基金会管理,开源,完全用Java开发
  • SpiderMonkey - 第一个JavaScript引擎,在过去服务于Netscape Navigator,现在则工作在Firefox上
  • JavaScriptCore - 开放源代码,由Apple为Safari而开发,产品名为Nitro
  • KJS - KDE的引擎,最初由Harri Porten为KDE项目的Konqueror网页浏览器开发
  • Chakra(JScript9) - Internet Explorer
  • Chakra(JavaScript) - Microsoft Edge
  • Nashorn - 作为OpenJDK的一部分开源,使用Oracle Java语言和工具编写
  • JerryScript - 用于物联网的轻量级引擎。

为什么创建V8引擎?

由Google创建的V8引擎是开源的,采用C++语言编写。 该引擎用于Google Chrome,但与其他引擎不同的是,V8也用于流行的Node.js运行时。

image

V8最初是设计于提高网页浏览器内JavaScript执行的性能。 为了提升速度,V8将JavaScript代码翻译成更高效的机器代码,而不是使用解释器。它实现了JIT(Just-In-Time,即时)编译器,将JavaScript代码编译为机器代码,如同许多其它现代JavaScript引擎(如SpiderMonkey或Rhino(Mozilla)等)所做的那样,但主要区别在于V8不生成字节码或任何中间代码。

V8曾经有两个编译器

在V8.9版本出来之前(今年早些时候发布的),引擎使用了两个编译器:

  • full-codegen - 一个简单而且非常快的编译器,可以生成简单但相对较慢的机器代码。
  • Crankshaft - 一种相对复杂的即时优化编译器,生成高度优化的代码。

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

  • 主线程完成您的任务:取得代码、编译并执行它
  • 还有一个单独的线程用于编译,这样可以一边优化代码,主线程可以一边继续执行
  • 一个Profiler线程,它会告诉运行时哪个方法花了很多时间,Crankshaft去优化它们
  • 一些线程处理垃圾回收扫描

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

当你的代码运行了一段时间之后,profiler线程收集了足够的数据,判断哪个方法应该被优化。

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

内联

第一个优化是提前尽可能多地内联代码。 内联是将被调用函数的主体替换到调用代码行的过程。 这个简单的步骤使得后续的优化更有意义。

image

隐藏类

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

大多数JavaScript解释器使用类似字典的结构(基于散列函数)来存储对象在内存中的属性值。这种结构使得在JavaScript中检索一个属性的值,要比在Java或C#这样的非动态编程语言中的计算量大得多。在Java中,所有的对象属性都是在编译之前由一个固定的对象布局决定的,并且不能在运行时动态添加或删除(当然,C#也有动态类型,这是另一个主题)。因此,属性的值(或指向这些属性的指针)可以作为连续的缓冲区存储在内存中,每个值之间有一个固定的偏移量。偏移量的长度可以很容易地根据属性类型来确定,这在运行时属性类型可以改变的JavaScript中,是不可能做到的。

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

function Point(x, y) {
    this.x = x;
    this.y = y;
}

var p1 = new Point(1, 2);

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

image

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

一旦第一个语句“this.x = x”被执行(在“Point”函数内部),V8将创建第二个基于“C0”的隐藏类,名为“C1”。 “C1”描述了属性x的在存储器中的位置(相对于对象指针)。 在本例中,“x”被存储在偏移量0处,这意味着当我们将point对象在存储器中的位置看作连续缓冲时,第一个偏移量将对应于属性“x”。 V8也会用“类别转换”来更新“C0”,如果一个属性“x”被添加到一个点对象,隐藏类应该从“C0”切换到“C1”。 如下point对象的隐藏类现在是“C1”。

image

每当一个新的属性添加到一个对象时,旧的隐藏类将被更新为新的隐藏类。 隐藏类的转换非常重要,因为它们允许隐藏类在以相同方式创建的对象之间共享。 如果两个对象共享一个隐藏类,并将相同的属性添加到这两个对象,则转换将确保两个对象接收相同的新隐藏类以及与之相关的所有优化代码。

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

一个名为“C2”的新隐藏类被创建,且将一个类转换添加到“C1”,指出如果将一个属性“y”添加到一个Point对象(已经包含属性“x”),那么隐藏类应该更新为 “C2”,同时point对象的隐藏类更新为“C2”。

image

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

function 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将不能使用内联缓存,因为即使两个对象是相同的类型,它们相应的隐藏类为其属性分配不同的偏移量。

image

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

编译成机器码

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

最后,Lithium被编译成机器码。然后发生OSR:堆栈替换。在我们开始编译和优化一个明显的长时间运行的方法之前,该方法可能正在运行。 V8记住它执缓慢,但不是使用优化版本再次运行,相反,它会转换我们拥有的所有上下文(堆栈,寄存器),以便在执行过程中切换到优化版本。注意除了其他优化之外,还有初始的V8代码内联,这是一个非常复杂的任务, 但V8不是唯一能够做到的引擎。

在引擎的假设不再成立的情况下,有一种叫做去最优化的保护措施来做出相反的变换,还原回非优化的代码。

垃圾回收

关于垃圾回收,V8采用了传统的标记-清除法。 标记阶段会停止JavaScript的执行,为了控制GC的代价,使得执行更加稳定,V8使用了增量标记,而不是遍历整个堆,试图标记每一个可能的对象。它只遍历一部分堆,然后恢复正常的执行。 下一次GC将从先前堆遍历过的地方继续,这使得正常执行过程只会有非常短时间的暂停。 如前所述,扫描阶段由单独的线程处理。

Ignition和TurboFan

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

新的执行管线建立在V8的解释器Ignition和V8的最新优化编译器TurboFan之上。

你可以查看V8团队关于这个话题的博客文章

自从V8.5版本问世以来,V8团队已经不再使用V8的full-codegen和Crankshaft(自2010年以来服务于V8的技术),V8团队一直在努力跟上新的JavaScript语言特性,并对这些特性进行优化。

这意味着V8整体上将有更简单和更可维护的架构。

image

Web和Node.js基准测试的提升

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

最后,有一些关于如何编写优化的、更好的JavaScript的技巧,你可以很容易地从上面的内容中得到这些,这里为了你的方便,进行一个总结:

如何编写优化的JavaScript

  • 对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。
  • 动态属性:在实例化之后向对象添加属性将强制隐藏类发生改变,并减缓先前优化了的隐藏类的所有方法。正确的做法是,在其构造函数中设置所有的对象属性。
  • 方法:重复执行相同方法的代码将比只执行一次许多不同方法的代码运行得更快。(由于内联缓存)
  • 数组:避免稀疏数组,其中键不是增量数字。稀疏数组中并非每个元素都是哈希表。这种阵列中的元素访问代价较高。另外,尽量避免预分配大型数组,根据需要增长会更好一些。最后,不要删除数组中的元素,这会使得键值稀疏。
  • 标记值:V8使用32位表示对象和数字。,它使用了一个bit来表明它是一个对象(flag = 1)还是一个的整数(flag = 0)。这种整数称为SMI(SMall Integer),因为它只有31位。如果一个数字值大于31位,V8会将该数字装箱(box),把它变成一个double类型,并创建一个新的对象来放入数字。尽可能使用31位有符号数字,以避免昂贵的装箱操作。

参考资源

image

  • 2
    点赞
  • 0
    评论
  • 0
    收藏
  • 扫一扫,分享海报

©️2022 CSDN 皮肤主题:编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值