【JSConf EU 2018】JavaScript引擎: 精粹部分

3,706 阅读9分钟

JSConf EU 2018圆满结束, 谷歌V8的开发者Mathias Bynens以及Benedikt Meurer一起发表了《JavaScript Engines: The Good Parts™》演讲,本文将带领大家回顾一下演讲上所提到的重点。

演讲第一部分: JavaScript引擎

JavaScript引擎

JavaScript引擎解析源代码并将其转换成抽象语法树(AST)。基于AST,解释器产生字节码。此时,引擎正在运行JavaScript代码。为了加快运行速度,字节码连同分析数据一起发送到编译器。编译器根据已有的分析数据做出某些假设,然后生成优化后机器代码。

1

JavaScript引擎中的解释器/编译器

通过对比主流JavaScript引擎之间的一些实现差异来说明JavaScript引擎是如何运行你的代码。

解释器快速生成未优化的字节码,编译器会花费更长的时间,但最终产生高度优化的机器代码。

2
以上基本就是V8在Chrome和Node.js中的工作流程

3
V8的解释器负责生成和执行字节码。当它运行字节码时,它收集分析数据,这些数据是优化的依据。当函数运行时,生成的字节码和分析数据被传递给TurboFan编译器,基于分析数据生成高度优化的机器代码。

4
SpiderMonkey是Mozilla的JavaScript引擎,在Firefox和SpiderNode中使用,它和我们上面所讲的流程有点不同。它有两个编译器。Baseline编译器生成一些优化的代码。结合在运行代码时收集的分析数据,IonMonkey编译器可以产生重度优化的代码。如果优化失败,IonMonkey 回退到Baseline的优化代码。

5
Chakra,微软的JavaScript引擎,用于Edge和Node-ChakraCore,有非常类似的两个优化编译器。解释器生成的字节码先通过SimuleJIT生成优化代码,这里的JIT代表即时编译器。结合分析数据,FuljJIT可以产生更加的优化代码。

6
JavaScriptCore(简称 JSC),苹果的JavaScript引擎,用于Safari和React Native,它包含三种不同的编译器。LLInt解释器生成字节码,可以经过Baseline编译器生成优化的代码。还可以通过DFG编译器进行进一步优化,最后还可以交给FTL编译器进行优化。

解释器可以快速生成字节码,但字节码通常执行效率不高。另一方面,编译器需要更长的时间,但最终会产生更高效的机器代码。快速获取代码以运行(解释器)或占用更多时间,但最终以最佳性能运行代码(编译器)之间存在权衡。

演讲第二部分:JavaScript的对象模型

ECMAScript规范基本上将所有对象定义为字典,并将字符串键映射到描述对象。

7

JavaScript对于数组的定义类似于对象。例如,包括数组索引在内的所有键都显式表示为字符串。数组中的第一个元素存储在键“0”。

