成长手记系列之V8-快属性和慢属性,提升对象属性访问速度

1,952 阅读8分钟

V8是什么?

简单来说V8 是 JavaScript 引擎的一种。我们可以把 JavaScript 引擎理解成是一个翻译程序,将人类能够理解的编程语言 JavaScript,翻译成计算机能够理解的计算机语言。在 V8 出现之前,所有的 JavaScript 引擎所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因。而 V8 率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。V8 出现之后,各大厂商也都在自己的 JavaScript 引擎中引入了 JIT 机制,所以你会看到目前市面上 JavaScript 引擎都有着类似的架构。另外,V8 也是早于其他引擎引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了 JavaScript 代码的编译执行效率。即便 V8 具有诸多优点,但我相信对于大部分小伙伴来说,V8 引擎还只是一个黑盒,我们将一段代码丢给这个黑盒,它便会返回结果,并没有深入了解过它的工作原理。

为什么学习V8

如果只是单纯使用 JavaScript 和调用 Web API,并不了解引擎内部是怎样工作的,在项目中遇到的很多问题很可能找不到解决的途径。比如,有时项目的占用内存过高,或者页面响应速度过慢,又或者使用 Node.js 的时候导致任务被阻塞等问题,都与 V8 的基本运行机制有关。如果你熟悉 V8 的工作机制,就会有系统性的思路来解决这些问题。

JS对象

大家都知道JavaScript中的对象是由一组组键值对组成的,像一个字典,字符串作为键名,任意数据类型作为键值,可以通过键名读取或者修改键值。然而在V8中实现对象存储时,出于对性能的考虑,并没有采用单一的字典存储方式,因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8为了提升存储和查找效率,采用了一套复杂的存储策略。

常规属性(properties)和排序属性(element)

在 V8 中,对象主要由三个指针构成,分别是隐藏类(Hidden Class), 常规属性(Properties)还有 排序属性(Element)。

今天我们先说说常规属性和排序属性,可以看到下面的一段代码:

function Score() {
    this["B"] = 'Str-B'
    this[50] = 'test-50'
    this[100] = 'test-100'
    this[9] = 'test-9'
    this[8] = 'test-8'
    this["A"] = 'Str-A'
    this[1] = 'test-1'
    this[3] = 'test-3'
    this[5] = 'test-5'
    this["C"] = 'Str-C'
}
var obj = new Score()
for (key in obj) {
    console.log(`index:${key}  value:${obj[key]}`)
}

上面这段代码我们用构造函数Score创建了一个obj对象,在构造函数中我们给obj对象设置了很多属性,包括数字属性和字符串属性,然后通过枚举打印obj对象的所有属性我们看到了下图的结果:

常规属性排序属性打印.png

观察这段数据我们可以看到,打印出来的属性顺序并不是我们设置的顺序,我们设置对象的时候是乱序设置的,比如先设置了字符串B,然后设置相对较大的数字50,而数字1是在较后设置的,但是打印的结果确是非常规律的,总的来说体现在以下两点:

  1. 设置的数字属性最先被打印出来, 并且是按照数字的大小排序打印的。
  2. 设置的字符串属性依然是按照设置时的顺序打印,比如我们先设置B-A-C,打印出来的依然是这个顺序。

之所以出现这样的结果,是因为在ECMAScript规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。

在这里我们把对象中的数字属性称为排序属性,在 V8 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties

在V8内部,为了有效的提升存储和访问这两个属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性,具体结构如下图:

对象构造关系图.jpg

通过上图我们可以发现,obj 对象包含了两个隐藏属性:elements 属性和 properties 属性,elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性。

分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。

快属性和慢属性

V8将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 obj.B这个语句来查找 B 的属性值,那么在 V8 会先查找出 properties 属性所指向的对象 properties,然后再在 properties 对象中查找 B 属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。

基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)。对象在内存中的展现形式你可以参看下图:

对象内属性.jpg

采用对象内属性之后,常规属性就被保存到 obj 对象本身了,这样当再次使用obj.B来查找 B 的属性值时,V8 就可以直接从 obj 对象本身去获取该值就可以了,这种方式减少查找属性值的步骤,增加了查找效率。

不过对象内属性的数量是固定的,默认是 10 个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。虽然属性存储多了一层间接层,但可以自由地扩容。

通常,我们将保存在线性数据结构中的属性称之为“快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。

因此,如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

对象内属性.jpg

下面我们实践一下:在Chrome中查看对象布局

现在我们知道了 V8 是怎么存储对象的了,接下来我们来结合 Chrome 中的内存快照,来看看对象在内存中是如何布局的?你可以打开 Chrome 开发者工具,先选择控制台标签,然后在控制台中执行以下代码查看内存快照:

首先我们先看对象中的Elements属性的存放规律:

function Foo() {}
var bar = new Foo()
for (let i = 0; i < 10; i++) {
    bar[i] = 'bar' + i
}
// bar[1111] = 'bar1111'

上面我们创建了一个构造函数,利用该构造函数创建了一个新的 bar 对象,然后我通过枚举给bar对象添加了10个排序属性,下面我们看看他在对象中是怎么存储的。

