深入 V8 引擎 + 5 个代码组织建议

分享

console.info

本文翻译自 sessionstack.com,该系列共 19 篇文章,解释了 JavaScript 是如何在浏览器中工作,深入到了 JavaScript 解释器的工作原理,以及 JavaScript 技术实现细节。

原文链接: How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code

前言

几周前,我们开始了系列:深入研究 JavaScript 及其工作原理。我们认为:通过了解 JavaScript 的组成以及这些组成如何一起发挥作用,就将能够编写更好的代码和应用。

该系列的第一篇文章着重介绍了 JavaScript 的引擎、运行时和调用堆栈。这篇文章将深入探讨 Google V8 JavaScript 引擎的内部实现。同时,我们还将介绍一些 JavaScript 代码的编写技巧和准则(我们的开发团队就是基于这些准则开发产品)。

总览

JavaScript 引擎是执行 JavaScript 代码所必须的解释器。JavaScript 引擎可以实现为标准解释器(全文解释后执行),或以某种形式将 JavaScript 编译成字节码的即时编译器(变解释变执行)。

下方列出了目前比较出名的 JavaScript 引擎:

  • V8 - 开源项目,Google 开发,使用 C++ 编写。
  • Rhino - 开源项目,由 Mozilla 基金会开发,使用 Java 编写
  • SpiderMonkey - 第一个 JavaScript 引擎,最开始是 Netscape Navigator 的 JavaScript 引擎,目前使用在 FireFox 上。
  • JavaScriptCore - 开源项目,Apple 开发,使用在 Safari 浏览器上。
  • KJS - KDE 引擎最初是由 Harri Porten 为 KDE 项目的 Konqueror web 浏览器开发。
  • Chakra (JScript9) - IE 浏览器
  • Chakra (JavaScript) - Edge 浏览器(目前 Edge 浏览器已经投靠 Chrome 使用 V8 引擎)
  • Nashorn - 开源项目 OpenJDK 的部分内容,由 Oracle Java Languages and Tool Group 编写。
  • JerryScript - 物联网下的一个轻量级引擎。

为什么需要 V8 ?

开源的 V8 引擎由 Google 使用 C++ 编写,使用在 Google 的 Chrome 上。但不同于其他的 JavaScript 引擎,V8 引擎还被用在目前非常流行的 Node.js 上。

image

V8 引擎设计的初衷是为了提升 JavaScript 在浏览器中的执行速度。为了提升 JavaScript 的执行速度,它将 JavaScript 代码转换成更高效的机器码,而不使用解释器。与许多现代 JavaScript 引擎(例如 SpiderMonkey 或 Rhino )类似,通过实现 JIT(Just-In-Time 实时)编译器将 JavaScript 代码实时的编译为机器码。但不同于其他 JavaScript 引擎,V8 不会生成字节码或任何中间代码。

注:使用解释器就代表有一个中间步骤,一般会生成字节码,然后在生成机器码。

V8 曾有两个编译器

在 5.9 版本之前,V8 采用了两种编译器:

  1. full-codegen:一个简单且高效的编译器,将代码转换成速度相对缓慢的机器码。
  2. Crankshaft:一个复杂的编译器(Just-In-Time 实时),高度优化代码。
注:这里的相对只是相对于未优化前。

V8 引擎内部主要有以下几个线程:

  1. 主线程:获取代码,编译并执行。
  2. 另外还有一个单独的编译线程,以便主线程可以在该线程优化代码时,继续执行。
  3. Profiler 线程:它将告诉运行时,在哪些方法上花费了大量的时间,以便 Crankshaft 编译器优化它们。
  4. 还有一些线程用于垃圾回收器(GC)。

当第一次执行 JavaScript 代码时,V8 利用 full-codegen 编译器,直接将 JavaScript 转换成机器码,不进行任何转换。这使得它可以非常快速地开始执行机器码。注意 V8 没有生成字节码的过程,因此不需要解释器。

当代码执行一段时间后,Profiler 线程就能收集到足够的信息,并能判断出哪些函数需要优化。

接下来,Crankshaft 编译器将会开启一个线程。 将 JavaScript 抽象语法树转换成一个被称为 Hydrogen 图的高度优化 static single-assignment (SSA 静态单赋值)的表示形式,并尝试优化该 Hydrogen 图。次过程会优化大量代码。

内联代码

第一个优化项:提前内联尽可能多的代码。所谓内联就是将函数调用(调用函数的代码行)替换为被调用函数的主体的过程。这个简单的过程使得之后的优化显得更有意义。替换过程如下所示:

image

隐藏类

JavaScript 是一种基于原型的语言,在执行过程中,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

没有任何的属性被定义,因此 C0 是一个空的类。

