type
Post
status
Published
date
Jul 1, 2022
slug
fast-properties-in-v8
summary
工作中遇到同事讨论JS里面属性访问的速度比较,有人认为用”.”访问比”[]”语法访问更慢。当然最后发现是没有区别的,不过这也让我们好奇浏览器是怎么加速JS的属性访问的。
tags
开发
前端
category
学习思考
icon
password
Property
Nov 13, 2022 07:13 AM
本文翻译自 V8 文档 Fast properties in V8
本文将阐述 V8 引擎内部如何处理 JavaScript 对象属性访问。从 JavaScript 角度来看,对象的属性有仅有少量的区别。大多情况下,JavaScript 对象表现得很像一个字典,拥有字符串类型的键和任意类型的值。语言规范要求在进行迭代时要对整数作为键时的属性进行区别对待。其他情况下,JavaScript 属性几乎没有任何区别,不管是数值属性还是其他字符串属性。
但是,在 V8 的实现底层,考虑到性能优化,会对不同的属性做不同的处理。本文将阐述 V8 如何提供高性能的属性访问,同时如何处理动态添加的属性。理解 V8 中 JavaScript 属性是如何工作的,对于理解 V8 的一些优化工作(例如inline caches)是十分重要的。
首先,本文会解释在处理整数属性和其他属性的区别。之后,会介绍 V8 中一种称为”隐藏类”(hidden classes)的机制,并且在添加动态属性时如何维护它。接下来继续介绍属性的访问和修改是如何得到优化的。最后,会阐述 V8 中处理整数属性或数组索引的细节。

属性 vs 元素

我们来看一个非常简单的对象:{ a: 'foo', b: 'bar' }。这个对象有两个字符串属性(named properties)'a''b'。它的属性中没有整数属性。而对于数组而言,它们由元素(elements)构成。例如['foo', 'bar']有两个整数属性:0,值为'foo'1,值为'bar'。V8 中对这两种属性的处理是不同的。
一个基本的 JavaScript 对象在内存中看起来像这样:
notion image
元素和属性在不同的数据结构中存储,这使得添加、访问一个属性或元素都更加高效。
元素主要用于Array.protoptype中的数组方法,例如pop()slice()等。这些方法访问连续的整数属性,V8 在内部多数时候也将这些元素存储为简单的数组。
本文后面会提到,元素的存储在某些时候也会采用一种稀疏的、基于字典的存储方式来节省内存。
属性则以类似的方式保存在另一个数组中。但是和元素不同,不能简单地通过属性名来推断它在属性数组中的位置,需要一些其他元信息。在 V8 中,每个 JavaScript 对象都有关联到一个隐藏类。这个隐藏类存储了这个对象的“形状”(shape)信息,其中包含一个由属性名到属性数组索引的映射。有时也直接用一个字典来保存属性,而不是简单地用属性数组。
重要的是要记住:1)整数属性,即“元素”,和字符串属性是分开存储的;2)字符串属性都保存在一个字符串属性的数组中;3)元素和属性都可以在底层用数组或字典来保存;4)每一个 JavaScript 对象都有一个“隐藏类”,它记录这个对象的“形状”。

隐藏类和描述符数组

在了解了字符串属性和元素的区别后,我们需要了解在 V8 中,隐藏类如何工作。这个隐藏类存储了对象的元信息,例如对象属性的数量和其原型的引用。从概念上说,隐藏类类似于其他面向对象编程语言中的”类“。然而,在 JavaScript 这样基于原型的编程语言中,不可能直接获得它的“类”。因此,V8 中的隐藏类是动态创建的,会随着对象属性的更新而变化。隐藏类具有标识一个对象的“形状”的作用,因此是 V8 引擎中优化编译、行内缓存(inline cache)的基础。
下图展示了隐藏类:
notion image
在 V8 中,一个 JavaScript 对象的第一个字段指向它的隐藏类(事实上,对任何在 V8 堆内存中存储并且由垃圾回收器管理的对象来说都是如此)。对属性而言,最重要的信息是第三个bit field结构体,它保存了属性的数量和一个指向描述符数组的指针。这个描述符数组包含了属性的若干信息,例如属性名和值存储的位置。注意这里我们不记录数值属性,因此描述符数组中没有数值属性对应的记录。
对隐藏类的一个基本假设是:拥有相同结构的对象(相同的属性,并且属性顺序相同)共享同一个隐藏类。为了实现这一点,当向对象中添加一个新属性时会指向其他的隐藏类。下面的例子中,我们创建一个空对象,并且添加三个字符串属性:
notion image
每当一个新属性被添加,对象的隐藏类就改变了。而 V8 会创建一颗转移树,将这些隐藏类连接起来。每当添加了一个新属性时,V8 知道应该使用哪一个新隐藏类。通过转移树,可以确保,按照同样的顺序添加了同样的属性的对象会拥有相同的隐藏类。下面的例子展示了这个过程,注意这个过程中如果添加了整数属性,也不会改变隐藏类:
notion image
notion image
本节主要记住:
  • 拥有相同结构的对象(相同的属性、相同的顺序)有相同的隐藏类。
  • 每个新的字符串属性添加到对象时,会改变其隐藏类(在转移树上移动到下一个节点或创建新节点)。
  • 向对象添加整数属性时不会改变隐藏类。

