V8中的快速属性

本文为翻译文章,因工作中遇到与V8引擎相关问题,网上相关文献大多为英文,故将遇到的个人觉得有价值的文献逐个翻译,如有错误欢迎指正。本篇[原文地址](https://v8.dev/blog/fast-properties#named-properties-vs.-elements)

在本篇博客中我们将讨论V8内部是怎样处理Javascript属性的。从JavaScript的角度,属性只有少量区别。Javascript对象大多表现为字典的形式:拥有键以及任意的对象作为值。但是该规范在迭代过程中对整数索引的属性和其他属性处理方式有所不同。除此之外,不同属性表现相似,与他们是否是整数索引无关。


但是,出于性能和内存方面的原因,V8确实依赖于几种不同的属性表示。
在这篇博文中我们将讨论V8在处理动态添加的属性时是怎样提供快速属性访问的。理解属性如何工作对于解释诸如内联缓存之类的优化在V8中如何工作是至关重要的。

这篇文章解释了在处理整数索引和命名属性之间的区别。最后,我们展示了V8在添加命名属性时如何维护隐藏类,以便提供一种快速识别对象形状的方法。然后,我们将继续深入了解如何根据使用情况为快速访问或快速修改优化命名属性。在最后一节中,我们将详细介绍V8如何处理整数索引属性或数组索引。

#命名属性 VS 元素

让我们从分析一个非常简单的对象开始:`{a:"foo",b:"bar"}`。这个对象有连个命名属性,"a"和"b"。它没有用于属性名的任何整数索引。数组索引式的属性,主要是元素,主要表现为数组。举个例子数组["foo","bar"]有连个数组索引的属性:0,对应值"foo",以及1,对应值"bar"。这是V8处理属性的主要区别。

下图揭示了一个基本的javascript对象在内存中是怎么样的。

V8中的快速属性_第1张图片

元素和属性没分别存储在单独的数据结构中,使得添加和访问属性或者
元素更有效率。

元素主要使用在多种`Array.prototype methods`方法中,诸如pop和slice

。由于这些函数以连续的方式访问属性,V8大多数情况下在内部以简单的数组表示它们。在这篇文章的后面,我们将解释有时如何切换到基于稀疏字典的表示来节省内存。

命名属性以一种相似的方式在单独的数组中存储。但是,与元素不同,我们不能简单地使用键来推断它们在属性数组中的位置;我们需要一些额外的元数据。在V8中每一个JavaScript对象都有一个与之相关的隐藏类HiddenClass。隐藏类存储关于对象形状的信息,以及从属性名到索引到属性的映射。为了应对复杂的事物,我们有时对属性使用字典而不是简单的数组。我们将在专门的部分中对此进行更详细的解释。

##从本节可以获知:

+ 以数组为索引的属性被存储在单独的元素区域

+ 命名属性存储在属性区域

+ 元素和属性都可以是数组或字典

+ 每一个Javascript对象都有一个隐藏类与之相关,用于保存对象的信息

#隐藏类与描述符数组

在解释元素的一般区别和命名属性之后,我们需要了解在V8中HiddenClass是如何工作的。这个隐藏类存储关于对象的元信息,包括对象上的属性数量和对对象原型的引用。隐藏类在概念上类似于典型的面向对象编程语言中的类。然而,在基于原型的语言(如JavaScript)中,通常不可能预先知道类。因此,在本例V8中,隐藏类是动态创建的,并随着对象的更改动态更新。隐藏类是对象形状的标识符,也是V8优化编译器和内联缓存的一个非常重要的组成部分。例如,如果优化编译器能够通过HiddenClass确保对象结构的兼容性,那么它就可以直接访问内联属性。

让我们来看看隐藏类的重要部分。


V8中的快速属性_第2张图片

在V8中,JavaScript对象的第一个字段指向一个隐藏类。事实上,对于V8堆上由垃圾收集器管理的任何对象都是如此。就属性而言,最重要的信息是存储属性数量的第三位字段和一个指向描述符数组的指针。描述符数组包含有关命名属性(如名称本身和值存储的位置)的信息。注意,这里不跟踪整数索引属性,因此描述符数组中没有条目。隐藏类的基本假设是对象具有相同结构时(例如,具有相同的命名属性,顺序相同)共享相同的隐藏类。为了实现这一点,当一个属性被添加到一个对象时,我们使用一个不同的隐藏类。在下面的示例中,我们从一个空对象开始,并添加三个命名属性。


V8中的快速属性_第3张图片

每次添加新属性时,对象的HiddenClass都会更改。在后台V8创建了一个将隐藏类链接在一起的转换树。V8知道在向空对象添加属性“a”时要使用哪个隐藏类。如果以相同的顺序添加相同的属性,则此转换树将确保最终得到相同的最终隐藏类。下面的示例显示,即使在中间添加简单的索引属性,我们也会遵循相同的转换树。

但是,如果我们创建一个新对象,该对象添加了一个不同的属性,在本例中是属性“d”,V8将为新的隐藏类创建一个单独的分支。

V8中的快速属性_第4张图片

##从本节中可以获知:

+ 拥有相同结构的对象(以相同顺序添加相同的属性)有着同样的隐藏类。

+ 默认情况下每添加一个新属性将导致一个新的隐藏类被创建。

+ 添加数组索引式的隐藏类不会创建新的隐藏类

#三种不同的命名属性

在概述V8如何使用隐藏类来跟踪对象的形状之后,让我们深入研究一下这些属性实际上是如何存储的。如上面的介绍所述,有两种基本的属性:命名属性和索引属性。下面的部分将介绍命名属性。

诸如{a:1,b:2}这样简单的对象在V8内部有多种不同的表现形式。虽然JavaScript对象的行为或多或少有点像外部的简单字典,但是V8试图避免字典,因为字典会妨碍某些优化,比如内联缓存,我们将在另一篇文章中解释。
**对象内属性 VS 常规属性**:V8支持所谓的对象内属性,这些属性直接存储在对象本身上。这些是V8中可用的最快的属性,因为它们不需要任何间接访问。对象内属性的数量由对象的初始大小决定。如果添加的属性多于对象中的空间,则将它们存储在属性存储区域中。属性存储添加了一个间接级别,但可以独立增长。


V8中的快速属性_第5张图片

**快属性 VS 慢属性**:下一个重要的区别是快属性和慢属性。通常,我们将线性属性存储中的属性定义为“fast”。快速属性只是通过属性存储中的索引来访问。要从属性的名称获得属性存储中的实际位置,我们必须参考HiddenClass上的描述符数组,正如我们之前概述的那样。

V8中的快速属性_第6张图片

但是,如果从对象中添加和删除许多属性,则会生成大量时间和内存开销来维护描述符数组和隐藏类。因此,V8也支持所谓的慢属性。具有慢属性的对象有一个自包含的字典作为属性存储。所有属性元信息不再存储在HiddenClass的描述符数组中,而是直接存储在属性字典中。因此,可以在不更新HiddenClass的情况下添加和删除属性。由于内联缓存不能使用dictionary属性,因此后者通常比fast属性慢。

##从本节可以获知:

+ 有三种不同的命名属性类型:in-object、fast和slow/dictionary。

1.对象内属性直接存储在对象本身上,并提供最快的访问速度。

2.快速属性存在于属性存储中,所有元信息都存储在HiddenClass的描述符数组中。

3.慢属性存在于自包含的属性字典中,元信息不再通过隐藏类共享。

+ 慢属性允许高效地删除和添加属性,但是访问慢于其他两种类型。

#元素或数组索引属性

到目前为止,我们已经研究了命名属性和忽略了数组中常用的整数索引属性。整数索引属性的处理与命名属性一样复杂。尽管所有的索引属性总是单独保存在元素存储中,但是有20种不同类型的元素!

**填充或空洞元素**:V8所做的第一个主要区别是,支持存储的元素是打包的还是有漏洞的。如果删除索引元素,或者不定义它,那么备份存储中就会出现漏洞。示例[1,,3]中中间的元素是个空洞。下面的例子说明了这个问题:

```

consto=['a','b','c'];

console.log(o[1]);// Prints 'b'.

deleteo[1];// Introduces a hole in the elements store.

console.log(o[1]);// Prints 'undefined'; property 1 does not exist.

o.__proto__={1:'B'};// Define property 1 on the prototype.

console.log(o[0]);// Prints 'a'.

console.log(o[1]);// Prints 'B'.

console.log(o[2]);// Prints 'c'.

console.log(o[3]);// Prints undefined

```


V8中的快速属性_第7张图片

简而言之,如果接收器上没有属性,我们必须继续查看原型链。考虑到元素是自包含的,例如,我们没有在HiddenClass上存储关于当前索引属性的信息,我们需要一个特殊的值the_hole来标记不存在的属性。这对于数组函数的性能至关重要。如果我们知道没有漏洞,即元素存储是打包的,我们就可以执行本地操作,而无需对原型链进行昂贵的查找。

**快速或字典元素**:元素的第二个主要区别是它们是快速模式还是字典模式。快速元素是简单的vm内部数组,其中属性索引映射到元素存储中的索引。然而,这种简单的表示对于只有很少条目被占用的非常大的稀疏/空穴数组是相当浪费的。在这种情况下,我们使用了基于字典的表示来节省内存,代价是访问稍微慢一些:

```

constsparseArray=[];

sparseArray[9999]='foo';// Creates an array with dictionary elements.

```

在本例中,分配一个包含10k项的完整数组是相当浪费的。相反,V8创建了一个字典,我们在其中存储一个键值描述符三元组。本例中的键为'9999',值为'foo',使用默认描述符。考虑到我们没有办法在HiddenClass上存储描述符的详细信息,V8在使用自定义描述符定义索引属性时,会使用慢速元素:

```

constarray=[];

Object.defineProperty(array,0,{value:'fixed'configurable:false});

console.log(array[0]);// Prints 'fixed'.

array[0]='other value';// Cannot override index 0.

console.log(array[0]);// Still prints 'fixed'.

```

在本例中,我们在数组上添加了一个不可配置的属性。此信息存储在慢元素字典三元组的描述符部分。需要注意的是,数组函数在具有慢元素的对象上执行得相当慢。

**Smi 和 Double 元素**:对于快速元素,V8中还有一个重要的区别。例如,如果您只在数组中存储整数(一种常见的用例),则GC不必查看数组,因为整数直接编码为所谓的小整数(Smis)。另一种特殊情况是数组只包含双精度。与Smis不同,浮点数通常表示为包含多个单词的完整对象。然而,V8为纯双数组存储原始双精度,以避免内存和性能开销。下面的例子列出了Smi和double元素的4个例子:

```

consta1=[1,2,3];// Smi Packed

consta2=[1,,3];// Smi Holey, a2[1] reads from the prototype

constb1=[1.1,2,3];// Double Packed

constb2=[1.1,,3];// Double Holey, b2[1] reads from the prototype

```

特殊元素:到目前为止,我们已经涵盖了20种不同元素中的7种。为了简单起见,我们为TypedArrays排除了9种元素类型,为String包装器排除了另外两种元素类型,最后但并非最不重要的是,为arguments对象排除了另外两种特殊的元素类型。

**The ElementsAccessor**:可以想象,我们并不热衷于用c++写20次数组函数,每种元素都写一次。这就是c++的魔力发挥作用的地方。我们没有一次又一次地实现数组函数,而是构建了ElementsAccessor,在这里,我们大多数情况下只能实现从后备存储中访问元素的简单函数。ElementsAccessor依赖CRTP为每个数组函数创建专门的版本。因此,如果你调用数组上的slice之类的东西,V8会在内部调用用c++编写的内置函数,并通过ElementsAccessor将其分派到函数的特殊版本:


V8中的快速属性_第8张图片

从本节中可以获知:

+ 有快速和字典模式的索引属性和元素。

+ 快速属性可以被压缩,也可以包含表示已删除索引属性的漏洞。

+ 元素对其内容进行专门化,以加速数组函数并减少GC开销。

理解属性如何工作是V8中许多优化的关键。对于JavaScript开发人员来说,许多这些内部决策都是不可见的,但是它们解释了为什么某些代码模式比其他模式更快。

更改属性或元素类型通常会导致V8创建一个不同的隐藏类,这可能导致类型污染,从而阻止V8生成最佳代码。请继续关注关于V8的vm -internal如何工作的后续文章。

你可能感兴趣的:(V8中的快速属性)