本文介绍了所有 JavaScript 引擎(而不仅仅 V8 引擎)共有的一些关键基本原理。作为 JavaScript 开发人员,深入了解 JavaScript 引擎的工作原理有助于您推理代码的性能特性。
这一切都始于你编写的 JavaScript 代码。JavaScript 引擎会解析源代码并将其转化为抽象语法树 (AST)。根据 AST,解释器就可以开始工作并生成字节码。好极了!此时,引擎就真正开始运行 JavaScript 代码了。
为了加快运行速度,字节码可以连同profiling数据一起发送给优化编译器。优化编译器会根据所掌握的profiling数据做出某些假设,然后生成高度优化的机器代码。
如果某个假设被证明是错误的,优化编译器就会取消优化(deoptimize)并返回解释器。
现在,让我们来深入了解这一工作流程中实际运行 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 引擎还有哪些共同点。
例如,JavaScript 引擎是如何实现 JavaScript 对象模型的,它们使用了哪些技巧来加快访问 JavaScript 对象属性的速度?事实证明,所有主要引擎的实现方式都非常相似。
ECMAScript 规范基本上将所有对象都定义为字典,字典的字符串键映射到property attributes。
除了[[value]]本身,规范还定义了这些属性:
- [[Writable]] 决定属性能否被重新赋值,
- [[Enumerable]] 决定属性能否被用在for-in循环中,
- [[Configurable]] 决定了属性能否被删除。
[[双括号]]符号看起来很奇怪, 但这只是规范表示不直接暴露给 JavaScript 的属性的方式。通过使用 Object.getOwnPropertyDescriptor API,您仍然可以在 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)属性。
const array = ['a', 'b'];
array.length; // → 2
array[2] = 'c';
array.length; // → 3
在本例中,数组创建时的长度为 2。然后我们将另一个元素赋值给索引 2,长度就会自动更新。
JavaScript 对数组的定义与对象类似。例如,包括数组索引在内的所有键都明确表示为字符串。数组中的第一个元素存储在键 "0 "下。
“length "属性只是另一种属性,恰好是不可数和不可配置的。
一旦有元素被添加到数组中,JavaScript 就会自动更新 "length "属性的[[Value]]属性。
一般来说,数组的行为与对象非常相似。
既然我们已经知道 JavaScript 中是如何定义对象的,下面就让我们深入了解 JavaScript 引擎是如何高效地处理对象的。
纵观 JavaScript 程序,访问属性是迄今为止最常见的操作。对于 JavaScript 引擎来说,快速访问属性至关重要。
const object = {
foo: 'bar',
baz: 'qux',
};
// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
// ^^^^^^^^^^
在 JavaScript 程序中,具有相同属性键的多个对象很常见。这些对象具有相同的形状(shape)。
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
// `object1` and `object2` have the same 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 引擎可以根据对象的形状优化对象属性访问。下面是其工作原理。
假设我们有一个具有 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)一词。
如果您有一个具有特定shape的对象,但您为它添加了一个属性,会发生什么情况?JavaScript 引擎如何找到新的shape?
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。
但如果无法创建过渡链,会发生什么情况呢?例如,如果您有两个空对象,并为每个对象添加了一个不同的属性,该怎么办?
const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
在这种情况下,我们就需要分支,最终形成的就不是一个链条,而是一棵过渡树:
在这里,我们创建了一个空对象 a,然后为其添加了一个属性 "x"。最后,我们将得到一个包含单个值的 JSObject 和两个形状:空形状和只有 x 属性的形状。
第二个示例一开始也是一个空对象 b,但随后添加了一个不同的属性 "y"。最后我们有了两个形状链,总共有三个形状。
这是否意味着我们总是从空形状开始?不一定。引擎会对已经包含属性的对象字面进行一些优化。比方说,我们要么从空对象字面开始添加 x,要么有一个已经包含 x 的对象字面:
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 点对象示例。
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)。
形状(shape)背后的主要动机是内联缓存或 IC 的概念。内嵌缓存是保证 JavaScript 快速运行的关键因素!JavaScript 引擎使用 IC 来记忆对象属性的查找信息,以减少昂贵的查找次数。
下面是一个函数 getX,它获取一个对象并从中加载属性 x:
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之前记录的相同,它就完全不需要再去查找属性信息--相反,可以完全跳过昂贵的属性信息查找。这比每次查找属性都要快得多。
对于数组来说,存储作为数组索引的属性是很常见的。此类属性的值称为数组元素。在每个数组中为每个数组元素存储属性会浪费内存。相反,JavaScript 引擎会利用数组索引属性可写入、可枚举和默认可配置的特性,将数组元素与其他命名的属性分开存储。
考虑一下这个数组:
const array = [
'#jsconfeu',
];
引擎会存储数组长度(1),并指向包含偏移量和 "长度 "属性的 Shape。
这与我们之前看到的情况类似......但数组值存储在哪里呢?
每个数组都有一个单独的元素后备存储空间,其中包含所有数组索引的属性值。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 删除。