前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JavaScript engine基础: Shapes and Inline Caches

JavaScript engine基础: Shapes and Inline Caches

作者头像
安全锚Anchor
修改2023-09-07 16:00:57
1950
修改2023-09-07 16:00:57
举报
文章被收录于专栏:安全基础安全基础

本文介绍了所有 JavaScript 引擎(而不仅仅 V8 引擎)共有的一些关键基本原理。作为 JavaScript 开发人员,深入了解 JavaScript 引擎的工作原理有助于您推理代码的性能特性。

JavaScript engine工作流程

这一切都始于你编写的 JavaScript 代码。JavaScript 引擎会解析源代码并将其转化为抽象语法树 (AST)。根据 AST,解释器就可以开始工作并生成字节码。好极了!此时,引擎就真正开始运行 JavaScript 代码了。

为了加快运行速度,字节码可以连同profiling数据一起发送给优化编译器。优化编译器会根据所掌握的profiling数据做出某些假设,然后生成高度优化的机器代码。

如果某个假设被证明是错误的,优化编译器就会取消优化(deoptimize)并返回解释器。

JavaScript engines中Interpreter/compiler工作流程

现在,让我们来深入了解这一工作流程中实际运行 JavaScript 代码的部分,即代码被解释和优化的部分,并了解主要 JavaScript 引擎之间的一些差异。

一般来说,有一个包含解释器和优化编译器的流水线。解释器能快速生成未经优化的字节码,而优化编译器需要的时间稍长,但最终能生成高度优化的机器码。

这种通用流水线与 Chrome 浏览器和 Node.js 中使用的 JavaScript 引擎 V8 的工作方式如出一辙:

V8 中的解释器称为 Ignition,负责生成和执行字节码。在运行字节码的同时,它还会收集profiling数据,这些数据可用于加快以后的执行速度。当一个函数变得热(hot)时,例如当它频繁运行时,生成的字节码和剖析数据就会传递给我们的优化编译器 TurboFan,以便根据profiling数据生成高度优化的机器代码。

火狐浏览器和 SpiderNode 中使用的 Mozilla JavaScript 引擎 SpiderMonkey 采用的方式略有不同。他们有两个优化编译器。解释器优化为基准编译器(Baseline compiler),后者会生成经过一定优化的代码。结合运行代码时收集的profiling数据,IonMonkey 编译器可以生成经过大量优化的代码。如果推测优化失败,IonMonkey 会退回到 Baseline 代码。

微软在 Edge 和 Node-ChakraCore 中使用的 JavaScript 引擎 Chakra 也有非常类似的设置,有两个优化编译器。解释器优化为 SimpleJIT(JIT 是 Just-In-Time 编译器的缩写),可生成经过一定优化的代码。结合剖析数据,FullJIT 可以生成经过更多优化的代码。

在 Safari 和 React Native 中使用的苹果 JavaScript 引擎 JavaScriptCore(缩写为 JSC)通过三种不同的优化编译器将其发挥到了极致。LLInt 是低级解释器,可优化为 Baseline 编译器,然后可优化为 DFG(数据流图)编译器,DFG 又可优化为 FTL(Faster Than Light)编译器。

为什么有些引擎其他引擎有更多的优化编译器?这就是权衡的问题。解释器可以快速生成字节码,但字节码通常效率不高。另一方面,优化编译器需要的时间稍长,但最终生成的机器码效率要高得多。在快速运行代码(解释器)与花费更多时间但最终以最佳性能运行代码(优化编译器)之间,需要权衡利弊。有些引擎会选择添加多个具有不同时间/效率特性的优化编译器,从而以增加复杂性为代价,对这些权衡进行更精细的控制。另一种权衡方法与内存使用有关;有关详情,请参阅我们的后续文章。

我们刚刚强调了每个 JavaScript 引擎在解释器和优化编译器管道方面的主要区别。但除了这些差异之外,从高层来看,所有 JavaScript 引擎的架构都是一样的:都有一个解析器和某种解释器/编译器流水线。