字符串属性的三种类型

在了解了 V8 如何使用隐藏类来确定对象的“形状”后,我们来看这些对象属性是如何存储的。如前所述,有两种基本的属性类型:字符串属性和整数属性。这里只针对字符串属性。
一个类似于{ a: 1, b: 2 }的简单对象在 V8 中可以有多种不同的表示。虽然看上去每个 JavaScript 对象都表现地像一个字典,但是 V8 尽量避免使用字典来实现对象,因为它会阻碍一些特定的优化行为(例如行内缓存)。
对象内属性普通属性:V8 支持所谓的“对象内属性”,它们直接保存在对象自身中。访问它们非常快因为不需要其他查询。对象内属性的数量在对象最初的大小被确定时就决定了。属性数组则添加了一层查询,但属性数组的大小可以自行增长。
notion image
快属性慢属性:下一个重要的区分是快属性和慢属性。通常我们将存储在属性数组中的属性称为”快属性“。快属性可以直接通过属性数组的索引访问。为了根据属性名获取属性存储的实际位置,我们需要通过隐藏类中的描述符数组进行查询。
notion image
但是,如果一个对象中有很多的属性被添加或者删除,为了维护隐藏类和描述符数组,会消耗很多时间和内存。因此,V8 也支持一种“慢属性”。拥有慢属性的对象,会有一个字典用于查询属性。所有的属性元数据都不会被添加到隐藏类中的描述符数组中,而是直接添加到属性字典中。因此,向对象中添加或删除属性不会更新其隐藏类。由于行内缓存对这种属性字典失效,因此访问此类属性会比访问“快属性”要慢。
本节要记住:
  • 有三种不同的字符串属性:对象内属性、快属性和慢属性:1)对象内属性直接保存在对象中,访问它们是最快的;2)快属性保存在属性数组中,所有的元信息都存储在隐藏类中的描述符数组中;3)慢属性存储在一个字典结构中,元信息不再关联到隐藏类了。
  • 慢属性提供了更快的属性删除/添加操作,但是访问速度不如另外两种快。

元素/整数属性

目前我们讨论了字符串属性,但是忽略了数组中常用的整数属性。实际上,处理整数属性并不比处理字符串属性简单。虽然所有的整数属性都会保存在独立的元素数组(或字典)中,但是存在着 20 种不同的元素!
完备元素中空元素:V8 第一个主要区分的是元素存储是连续完整(完备)的,还是中空的。例如[1,,3],它的第二项就是一个空洞。
const o = ['a', 'b', 'c'] console.log(o[1]) // Prints 'b'. delete o[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
notion image
简单来说,就是如果在当前对象上没有找到属性,需要进一步去查找原型链。由于元素的相关信息并不保存在隐藏类中,我们需要一个特殊的值,\_hole,来标记没有的元素。这对数组类型方法的性能至关重要。如果我们知道数组没有空洞,即它是完备的,那么就可以避免代价高昂的原型链查找操作。
快元素字典元素:第二个需要区分的场景是元素是“快元素”还是“字典元素”。通常元素的索引直接对应虚拟机内部保存的数组的索引。但是这种简单数组的存储方式对于稀疏的数组来说是非常浪费内存的,因为只有少量的索引真正保存了值。此时我们需要使用基于字典的存储方式以此节省内存,代价是访问速度会稍微变慢。
const sparseArray = [] sparseArray[9999] = 'foo' // Creates an array with dictionary elements.
这个例子中,如果虚拟机真的创建一个 10k 长度的数组,是极为浪费内存的。V8 真正做的事情是保存了一个 key-value-descriptor 结构。这里的 key 是'9999',value 是'foo',并且使用默认的描述符。由于整数属性无法通过隐藏类存储描述符信息,V8 借助了对象定义时提供描述符的机制:
const array = []; 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'.
本例中我们添加了一个不可编辑的属性。在这样的数组中,数组方法会显著变慢。
本节要记住:
  • 对于元素(整数属性)来说,有“快元素”和“字典元素”的区别,后者主要用于稀疏的数组。
  • 对于快元素,中空的和完备的元素排列会影响性能,因此需要使用\_hole 来区分。
  • 在 V8 内部,会根据元素的类型采取优化,加快数组方法和减少 GC。

总结

理解 JavaScript 对象属性的实现对于理解 V8 的一些优化是很重要的。对于 JavaScript 程序员,这些 V8 内部的细节大多不可见,但是可以解释为什么有些代码模式比其他的更快。向对象中添加属性或者删除属性,会导致 V8 改变其隐藏类,使得 V8 不能生成最优的代码。
防抖和节流手写一个Promise