8
“长度”属性只是另一个不可枚举和不可配置的属性。一旦元素添加到数组中,JavaScript会自动更新“length”属性的[[Value]描述对象。
9

演讲第三部分:属性的访问优化

属性访问是JavaScript程序中最常见的操作。对JavaScript引擎来说,快速访问属性是至关重要的。

const object = {
	foo: 'bar',
	baz: 'qux',
};

// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
//          ^^^^^^^^^^

Shape

在JavaScript程序中,具有相同属性键的对象是常见的。这样的对象具有相同的Shape。

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
// `object1` and `object2` have the same shape.`
在相同Shape的对象上访问相同的属性也是非常常见的:
`function logX(object) {
	console.log(object.x);
	//          ^^^^^^^^
}

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };

logX(object1);
logX(object2);

所以,JavaScript引擎可以基于对象的Shape优化属性的访问。

假设我们有一个属性为x和y的对象,它使用我们前面讨论过的字典数据结构:它包含作为字符串的键,并且他们指向各自属性的描述对象。

10
如果你访问了一个属性,例如object.y,JavaScript引擎将在js对象中查找关键字“y”,然后加载相应的描述对象,最后返回[[Value]]属性的值。

如果每个JS对象都存储描述对象,会造成大量的重复和不必要的内存开销。JavaScript引擎会将这些对象的Shape分开存储。

11
这个Shape使用offset代替了[[Value]],每一个具有相同Shape的JS对象都指向这个Shape实例。

12
当有多个对象时,只要它们有相同的Shape,只需要存储一个就可以!

所有JavaScript引擎都使用Shape作为优化,但它们并不都称之为Shape:

  • 学术论文称之为Hidden Classes
  • V8称之为Maps
  • Chakra称之为Types
  • JavaScriptCore称之为Structures
  • SpiderMonkey称之为Shapes 演讲中统一使用了Shape。

过渡链与过渡树

如果一个对象指向某个Shape,你给它添加一个新的属性,JavaScript引擎如何找到新的Shape。这类Shape在JavaScript引擎中形成所谓的“过渡链”。下面是一个例子:

13
对象开始时没有任何属性,因此指向空Shape。下一个语句将一个值为5键为“x”的属性赋值给这个对象,因此JavaScript引擎将JS对象指向一个包含属性“x”的Shape,并且将5添加到JS对象的第0位。下一行代码添加了一个属性“y”,因此引擎将JS对象指向另一个包含属性“x”和属性“y”的Shape,并且将6追加到JS对象的第1位。

我们甚至不需要为每个Shape存储完整的属性表。相反,每一个Shape仅需要知道它所引入的新属性。例如,在这种情况下,我们不必在最后一个Shape中存储关于“x”的信息,因为它可以在链中更早地找到。为了做到这一点,每一个Shape都和上一个Shape产生链接:

14
如果你在JavaScript代码中编写了o.x,JavaScript引擎通过过渡链找到引入属性“y”的Shape,从而找到找到属性“x”。

但是如果没有办法创建一个过渡链怎么办?例如,如果有两个空对象,并且向每个对象添加不同的属性呢?

const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;

在这种情况下,我们必须使用分支取代链,我们最终得到一个过渡树:

15

引擎对已经包含属性的对象应用了一些优化。要么从空对象开始添加“x”,要么有一个已经包含“x”的对象:

const object1 = {};
object1.x = 5;
const object2 = { x: 6 };

16
对象在一开始就指向包含属性“x”的Shape,有效地跳过空Shape。V8和SpiderMonkey就是这样做的。这种优化缩短了过渡链,并使其更高效地从文字构造对象。

内联缓存(ICs)

ICs是使JavaScript快速运行的关键因素!JavaScript引擎使用ICs来记住在何处查找对象属性的信息,以减少查找次数。 这里有一个函数getX,它获取一个对象并从中加载属性“x”:

function getX(o) {
	return o.x;
}

如果我们在JSC中运行这个函数,它会生成下面的字节码:

17
第一个get_by_id指令从第一个参数(arg1)加载属性“x”,并将结果存储到loc0中。第二个指令返回我们存储到的LoC0。

JSC还将内联缓存嵌入到get_by_id指令中,该指令由两个未初始化的槽组成。

18
现在假设我们使用{x:“a”}参数来调用getX。如我们所知,这个对象指向有属性“x”的Shape,并且该Shape存储了属性“x”的偏移量和描述对象。当第一次执行该函数时,get_by_id指令查找属性“x”,并发现该值被存储在偏移量0。
19
嵌入到get_by_id指令中的IC记住了这个属性是从哪个Shape以及偏移量中找到的:
20
对于后续的运行,IC只需要比较Shape,如果它与以前相同,只需从存储的偏移量中加载值即可。具体地说,如果JavaScript引擎看到对象指向了IC之前记录的Shape,那么就不需要重新去查找,可以完全跳过昂贵的属性查找。这比每次查找属性要快得多。

演讲第四部分:有效的存储数组

数组使用数组索引来存储属性。这些属性的值称为数组元素。为每个数组元素存储描述对象是不明智的。数组索引属性默认为可写、可枚举和可配置,JavaScript引擎将数组元素与其他属性分开存储。

看一下这个数组:

const array = [
	'#jsconfeu',
];

引擎存储的数组长度为1,并指向包含length的Shape,偏移值为0。

21

22
每个数组都有一个单独的元素后备存储区,它包含所有数组索引的属性值。JavaScript引擎不必为每个数组元素存储任何描述对象,因为它们通常都是可写的、可枚举的和可配置的。

如果更改数组元素的描述对象,会怎么样?

// Please don’t ever do this!
const array = Object.defineProperty(
	[],
	'0',
	{
		value: 'Oh noes!!1',
		writable: false,
		enumerable: false,
		configurable: false,
	}
);

上面的代码段定义了一个名为“0”的属性(恰好是一个数组索引),但它将属性设置为非默认值。

在这样的极端情况下,JavaScript引擎将整个元素后备存储区作为字典,映射描述对象到每个数组索引。

23
即使只有一个数组元素有非默认描述对象,整个数组的元素后备存储区也会进入这个缓慢而低效的模式。避免在元素索引上使用Object.defineProperty!

结语

本次演讲让我们明白JavaScript引擎是如何工作的,如何存储对象和数组,以及如何通过Shape和ICs优化了属性的访问,如何优化了数组的存储。基于这些知识,为我们确定了一些实用的可以帮助提高性能的编码技巧:

  • 总是以同样的方式初始化对象,它们最终会有相同的Shape。
  • 不要修改数组元素的描述对象,它们可以有效地存储。

注记

  • 本文结构及代码来自 Mathias Bynens以及Benedikt Meurer 在 JSConf EU 2018 上所作的演讲 JavaScript Engines: The Good Parts™。录像地址:https://www.youtube.com/watch?v=5nmpokoRaZI&index=11&list=PL37ZVnwpeshG2YXJkun_lyNTtM-Qb3MKa
  • 同时也可以阅读本次演讲的Blog:https://mathiasbynens.be/notes/shapes-ics