JavaScript中object的模型

让我们通过放大某些方面的实现方式,看看 JavaScript 引擎还有哪些共同点。

例如,JavaScript 引擎是如何实现 JavaScript 对象模型的,它们使用了哪些技巧来加快访问 JavaScript 对象属性的速度?事实证明,所有主要引擎的实现方式都非常相似。

ECMAScript 规范基本上将所有对象都定义为字典,字典的字符串键映射到property attributes

除了[[value]]本身,规范还定义了这些属性:

- [[Writable]] 决定属性能否被重新赋值,

- [[Enumerable]] 决定属性能否被用在for-in循环中,

- [[Configurable]] 决定了属性能否被删除。

[[双括号]]符号看起来很奇怪, 但这只是规范表示不直接暴露给 JavaScript 的属性的方式。通过使用 Object.getOwnPropertyDescriptor API,您仍然可以在 JavaScript 中获取任何给定对象和属性的这些属性。

代码语言:javascript
复制
const object = { foo: 42 };
Object.getOwnPropertyDescriptor(object, 'foo');
// → { value: 42, writable: true, enumerable: true, configurable: true }

好了,JavaScript 就是这样定义对象的。那么数组呢?

你可以把数组看作对象的一种特例。不同之处在于,数组对数组索引进行了特殊处理。这里的数组索引是 ECMAScript 规范中的一个特殊术语。在 JavaScript 中,数组仅限于 2³²-1 项。数组索引是在此限制范围内的任何有效索引,即从 0 到 2³²-2 之间的任何整数。

另一个区别是数组也有一个神奇的长度(length)属性。

代码语言:javascript
复制
const array = ['a', 'b'];
array.length; // → 2
array[2] = 'c';
array.length; // → 3

在本例中,数组创建时的长度为 2。然后我们将另一个元素赋值给索引 2,长度就会自动更新。

JavaScript 对数组的定义与对象类似。例如,包括数组索引在内的所有键都明确表示为字符串。数组中的第一个元素存储在键 "0 "下。

“length "属性只是另一种属性,恰好是不可数和不可配置的。

一旦有元素被添加到数组中,JavaScript 就会自动更新 "length "属性的[[Value]]属性。

一般来说,数组的行为与对象非常相似。

优化属性访问(Optimizing property access)

既然我们已经知道 JavaScript 中是如何定义对象的,下面就让我们深入了解 JavaScript 引擎是如何高效地处理对象的。

纵观 JavaScript 程序,访问属性是迄今为止最常见的操作。对于 JavaScript 引擎来说,快速访问属性至关重要。

代码语言:javascript
复制
const object = {
	foo: 'bar',
	baz: 'qux',
};

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

Shapes

在 JavaScript 程序中,具有相同属性键的多个对象很常见。这些对象具有相同的形状(shape)

代码语言:javascript
复制
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
// `object1` and `object2` have the same shape.

在形状相同的对象上访问相同的属性也很常见:

代码语言:javascript
复制
function logX(object) {
	console.log(object.x);
	//          ^^^^^^^^
}

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

logX(object1);
logX(object2);

有鉴于此,JavaScript 引擎可以根据对象的形状优化对象属性访问。下面是其工作原理。

假设我们有一个具有 x 和 y 属性的对象,它使用了我们之前讨论过的字典数据结构:它包含字符串形式的键,这些键指向各自的属性。

如果访问一个属性,例如 object.y,JavaScript 引擎会在 JSObject 中查找键'y',然后加载相应的属性,最后返回[[value]]。

但是,这些属性在内存中存储在哪里呢?我们应该把它们作为 JSObject 的一部分来存储吗?如果我们假设以后会看到更多具有这种形状的对象,那么将包含属性名称和属性的完整字典存储在 JSObject 本身就会造成浪费,因为所有具有相同形状的对象都会重复使用这些属性名称。这将造成大量重复和不必要的内存占用。作为一种优化,引擎可以单独存储对象的形状。

