JavaScript 引擎基础:形状和内联缓存

本文描述了所有 JavaScript 引擎共有的一些关键基础知识——而不仅仅是 V8,作者(Benedikt 和 Mathias)使用的引擎。 作为一名 JavaScript 开发人员,深入了解 JavaScript 引擎的工作原理有助于我们分析代码的性能特征。


JavaScript 引擎管道

这一切都始于我们编写的 JavaScript 代码。 JavaScript 引擎解析源代码并将其转换为抽象语法树 (AST)。 基于该 AST,解释器可以开始做它的事情并生成字节码。 这很伟大! 那时引擎实际上正在运行 JavaScript 代码。

JavaScript 引擎基础:形状和内联缓存

为了使其运行得更快,可以将字节码与分析数据一起发送到优化编译器。 优化编译器根据它拥有的分析数据做出某些假设,然后生成高度优化的机器代码。

如果在某个时候其中一个假设被证明是不正确的,则优化编译器会取消优化并返回给解释器。

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

现在,让我们放大这个管道中实际运行 JavaScript 代码的部分,即代码被解释和优化的部分,并回顾一下主要 JavaScript 引擎之间的一些差异。

一般来说,有一个包含解释器和优化编译器的管道。 解释器快速生成未优化的字节码,优化编译器需要更长的时间,但最终生成高度优化的机器码。

 

这个通用管道几乎就是 Chrome 和 Node.js 中使用的 JavaScript 引擎 V8 的工作方式:

 

V8 中的解释器称为 Ignition,负责生成和执行字节码。 当它运行字节码时,它会收集分析数据,这些数据可用于加快以后的执行速度。 当一个函数变得很热时,例如当它经常运行时,生成的字节码和分析数据被传递给我们的优化编译器 TurboFan,以根据分析数据生成高度优化的机器代码。

 

在 Firefox 和 SpiderNode 中使用的 Mozilla JavaScript 引擎 SpiderMonkey 的做法略有不同。 他们有两个而不是一个优化编译器。 解释器优化到 Baseline 编译器中,后者生成稍微优化的代码。 结合运行代码时收集的分析数据,IonMonkey 编译器可以生成高度优化的代码。 如果推测优化失败,IonMonkey 会回退到基线代码。

 

Edge 和 Node-ChakraCore 中使用的 Microsoft JavaScript 引擎 Chakra 具有非常相似的设置,带有两个优化编译器。 解释器优化为 SimpleJIT——其中 JIT 代表即时编译器——它会生成稍微优化的代码。 结合分析数据,FullJIT 可以生成高度优化的代码。

 

JavaScriptCore(缩写为 JSC)是 Apple 的 JavaScript 引擎,用于 Safari 和 React Native,通过三种不同的优化编译器将其发挥到了极致。 LLInt,Low-Level Interpreter,优化到 Baseline 编译器,然后可以优化到 DFG(Data Flow Graph)编译器,后者又可以优化到 FTL(Faster Than Light)编译器。

为什么有些引擎比其他引擎有更多的优化编译器? 这都是关于权衡取舍的。 解释器可以快速生成字节码,但字节码通常效率不高。 另一方面,优化编译器需要更长的时间,但最终会生成更高效的机器代码。 在快速运行代码(解释器)或花费更多时间但最终以最佳性能运行代码(优化编译器)之间存在权衡。 一些引擎选择添加多个具有不同时间/效率特征的优化编译器,允许以额外的复杂性为代价对这些权衡进行更细粒度的控制。 另一个权衡与内存使用有关。 有关详细信息,请参阅我们的后续文章。

我们刚刚强调了每个 JavaScript 引擎在解释器和优化编译器管道方面的主要区别。 但除了这些差异之外,在较高的层次上,所有 JavaScript 引擎都具有相同的架构:有一个解析器和某种解释器/编译器管道。


JavaScript 的对象模型

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

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

ECMAScript 规范本质上将所有对象定义为字典,字符串键映射到属性属性。

 

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

  • [[Writable]] 确定属性是否可以重新分配给,
  • [[Enumerable]] 确定属性是否出现在 for-in 循环中,
  • 和 [[Configurable]] 决定是否可以删除该属性。

[[double square brackets]] 符号看起来很时髦,但这正是规范表示不直接暴露给 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);
//          ^^^^^^^^^^

Shapes

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

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]]。

但是这些 property 属性存放在内存的什么地方呢? 我们应该将它们存储为 JSObject 的一部分吗? 如果我们假设我们稍后会看到更多具有这种形状的对象,那么将包含属性名称和属性的完整字典存储在 JSObject 本身上是一种浪费,因为属性名称对于具有相同形状的所有对象都是重复的。 这是很多重复和不必要的内存使用。 作为优化,引擎单独存储对象的形状。

 