当执行了第一个语句 this.x = x 时(Point 函数的第一行),V8 将会创建以 C0 为基础的第二个隐藏类:C1。C1 描述了可以在内存中找到属性 x 的位置(相对于对象指针)。在这个案例中,x 的偏移量为 0,这意味着:如果将 Point 对象的属性视为一个数据块时,x 将会被存储在这数据块中的第一个位置。同时 V8 会更新 C0 类,这意味着:当属性 x 被添加到对象时,这个对象的隐藏类应该由 C0 转换到 C1,同时 C0 关联到 C1,形成转换链,如下图所示:

image

每当对象添加一条属性时,旧的隐藏类将会被更新为新的隐藏类。隐藏类的转换非常重要,因为 V8 允许不同对象关联相同的隐藏类(只要对象以相同的方式创建)。如果两个对象拥有相同的隐藏类,当相同的属性添加到该对象上时,V8 会确保这两个对象仍拥有相同的隐藏类,所有这个隐藏类上的优化都会相同。

当执行语句 this.y = y(Point 函数的第二行)时,将重复此过程。

当 y 属性被添加时,一个新的隐藏类 C2 被创建,C1 也关联上了 C2,同时该对象的隐藏类也更新到了 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 拥有相同的隐藏类,但是实现上,由于 a b 属性添加顺序的不同,p1 p2 拥有不同的隐藏类。因此这有个代码优化建议:以相同的顺序添加对象的属性,那么这些对象将拥有相同的隐藏类,这将会减少隐藏类的创建。

内联缓存

内联缓存:V8 中另一种优化动态类型语言的技术。内联缓存基于这样一个事实:相同类型的对象倾向于重复调用相同的方法。可以在这里找到内联缓存的详细说明。

这里将涉及到内联缓存的一般概念(以防你没有时间来详细了解上面的内容)。

那么,它是如何工作的呢?

V8 保存了最近一次方法调用中参数(引用类型的参数)的类型,并使用这些类型来假设将来函数调用时传入参数的类型。如果 V8 能准确的猜测函数参数的类型,那么它就可以通过之前对象的隐藏类来跳过访问对象属性的过程。

那么,如何关联隐藏类和内联缓存呢?

每当在特定对象上调用函数(将对象作为参数传入函数中)时,V8 引擎会找到该对象的隐藏类,用于确定某个属性的偏移量。当使用相同的隐藏类成功调用两次相同的函数之后,V8 将会省略隐藏类的查找,直接将属性的偏移量添加到对象上。对于之后该方法的调用,V8 引擎都会假定隐藏类未更改,并使用之前保存的偏移量直接跳到属性的内存地址。 这大大提高了执行速度。

内联缓存也是同类型对象共享隐藏类的重要原因。如果两个同类型的对象拥有不同隐藏类(正如我们前面的例子),V8 就不会使用内联缓存,因为即使两个同类型的对象,其不同隐藏类中属性可能会有不同的偏移量,如下图所示:

![两个对象类型相同,但隐藏类不同,导致属性的偏移量不同]image

编译为机器码

一旦 Hydrogen 图优化完成,Crankshaft 将其降低到一个称为 Lithium 的较低级表示形式。多数 Lithium 基于特定架构。寄存器分配在这个级别进行。

最后,Lithium 被编译成机器码。接着就是 OSR(on-stack replacement 堆叠更换)。在 V8 完成编译和优化这些耗时方法前,OSR 就可能已经在运行了,V8 不会移除它已执行的优化,相反的,它将转换我们拥有的所有上下文(堆栈、寄存器),方便我们在执行过程中直接切换到优化后的版本。这是一项非常复杂的任务,考虑到其他优化,V8 一开是就已经内联了代码。V8 并不是唯一这么做的的引擎。

还有一种称为反优化的保护措施,可以进行相反的转换,并在假设引擎不再适用的情况下去除优化还原为未优化的代码。

垃圾回收

V8 使用传统的 mark-and-sweep 来清理变量。标记阶段会阻塞 JavaScript 的执行。为了控制 GC 成本并使执行更稳定,V8 使用增量标记:它不遍历整个堆,而是尝试标记每个可能会被调用的对象,它只遍历堆的一部分,然后恢复正常执行。下一次 GC 运行时它将从上一次停止的地方继续。这会在正常执行期间有非常短的暂停。扫描阶段由单独的线程处理。

Ignition and TurboFan

随着 2017 年初 V8@5.9 版本的发布,V8 引入了新的执行流程。新的执行流程在 JavaScript 应用程序中实现了更大的性能改进并显着节省了内存。

新的执行流程建立在 Ignition( V8 的解释器)和 TurboFan( V8 的最新优化编译器)之上。

你可以在这里查看 V8 团队关于这个主题的博文。

自从 V8 的 5.9 版本发布以来,由于 V8 团队为了努力跟上新的 JavaScript 语言特性和这些特性所需要的优化,full-codegen 和 Crankshaft(2010年以来一直为V8服务的技术)已经不再被 V8 用于 JavaScript 的执行。