形状(shape)包含所有属性名称和属性,但不包括它们的[[value]]。相反,Shape 包含 JSObject 内部值的偏移量,以便 JavaScript 引擎知道在哪里可以找到这些值。每个具有相同形状的 JSObject 都会精确地指向这个 Shape 实例。现在,每个 JSObject 只需存储该对象独有的值。

当我们拥有多个对象时,好处就显而易见了。无论有多少个对象,只要它们具有相同的形状,我们就只需存储一次形状和属性信息!

所有 JavaScript 引擎都使用形状作为优化手段,但并不都称之为形状:

- 学术论文称之为Hidden Classes

- V8 称之为Maps

- Chakra 称之为Types

- JavaScriptCore 将其称为Structures

- SpiderMonkey 称之为Shapes

在本文中,我们将继续使用 "形状"(Shapes)一词。

Transition chains and trees(过渡链和树)

如果您有一个具有特定shape的对象,但您为它添加了一个属性,会发生什么情况?JavaScript 引擎如何找到新的shape?

代码语言:javascript
复制
const object = {};
object.x = 5;
object.y = 6;

这些形状在 JavaScript 引擎中形成所谓的过渡链。下面是一个例子:

该对象一开始没有任何属性,因此指向空形状。下一条语句为该对象添加了一个值为 5 的属性 "x",因此 JavaScript 引擎会转换到一个包含属性 "x "的形状,并在第一个偏移量 0 处为 JSObject 添加值 5。 下一行添加了一个属性 "y",因此引擎会转换到另一个同时包含 "x "和 "y "的形状,并在 JSObject(偏移量 1 处)添加值 6。

注意:添加属性的顺序会影响形状。例如,{ x: 4, y: 5 } 与 { y: 5, x: 4 } 形状并不相同

我们甚至不需要为每个形状存储完整的属性表。相反,每个形状只需要知道它引入的新属性。例如,在本例中,我们不需要在最后一个形状中存储有关 "x "的信息,因为它可以在链中的更早位置找到。为了做到这一点,每个形状都会链接回之前的形状:

如果在 JavaScript 代码中写入 o.x,JavaScript 引擎就会沿着过渡链查找属性 "x",直到找到引入属性 "x "的 Shape。

但如果无法创建过渡链,会发生什么情况呢?例如,如果您有两个空对象,并为每个对象添加了一个不同的属性,该怎么办?

代码语言:javascript
复制
const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;

在这种情况下,我们就需要分支,最终形成的就不是一个链条,而是一棵过渡树:

在这里,我们创建了一个空对象 a,然后为其添加了一个属性 "x"。最后,我们将得到一个包含单个值的 JSObject 和两个形状:空形状和只有 x 属性的形状。

第二个示例一开始也是一个空对象 b,但随后添加了一个不同的属性 "y"。最后我们有了两个形状链,总共有三个形状。

这是否意味着我们总是从空形状开始?不一定。引擎会对已经包含属性的对象字面进行一些优化。比方说,我们要么从空对象字面开始添加 x,要么有一个已经包含 x 的对象字面:

代码语言:javascript
复制
const object1 = {};
object1.x = 5;
const object2 = { x: 6 };

在第一个示例中,我们从空形状开始,然后过渡到也包含 x 的形状,就像我们之前看到的那样。

在对象 2 的情况下,从一开始就直接生成已经有 x 的对象,而不是从一个空对象开始过渡。

包含属性 "x "的对象字面从包含 "x "的形状开始,实际上跳过了空形状。至少 V8 和 SpiderMonkey 是这样做的。这种优化缩短了转换链,使从字面构建对象的效率更高。

Benedikt 关于surprising polymorphism in React applications的博文讨论了这些微妙之处如何影响实际性能。

下面是一个具有 "x"、"y "和 "z "属性的 3D 点对象示例。

代码语言:javascript
复制
const point = {};
point.x = 4;
point.y = 5;
point.z = 6;