创建了函数对象,接下来我们就来看看构造函数和对象在内存中的状态。你可以将 Chrome 开发者工具切换到 Memory 标签,然后点击左侧的小圆圈就可以捕获当前的内存快照,要想查找我们刚才创建的对象,你可以在搜索框里面输入构造函数 Foo,Chrome 会列出所有经过构造函数 Foo 创建的对象,最终截图如下所示:

控制台顺序.png

观察下图,我们搜索出来了所有经过构造函数 Foo 创建的对象,点开 Foo 的那个下拉列表,第一个就是刚才创建的 bar 对象,我们可以看到 bar 对象有一个 elements 属性,这里面就包含我们创造的所有的排序属性,这时可以看到它的数据结构是线性数据结构

控制台快照1.png

接下来我们给上述代码加上一行bar[1111] = 'bar1111',后重新生成内存快照:

控制台快照2.png

可以看到这时候的elements属性中的数据存放起来已经没有了顺序,这是因为当我们添加了 bar[1111] 之后,数组会变成稀疏数组。为了节省空间,稀疏数组会转换为哈希存储的方式,而不再是用一个完整的数组描述这块空间的存储。所以,这几个可索引属性也不能再直接通过它的索引值计算得出内存的偏移量,这就是慢属性策略。

接下来我们看一下Properties属性的存放规律:

function Foo2() {}

var a = new Foo2()
var b = new Foo2()
var c = new Foo2()

for (var i = 0; i < 10; i ++) {
  a[new Array(i+2).join('a')] = 'aaa' + i
}

for (var i = 0; i < 12; i ++) {
  b[new Array(i+2).join('b')] = 'bbb' + i
}

for (var i = 0; i < 30; i ++) {
  c[new Array(i+2).join('c')] = 'ccc' + i
}

上面我们创建了一个构造函数,利用该构造函数创建了三个新的对象,然后我通过枚举给对象们分别添加了10个、12个、30个常规属性,下面我们看看他在对象中是怎么存储的。

控制台快照3.png

通过观察上图我们可以看到第一个a对象在常规属性只有10个时,将所有的常规属性都通过内属性的方式直接存储在a对象中。

而在b对象中当常规属性超过10个后,先是将先声明的10个常规属性通过内属性的方式直接存储在b对象中,剩下的则是添加到了properties属性中以线性数据结构方式存储。

而当常规属性超过一定量时(本次实验数量是30),可以看到properties属性中的索引变成了毫无规律的数,意味着这个对象已经变成了哈希存取结构了,这就是慢属性策略。

看到这里你明白了V8是如何提升对象属性访问速度了吗?

我们实践的出的结论正是印证了我们上面所述的:

  1. V8在开始时使用快属性存储策略:排序属性element常规属性properties。通过线性数据结构进行对象属性的存储,并在常规属性properties少于10个时为了提升访问速度将其提升为内属性直接存储在对象本身。
  2. 但当排序属性element的索引为无序或者为稀疏数组时、常规属性properties超出一定数量时,采用了另一种存储策略:慢属性,慢属性内部有独立的非线性数据结构作为属性存储容器。

总结

本次我们学习了 V8 内部是如何存储对象的,因为 JavaScript 中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。为了提升查找效率,V8 在对象中添加了两个隐藏属性,排序属性element常规属性properties,element 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性。properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存常规属性。通过引入这两个属性,加速了 V8 查找属性的速度,为了更加进一步提升查找效率,V8 还实现了内置内属性的策略,当常规属性少于等于10个时,V8 就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的存储模式,因为超过某个数值,顺序查找就不够快了,需要通过hash表结构查找,提升速度,也提升了修改对象的属性的速度。

下面是来至一位大佬的评论:

1、chrome显示

不要关心一级目录上是否显示了element属性或property属性,为了调试方便,chrome应该是不管怎么存储,都会显示出来。 直接去看elements和properties内存储的内容,更准确一些,如果没有properties则说明propertie属性没有超过10个。

2、element

element没有内置。 element默认应该采用连续的存储结构,通过浪费空间换取时间,直接下标访问,提升访问速度。 但当element的序号十分不连续时,会优化成为hash表,因为要浪费的空间太大了,不合算。

3、properties

properties有内置,只有十个,但建议把这十个单独考虑,后面就容易考虑清楚了。 properties默认采用链表结构,当数据量很小时,查找也会很快,但数据量上升到某个数值后,会优化成为hash表。 因为超过某个数值,顺序查找就不够快了,需要通过hash表结构查找,提升速度。

4、hash表不是应该查找一次吗?为何是慢查询

hash表要解决key冲突问题,一般会用list存储多个冲突的key,所以计算hash后,还是要做顺序访问,所以要多次访问。 此外,还涉及到hash扩容的问题,那就更慢了。 所以,整体上来说,hash慢于按地址访问的; 在数据量小的时候,也慢于链表的顺序访问。

5、hash表如何存储property顺序?

再用一个链表记录插入属性就好了,类似于Java中的 LinkedHashMap ,就可以解决问题

此篇文章为学习总结,如有错误欢迎指出,谢谢

参考资料: