本文为译文,原文地址:http://v8project.blogspot.com...,作者,@Camillo Bruni ,V8 JavaScript Engine Team Blog
在这篇博客中,我们想解释 V8 如何在内部处理 JavaScript 属性。从 JavaScript 的角度来看,属性只有一些区别。JavaScript 对象主要表现为字典,字符串作为键名以及任意对象作为键值。然而,该规范在迭代过程中对整数索引(integer-indexed
)属性和其它属性进行了不同的处理。除此之外,不同的属性的行为大致相同,与它们是否为整数索引无关。
然而,在 V8 引擎下,由于性能和内存的原因,确实依赖于几种不同的属性表示。在这篇博客中,我们将介绍 V8 如何在处理动态添加的属性时提供快速的属性访问。了解属性的工作原理对于解释诸如内联缓存(inline caches
)在 V8 中的优化是至关重要的。
这篇博客解释了处理整数索引和命名属性(named properties
)的区别。之后我们展示了当添加命名属性时 V8 如何维护 HiddenClasses
,便于提供一种快速的方式来识别对象的形状。然后,我们将继续深入了解命名属性如何针对快速访问进行优化,或依据用途进行快速修改。在最后一个章节,我们将提供有关 V8 如何处理整数索引或数组索引(array indices
)的详细信息。
命名属性(Named Properties)和元素(Elements)
我们首先来分析一个简单的对象,如 {a: "foo", b: "bar"}
。该对象有两个命名属性,“a” 和 “b”。它是没有任何属性名称的整数索引。数组索引(array-indexed properties
)的属性(通常称为元素),在数组上最为突出。例如,数组 ["foo", "bar"]
,有两个数组索引属性:0,值为“foo”,1,值为“bar”。这是 V8 处理属性的第一个主要区别。
下图显示了一个基本的 JavaScript 对象在内存中的样子。
元素和属性存储在两个单独的数据结构中,这使得添加和访问属性或元素对于不同的使用模式更有效。
元素主要用于各种 Array.prototype
方法,如 pop
或 slice
。假设这些函数访问连续范围内的属性,V8 大部分时间上也将它们内部表示为简单的数组。在这篇文章的后面,我们将会解释我们如何切换到基于稀疏字典的表示(sparse dictionary-based representation
)来节省内存。
命名属性以类似的方式存储在单独的数组中。然而,不同于元素,我们不能简单地使用键推断它所在数组中的位置,我们需要一些额外的元数据。在 V8 中,每个 JavaScript 对象都有一个 HiddenClass 关联。HiddenClass 存储有关对象形状的信息,其中包括从属性名到索引再到属性的映射。为了是事情复杂化,我们有时会为属性而不是简单的数组使用字典。我们将在专门的章节中更详细地解释这一点。
从这一节开始:
- 数组索引的属性存储在单独的元素存储中。
- 命名的属性存储在属性存储中。
- 元素和属性可以是数组或字典。
- 每个 JavaScript 对象都有一个关联的 HiddenClass,它保存关于对象形状的信息。
HiddenClass 和 DescriptorArrays
在解释元素和命名属性的区别之后,我们需要看看 HiddenClass 在 V8 中的工作原理。HiddenClass 存储有关对象的元信息,包括对象上的属性以及对象原型的引用数量。HiddenClass 在概念上类似与典型的面向对象编程语言中的类。然而,在基于原型的语言(如 JavaScript )中,通常不可能预先知道类。因此,在这种情况下,V8 引擎的 HiddenClass 是随机创建的,并随着对象的改变而动态更新。HiddenClass 作为对象形状的标识符,并且是 V8 优化编译器和内联缓存(inline caches
)的一个非常重要的组成部分。例如,优化编辑器可以直接内联属性访问,如果它可以通过 HiddenClass 确保兼容对象结构。
让我们来看看 HiddenClass 的重要部分。
在 V8 中,JavaScript 对象的第一个字段指向一个 HiddenClass。(实际上,这是在 V8 堆上由垃圾收集器管理的任何对象的情况)。在属性方面,最重要的信息是存储属性数量的第三位字段和指向描述符数组的指针。描述符数组包含有关命名属性的信息,如名称本身和存储值的位置。请注意,我们在这里不跟踪整数索引属性,因此描述符数组中没有条目。
关于 HiddenClass 的基本假设是具有相同结构的对象。例如,相同的命名属性以相同的顺序共享相同的 HiddenClass。为了实现这一点,当一个属性被添加到一个对象时,我们使用一个不同的 HiddenClass。在下面的例子中,我们从一个空对象开始,并添加三个命名属性。
每次添加新的属性时,对象的 HiddenClass 都会被更改。在后台 V8 创建一个将 HiddenClass 链接在一起的转换树。V8 知道当你向空对象添加属性“a”时要使用哪个 HiddenClass。如果以相同的顺序添加相同的属性,则此转换树将确保最终具有相同的最终 HiddenClass。以下实例显示,即使我们在两者之间添加简单的索引属性,也将遵循相同的转换树。
然而,如果我们创建一个新的对象来获取不同的属性,在这种情况下,属性“b”,V8 将为新的 HiddenClass 创建一个单独的分支。
从本节开始:
- 具有相同结构(相同属性的相同顺序)的对象具有相同的 HiddenClass。
- 默认情况下,添加的每个新的命名属性都将创建一个新的 HiddenClass。
- 添加数组索引属性不会创建新的 HiddenClass。
三种不同类型的命名属性
在概述 V8 如何使用 HiddenClass 跟踪对象的形状之后,让我们来看就这些属性实际是如何存储的。如上面的介绍所述,有两种基本类型的属性:命名和索引。以下部分包含命名属性。
一个简单的对象,如 {a: 1, b: 2}
,可以在 V8 中有各种内部表现。虽然 JavaScript 的行为或多或少与外部的简单字典相似,但 V8 视图避免使用字典,因为它们阻碍了一些优化,例如内联缓存,我们将在单独的文章中解释。
In-object 和 Normal Properties:V8 支持直接存储在对象本身上的所谓 in-object
属性。这些是 V8 中可用的最快属性。因为它们可以无间接访问。对象 in-object
的数量由对象的初始大小预先确定。如果对象中有空格添加了更多属性,它们将被存储在属性存储中。属性存储添加了一个间接级别,但可以独立生长。
fast 和 slow 属性:下一个重要区别在于 fast
和 slow
之间的属性。通常来说我们将线性属性存储中存储的属性称为“fast”。fast
属性是可以简单的通过索引来访问的。要从属性的名称到属性存储中的实际位置,我们必须先查看 HiddenClass 中的描述符数组,如前所述。
然而,如果许多属性从对象中添加和删除,则可能会生成大量时间和内存开销来维护描述符数组和 HiddenClass。因此,V8 也支持所谓的 slow
属性。具有 slow
属性的对象具有自包含的字典作为属性存储。所有属性元信息不再存储在 HiddenClass 中的描述符数组中,而是直接存储在属性字典中。因此,可以添加和删除属性,而无需更新 HiddenClass。由于内联缓存不能与字典属性一起使用,后者通常比 fast
属性慢。
从这一节开始:
-
有三种不同的命名属性类型:
in-object
,fast
和slow
字典。-
in-object
属性直接存储在对象本身上,并提供最快访问。 -
fast
属性存储在属性中,所有元信息都存储在 HiddenClass 的描述符数组中。 -
slow
属性存储在自包含(self-contained)属性字典中,元信息不再通过 HiddenClass 共享
-
-
slow
属性允许有效的属性删除和添加,但访问速度比其他两种类型更慢。
元素或数组索引属性
到目前为止,我们已经查看了命名属性,忽略了常用于数组的整数索引属性。整数索引属性的处理和命名属性的复杂性相同。即使所有索引属性始终在元素存储中单独存储,也有 20 种不同类型的元素!
Packed 或 Holey 元素:V8 做出的第一个主要区别是元素是否支持存储打包(packed)或有空位(holes)。如果你删除索引元素,或者你没有定义它,你将在后台存储中找到空位。一个简单的例子是 [1,,3],第二个条目是一个空位。下面的例子说明了这个问题:
const o = "a", "b", "c" ();
console.log(o1 ()); // Prints "b".
delete o1 (); // Introduces a hole in the elements store.
console.log(o1 ()); // Prints "undefined"; property 1 does not exist.
o.proto = {1: "B"}; // Define property 1 on the prototype.
console.log(o0 ()); // Prints "a".
console.log(o1 ()); // Prints "B".
console.log(o2 ()); // Prints "c".
console.log(o3 ()); // Prints undefined
简单来说,如果接收方不存在属性,则必须继续查找原型链。鉴于元素是独立的,例如我们不在 HiddenClass 上存储有关当前索引属性的信息,因此我们需要一个名为 the_hole 的特殊值来标记不存在的属性。这对于数组非常重要。如果我们知道没有空位,即元素存储被打包,我们可以执行本地操作,而不必浪费在原型链上查找。
Fast 或 Dictionary 元素:元素上第二个主要的区别是它们是 fast 还是 dictionary 模式。fast 元素是简单的 VM 内部数组,其中属性索引映射到元素存储中的索引。然而,对于只有少数条目被占用的非常大的 sparse/holey 数组,这几乎是相当浪费的。在这种情况下,我们使用基于字典的表示形式来节省内存,代价是访问速度稍慢:
const sparseArray = ();
sparseArray9999 () = "foo"; // Creates an array with dictionary elements.
在这个例子中,使用 10k 条目分配一个完整的数组那是相当浪费的。而 V8 会创建一个字典,我们存储一个键值描述符三元组。在这个例子中,键名会是 9999,键值为 “foo” 和默认描述符。鉴于我们没有办法在 HiddenClass 上存储描述符详细信息,所以当你使用自定义描述符定义索引属性时,V8 将采用 slow 元素:
const array = ();
Object.defineProperty(array, 0, {value: "fixed", configurable: false});
console.log(array0 ()); // Prints "fixed".
array0 () = "other value"; // Cannot override index 0.
console.log(array0 ()); // Still prints "fixed".
在这个例子中,我们在数组中添加了一个不可配置的属性。该信息存储在 slow 元素字典三元组的描述符部分中。重要的是要注意,对于具有 slow 元素的对象,Array 函数执行的相当慢。
Smi 和 Double 元素:对于 fast 元素,V8 中还有另一个重要区别。例如,如果只将数组中的整数存储在一个常见的用例中,则 GC 不必查看数组,因为整数直接编码为所谓的小整数(Smis)。另一个特殊情况是数组只包含 doubles。与 Smis 不同,浮点数通常表示为占据多个单词的完整对象。然而,V8 存储纯双数组的原始双精度,以避免内存和性能开销。以下示例列出了 Smis 和 double 元素的 4 个示例:
const a1 = 1, 2, 3 (); // Smi Packed
const a2 = 1, , 3 (); // Smi Holey, a21 () reads from the prototype
const b1 = 1.1, 2, 3 (); // Double Packed
const b2 = 1.1, , 3 (); // Double Holey, b21 () reads from the prototype
特殊元素:目前为止,我们涵盖了 20 种不同元素中的 7 种。为了简单起见,我们排除了 TpyedArrays 的 9 个元素类型,以及两个用于字符串包装,最后剩下两个更特殊的元素种类的参数对象。
ElementsAccessor: 可以想象,我们并不是完全热衷于在 C++ 中编写数组函数 20 次,对于每一个元素都是一样。那就是展现 C++ 魔法的时刻了,而不是一遍遍地实现 Array 函数,我们构建了 ElementsAccessor,只需要实现从后备存储器访问元素的简单函数。ElementsAccessor 依赖于 CRTP 来创建每个 Array 函数的专用版。因此,如果你在数组上调用 slice,V8 就会内部调用 C++ 编写的内建函数,并通过 ElementsAccessor 调用该函数的专用版本:
从这一节开始:
- fast 和字典模式索引的属性和元素
- fast 属性可以打包,也可以包含指示索引属性已被删除的空位。
- 元素专门针对其内容来加快阵列功能并降低 GC 开销。
了解属性的工作原理是 V8 中许多优化的关键。对于 JavaScript 开发者,许多内部决策不是直接可见的,但它们解释了为什么某些代码模式比其他代码模式更快。更改属性或元素类型通常会导致 V8 创建一个不同的 HiddenClass,这可能导致类似污染,从而阻止 V8 生成最佳代码。请继续关注 V8 的内部虚拟机的工作原理。