正如我们之前所学到的,这会在内存中创建一个包含 3 个形状的对象(不包括空形状)。要访问该对象上的属性 "x",例如在程序中写入 point.x,JavaScript 引擎需要遵循链表:它从底部的形状开始,然后一路向上到顶部引入 "x "的形状。

如果我们经常这样做,速度就会非常慢,尤其是当对象有很多属性时。查找属性的时间为 O(n),即与对象的属性数量成线性关系。为了加快搜索属性的速度,JavaScript 引擎添加了一个 ShapeTable 数据结构。该 ShapeTable 是一个字典,将属性键映射到引入给定属性的相应形状。

等等,现在我们又回到了查字典的阶段......这就是我们开始添加形状(shape)之前的状态!那我们为什么还要使用形状呢?

因为形状(shape)可以实现另一种优化,即内联缓存(Inline Caches)。

内联缓存 Inline Caches (ICs)

形状(shape)背后的主要动机是内联缓存或 IC 的概念。内嵌缓存是保证 JavaScript 快速运行的关键因素!JavaScript 引擎使用 IC 来记忆对象属性的查找信息,以减少昂贵的查找次数。

下面是一个函数 getX,它获取一个对象并从中加载属性 x:

代码语言:javascript
复制
function getX(o) {
	return o.x;
}

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

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

JSC 还在 get_by_id 指令中嵌入了内联高速缓存,它由两个未初始化的槽组成。

现在,假设我们使用一个对象 { x: 'a' } 调用 getX。第一次执行函数时,get_by_id 指令会查找属性 "x",并发现该值存储在偏移 0 处。

嵌入 get_by_id 指令的 IC 会记住形状和找到属性的偏移量:

在随后的运行中,IC只需比较形状,如果形状与之前的相同,则只需从记忆的偏移量中加载值即可。具体来说,如果 JavaScript 引擎看到的对象的形状与IC之前记录的相同,它就完全不需要再去查找属性信息--相反,可以完全跳过昂贵的属性信息查找。这比每次查找属性都要快得多。

高效存储数组 Storing arrays efficiently

对于数组来说,存储作为数组索引的属性是很常见的。此类属性的值称为数组元素。在每个数组中为每个数组元素存储属性会浪费内存。相反,JavaScript 引擎会利用数组索引属性可写入、可枚举和默认可配置的特性,将数组元素与其他命名的属性分开存储。

考虑一下这个数组:

代码语言:javascript
复制
const array = [
	'#jsconfeu',
];

引擎会存储数组长度(1),并指向包含偏移量和 "长度 "属性的 Shape。

这与我们之前看到的情况类似......但数组值存储在哪里呢?

每个数组都有一个单独的元素后备存储空间,其中包含所有数组索引的属性值。JavaScript 引擎无需为数组元素存储任何属性,因为它们通常都是可写的、可枚举的和可配置的。

但在特殊情况下会发生什么?如果更改数组元素的属性,会发生什么情况?

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

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

在这种边缘情况下,JavaScript 引擎会将整个元素备份存储表示为一个字典,将数组索引映射到属性属性。

即使只有一个数组元素具有非默认属性,整个数组的后备存储也会进入这种缓慢而低效的模式。避免在数组索引上使用 Object.defineProperty!(我不知道为什么要这么做。这似乎是一件怪异而无用的事)。

总结

我们已经了解了 JavaScript 引擎如何存储对象和数组,以及形状和IC如何帮助优化对象和数组上的常见操作。基于这些知识,我们确定了一些有助于提高性能的实用 JavaScript 编码技巧:

- 始终以相同的方式初始化对象,以免它们最终形状各异。

- 不要弄乱数组元素的属性,以便有效地存储和操作它们。

本文系外文翻译,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系外文翻译前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • JavaScript engine工作流程
  • JavaScript engines中Interpreter/compiler工作流程
  • JavaScript中object的模型
  • 优化属性访问(Optimizing property access)
    • Shapes
      • Transition chains and trees(过渡链和树)
        • 内联缓存 Inline Caches (ICs)
        • 高效存储数组 Storing arrays efficiently
        • 总结
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档