此 Shape 包含所有属性名称和特性,但它们的 [[Value]] 除外。 相反,Shape 包含 JSObject 内部值的偏移量,以便 JavaScript 引擎知道在哪里可以找到这些值。 每个具有相同形状的 JSObject 都指向这个 Shape 实例。 现在每个 JSObject 只需存储该对象唯一的值。

 

当我们有多个对象时,好处就很明显了。 不管有多少对象,只要它们的形状相同,我们只需要存储一次形状和属性信息!

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

  • 学术论文称它们为隐藏类(混淆 w.r.t. JavaScript 类)
  • V8 称它们为 Maps(混淆了 w.r.t. JavaScript Maps)
  • Chakra 称它们为类型(混淆了 w.r.t. JavaScript 的动态类型和 typeof)
  • JavaScriptCore 称它们为结构
  • SpiderMonkey 称它们为形状

在整篇文章中,我们将继续使用术语形状。

过渡链和树

如果你有一个具有特定形状的对象,然后你给它添加了一个属性,会发生什么? JavaScript 引擎如何找到新形状?

const object = {};
object.x = 5;
object.y = 6;

这些形状在 JavaScript 引擎中形成所谓的转换链。 这是一个例子:

 

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

我们甚至不需要为每个 Shape 存储完整的属性表。 相反,每个 Shape 只需要知道它引入的新属性。 例如,在这种情况下,我们不必在最后一个形状中存储有关“x”的信息,因为它可以在链中的较早位置找到。 为了使这项工作有效,每个 Shape 都链接回其以前的形状:

 

如果我们在 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 的形状,就像我们之前看到的那样。

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

 

包含属性“x”的对象字面量从包含“x”的形状开始,有效地跳过了空形状。 这是(至少)V8 和 SpiderMonkey 所做的。 这种优化缩短了转换链,使从文字构造对象变得更加高效。

Benedikt 关于 React 应用程序中令人惊讶的多态性的博客文章讨论了这些微妙之处如何影响现实世界的性能。

这是一个具有属性“x”、“y”和“z”的 3D 点对象的示例

const point = {};
point.x = 4;
point.y = 5;
point.z = 6;

正如我们之前了解到的,这会在内存中创建一个具有 3 个形状的对象(不包括空形状)。 要访问该对象的属性“x”,例如 如果我们在程序中编写 point.x,则 JavaScript 引擎需要遵循链表:它从底部的 Shape 开始,然后向上移动到在顶部引入 ‘x’ 的 Shape。

 

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

 

等一下,现在我们回到字典查找……这就是我们首先开始添加形状之前的位置! 那么,为什么我们要为形状烦恼呢?

原因是形状启用了另一种称为内联缓存的优化。


内联缓存 (IC)

形状背后的主要动机是内联缓存或 IC 的概念。 IC 是使 JavaScript 快速运行的关键要素! JavaScript 引擎使用 IC 来存储有关在何处查找对象属性的信息,以减少昂贵的查找次数。

这是一个函数 getX,它接受一个对象并从中加载属性 x:

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

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

 

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

JSC 还在 get_by_id 指令中嵌入了一个 Inline Cache,它由两个未初始化的槽组成。

 

现在假设我们用对象 { x: ‘a’ } 调用 getX。 正如我们所了解的,该对象具有一个带有属性“x”的形状,并且该形状存储该属性 x 的偏移量和属性。 当您第一次执行该函数时,get_by_id 指令会查找属性“x”并发现该值存储在偏移量 0 处。

 

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

 

后续运行时,IC只需要比较形状,如果和之前一样,就从记忆的 offset 中加载值即可。 具体来说,如果 JavaScript 引擎看到具有 IC 之前记录的形状的对象,它根本不再需要访问属性信息——相反,可以完全跳过昂贵的属性信息查找。 这比每次都查找属性要快得多。


有效地存储数组

对于数组,通常存储作为数组索引的属性。 此类属性的值称为数组元素。 为每个数组中的每个数组元素存储属性属性会浪费内存。 相反,JavaScript 引擎使用数组索引属性在默认情况下是可写、可枚举和可配置的这一事实,并将数组元素与其他命名属性分开存储。

考虑这个数组:

const array = [
    '#jsconfeu',
];

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

 

这与我们之前看到的类似……但是数组值存储在哪里?

 

每个数组都有一个单独的元素后备存储,其中包含所有数组索引的属性值。 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 编码技巧:

不要乱用数组元素的 property 属性,这样可以高效地存储和操作它们。