这意味着 V8 将会拥有更简单,更易于维护的体系结构。

image

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

最后,这里有一些关于如何编写优化的、更好的 JavaScript 的技巧。你可以很容易地从上面的内容理解这些技巧所带来的性能提升。

如何组织 JavaScript 代码

  1. 组织对象属性的顺序:始终以相同的顺序实例化对象的属性,以便共享隐藏的类以及随后的优化代码。
  2. 动态属性:对象实例化后继续添加属性将修改隐藏类,并减慢上一个隐藏的类优化的所有方法,因此对象的属性最好全部在构造函数中声明。
  3. 方法:重复执行的方法将比仅执行一次的方法运行得更快(由于内联缓存)。
  4. 数组:避免稀疏数组,由于稀疏数组中的键值不是自增的数字,因此稀疏数组使用哈希表进行存储。这种数组中的元素访问开销较高;另外,尽量避免预分配大数组,而是是按需增长;最后,不要删除数组中的元素,这会使键值变得稀疏。
  5. 标记值:V8 使用 32 位表示对象和数字。它使用一位来区别对象(标志= 1)和 SMI(小整数)(标志= 0),因此可使用 31 位。如果数值大于 31 位,那么 V8 会将数字封装,将其变成双精度(double)并创建一个新对象用于数字的保存。因此尽可能使用 31 位带符号的数字,避免产生封装。

参考

阅读更多

JavaScript 对象 & 原型

前言 这次的 why what or how 主题:JavaScript 对象 & 原型。 此类文章在百度上一搜一大把,其实不用再写了,但是本着把这个问题解释的清清楚楚明明白白,还是开始写了。 原因如下: 1. 面试宝典类文章,弄张图片一糊弄,让人觉得自己理解了。 2. 解释类文章,告诉你一堆语法,对语法一顿解释,告诉你就是这样的,还是没说清楚。 3. 很少有文章单独解释这个点!但这个点是基础!真的很重要! 所以本篇文章想说一说对象 & 原型,但为了确保能顺利理解,请先看完 JS 变量存储?栈 & 堆?NONONO!,因为该篇文章从变量存储的角度来解释 JavaScript 对象 & 原型,请确保看完,在看这篇文章。 什么是对象? 既然要说清楚,

By Breeze

浏览器下的 Event Loop

前言 javascript 是以单线程的形式运行在宿主环境下,javascript 采用了回调的形式来解决异步任务。 为什么是单线程? javascript 的最开始的出现是为了给 web 页面增添一些动态的效果,那么就避免不了获取页面上的元素信息,如果 javascript 是以多线程的形式运行在浏览器内,如果两个线程内的 javascript 同时去获取/修改,某个页面上的元素,那么浏览器该让哪个 javascript 线程拥有获取/修改该元素的权限呢?由于元素的信息会经常性的发生变化,那么又改如何去同步各个线程内所保存的元素信息呢? 所以综合以上问题, javascript 是单线程的原因就显示意见了,单线程在执行时,对于元素信息的引用在同一时间仅可能只有一个,那么以上所有的问题都不存在了。 什么是异步任务? 任何代码在执行时,都会碰到一些需要经过大量时间运算或是等待的代码,在浏览器的环境下,常见的就是 http 任务,比如:资源的加载(图片的 onload 事件),ajax 的请求(XMLHttpRequest 的 onLoad 事件)还有页面元素的点击事件以及

By Breeze

node 下的 Event Loop

前言 通过 浏览器下的 Event Loop ,可以得知 javascript 是一门事件驱动的语言, javascript 主线程通过不断的调用事件队列中的事件来完成异步任务。那么 Node 下是否也是如此? Node 下的事件队列 在 Node 官网有这样一篇文章:The Node.js Event Loop, Timers, and process.nextTick() 。 该文章主要讲述了 Node 中是如何处理以及实现 Node 下的 Event Loop。 主要包含以下内容: 事件队列 不同于浏览器,Node 下的有 6 个事件处理阶段,其执行顺序和队列名称如下: ┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │

By Breeze

什么是 HTML 5?

前言 作为程序员,技术的落实与巩固是必要的,因此想到写个系列,名为 why what or how 每篇文章试图解释清楚一个问题。 这次的 why what or how 主题:现在几乎所有人都知道了 HTML5 ,那么 H5 到底相比于 HTML4 有什么区别呢? 升级版?标准版! HTML5 做为 HTML 标准的第 5 版,于 2014 年发布,相信关注过 HTML5 的发展史的朋友都知道该版本是 WHATWG 和 W3C 握手言和后诞生的,是 W3C 组织与浏览器厂商相互妥协的结果,其中的绝大部分规范都由 WHATWG 组织所制定,之后由 W3C 采纳并入标准中。

By Breeze