You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12 KiB

03 | 快属性和慢属性V8是怎样提升对象属性访问速度的

你好,我是李兵。

在前面的课程中我们介绍了JavaScript中的对象是由一组组属性和值的集合从JavaScript语言的角度来看JavaScript对象像一个字典字符串作为键名任意对象可以作为键值可以通过键名读写键值。

然而在V8实现对象存储时并没有完全采用字典的存储方式这主要是出于性能的考量。因为字典是非线性的数据结构查询效率会低于线性的数据结构V8为了提升存储和查找效率采用了一套复杂的存储策略。

今天这节课我们就来分析下V8采用了哪些策略提升了对象属性的访问速度。

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

在开始之前,我们先来了解什么是对象中的常规属性排序属性,你可以先参考下面这样一段代码:

function Foo() {
    this[100] = 'test-100'
    this[1] = 'test-1'
    this["B"] = 'bar-B'
    this[50] = 'test-50'
    this[9] =  'test-9'
    this[8] = 'test-8'
    this[3] = 'test-3'
    this[5] = 'test-5'
    this["A"] = 'bar-A'
    this["C"] = 'bar-C'
}
var bar = new Foo()


for(key in bar){
    console.log(`index:${key}  value:${bar[key]}`)
}

在上面这段代码中我们利用构造函数Foo创建了一个bar对象在构造函数中我们给bar对象设置了很多属性包括了数字属性和字符串属性然后我们枚举出来了bar对象中所有的属性并将其一一打印出来下面就是执行这段代码所打印出来的结果

index:1  value:test-1
index:3  value:test-3
index:5  value:test-5
index:8  value:test-8
index:9  value:test-9
index:50  value:test-50
index:100  value:test-100
index:B  value:bar-B
index:A  value:bar-A
index:C  value:bar-C

观察这段打印出来的数据我们发现打印出来的属性顺序并不是我们设置的顺序我们设置属性的时候是乱序设置的比如开始先设置100然后又设置了1但是输出的内容却非常规律总的来说体现在以下两点

  • 设置的数字属性被最先打印出来了,并且是按照数字大小的顺序打印的;
  • 设置的字符串属性依然是按照之前的设置顺序打印的比如我们是按照B、A、C的顺序设置的打印出来依然是这个顺序。

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

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

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

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

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

快属性和慢属性

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

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

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

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

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

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

实践在Chrome中查看对象布局

现在我们知道了V8是怎么存储对象的了接下来我们来结合Chrome中的内存快照来看看对象在内存中是如何布局的

你可以打开Chrome开发者工具先选择控制台标签然后在控制台中执行以下代码查看内存快照

function Foo(property_num,element_num) {
    //添加可索引属性
    for (let i = 0; i < element_num; i++) {
        this[i] = `element${i}`
    }
    //添加常规属性
    for (let i = 0; i < property_num; i++) {
        let ppt = `property${i}`
        this[ppt] = ppt
    }
}
var bar = new Foo(10,10)

上面我们创建了一个构造函数可以利用该构造函数创建了新的对象我给该构造函数设置了两个参数property_num、element_num分别代表创建常规属性的个数和排序属性的个数我们先将这两种类型的个数都设置为10个然后利用该构造函数创建了一个新的bar对象。

创建了函数对象接下来我们就来看看构造函数和对象在内存中的状态。你可以将Chrome开发者工具切换到Memory标签然后点击左侧的小圆圈就可以捕获当前的内存快照最终截图如下所示

上图就是收集了当前内存快照的界面要想查找我们刚才创建的对象你可以在搜索框里面输入构造函数FooChrome会列出所有经过构造函数Foo创建的对象如下图所示

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

这是因为只创建了10个常规属性所以V8将这些常规属性直接做成了bar对象的对象内属性。

所以这时候的数据内存布局是这样的:

  • 10个常规属性作为对象内属性存放在bar函数内部
  • 10个排序属性存放在elements中。

接下来我们可以将创建的对象属性的个数调整到20个你可以在控制台执行下面这段代码

var bar2 = new Foo(20,10)

然后我们再重新生成内存快照,再来看看生成的图片:

我们可以看到构造函数Foo下面已经有了两个对象了其中一个bar另外一个是bar2我们点开第一个bar2对象内容如下所示

由于创建的常用属性超过了10个所以另外10个常用属性就被保存到properties中了注意因为properties中只有10个属性所以依然是线性的数据结构我们可以看其都是按照创建时的顺序来排列的。

所以这时候属性的内存布局是这样的:

  • 10属性直接存放在bar2的对象内;
  • 10个常规属性以线性数据结构的方式存放在properties属性里面;
  • 10个数字属性存放在elements属性里面。

如果常用属性太多了比如创建了100个那么我们再来看看其内存分布你可以执行下面这段代码

var bar3 = new Foo(100,10)

然后以同样的方式打开bar3查看其内存布局最终如下图所示

结合上图我们可以看到这时候的properties属性里面的数据并不是线性存储的而是以非线性的字典形式存储的所以这时候属性的内存布局是这样的

  • 10属性直接存放在bar3的对象内;
  • 90个常规属性以非线性字典的这种数据结构方式存放在properties属性里面;
  • 10个数字属性存放在elements属性里面。

其他属性

好了现在我们知道V8是怎么存储对象的了不过这里还有几个重要的隐藏属性我还没有介绍下面我们就来简单地看下。你可以先看下图

观察上图除了elements和properties属性V8还为每个对象实现了map属性和__proto__属性。__proto__属性就是原型是用来实现JavaScript继承的我们会在下一节来介绍而map则是隐藏类我们会在《15 | 隐藏类:如何在内存中快速查找对象属性?》这一节中介绍其工作机制。

总结

好了,本节的内容就介绍到这里,下面我来总结下本文的主要内容:

本文我们的主要目标是介绍V8内部是如何存储对象的因为JavaScript中的对象是由一组组属性和值组成的所以最简单的方式是使用一个字典来保存属性和值但是由于字典是非线性结构所以如果使用字典读取效率会大大降低。

为了提升查找效率V8在对象中添加了两个隐藏属性排序属性和常规属性element属性指向了elements对象在elements对象中会按照顺序存放排序属性。properties属性则指向了properties对象在properties对象中会按照创建时的顺序保存常规属性。

通过引入这两个属性加速了V8查找属性的速度为了更加进一步提升查找效率V8还实现了内置内属性的策略当常规属性少于一定数量时V8就会将这些常规属性直接写进对象中这样又节省了一个中间步骤。

但是如果对象中的属性过多时或者存在反复添加或者删除属性的操作那么V8就会将线性的存储模式降级为非线性的字典存储模式这样虽然降低了查找速度但是却提升了修改对象的属性的速度。

思考题

通常我们不建议使用delete来删除属性你能结合文中介绍的快属性和慢属性给出不建议使用delete的原因吗欢迎你在留言区与我分享讨论。

感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。