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标签,然后点击左侧的小圆圈就可以捕获当前的内存快照,最终截图如下所示:

上图就是收集了当前内存快照的界面,要想查找我们刚才创建的对象,你可以在搜索框里面输入构造函数Foo,Chrome会列出所有经过构造函数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的原因吗?欢迎你在留言区与我分享讨论。

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

精选留言

  • neohope

    2020-07-21 16:19:45

    1、chrome显示
    不要关心一级目录上是否存在某个element或property,为了调试方便,chrome应该是无论如何存储,都会输出来。
    直接去看elements和properties内存储的内容,更准确一些。

    2、截图里property10怎么有两个:
    这个问题,建议最好改一下演示代码,将Key和Value区分开,现在两个一样,容易引起一些误解。

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

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

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

    6、hash表如何存储property顺序?
    再用一个链表记录插入属性就好了,类似于Java中的 LinkedHashMap ,就可以解决问题
  • 伏枫

    2020-03-23 07:37:16

    https://www.cnblogs.com/chargeworld/p/12236848.html
    找到了一篇博客,应该能帮助一些同学解惑
    作者回复

    很赞

    2020-07-03 21:36:18

  • cc

    2020-03-22 18:14:34

    有个疑问,properties在元素较少的时候使用链表存储的吗?在元素较多的时候换成查找树?
    properties存的属性key是字符串,应该不可能是数组存。要不就是链表,要不就是hash表。如果是hash表,那就没有必要切换成查找树,性能改变微乎其微,最多也就是把hash表里由于冲突导致的过长链表换成查找树。

    对文章里所说的非线性结构和线性结构感到很困惑,比如链表和数组的查找性能就有很大区别,但又都是线性结构。所以为啥不直接说具体是数组还是链表?
    字典的实现可以是哈希表或者查找树,哈希表是线性结构,查找树是非线性结构。

    这节看下来这真是一头雾水。


  • 潇潇雨歇

    2020-03-22 18:07:08

    使用delete删除属性:
    如果删除属性在线性结构中,删除后需要移动元素,开销较大,而且可能需要将慢属性重排到快属性。
    如果删除属性在properties对象中,查找开销较大。
  • try-catch

    2020-03-24 10:24:05

    执行完例子后有些疑惑,找到了v8引擎原博客 https://v8.dev/blog/fast-properties 中找到了答案:
    "The number of in-object properties is predetermined by the initial size of the object"
    in-object properties size 取决于初始化对象的大小。
    作者回复

    赞,v8.dev里面的文章都不错

    2020-03-25 20:29:23

  • Silence

    2020-03-21 18:20:01

    老师,我的 Chrome 版本是 80 的,看 memory 面板好像和你讲的不太一样。
    当有 20 个常规属性时,properties 中有 10 个,但是20 个都在 bar 对象内。
    当有 100 个常规属性时,properties 就更诡异了,每个都有 2 个,共 200 个,bar 对象上有 100 个。
    而且每次都是刷新浏览器后试的,这是什么情况?
    评论区没办法截图。
  • 王楚然

    2020-03-23 11:34:49

    有几个问题没有弄懂:
    1. element(排序属性)是否也有内置,快属性,慢属性三种?不会是一直线性存储吧?
    2. 在properties(字符串属性)很多的时候,会大部分存储成字典结构,具体是什么样的字典结构呢?如何按照ECMA标准保证属性依据创建顺序排序呢?
    3. 还有针对原文“线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。”这句话,线性存储模式是链表吗?字典存储是什么呢?修改的流程,应该也是先查找后修改吧?为什么后者会降低查找速度却能提高修改速度呢?
  • 青史成灰

    2020-03-24 09:36:47

    老师,这里的线形、非线形数据结构,能否说的具体点,是数组,链表,红黑树还是啥的
  • Hello,Tomrrow

    2022-03-07 20:37:21

    在最新版的chrome版本中(V98),对象内属性的数量没有限制了
  • Geek_6zjb9f

    2020-03-21 16:42:18

    这个快属性的数量和平台相关么? mac 平台 chrome 尝试这个代码,会把所有 properties elements 添加为快属性。

    不建议 delete 可能会影响性能的地方:
    1.如果删除排序属性,线性存储结构会有个O(n)复杂度的移动。
    2.如果删除常规属性,可能会重新计算并添加快属性。
  • 马成

    2020-03-21 00:20:57

    老师,字典结构为什么读取效率比线性结构低。如果都是数字索引的话,线性结构很快,但是字符串属性只能遍历呀,怎么会比字典快呢?
  • wilson

    2023-03-01 22:46:08

    这个课程是太老的缘故吗?3年前的课程了,今天来看,Chrome打印出来的内存结果和课程例子不一样。
  • Hyhang

    2022-01-20 10:58:04

    inobject properties 的数量是有上限的,其计算过程大致是:

    // 为了方便计算,这里把涉及到的常量定义从源码各个文件中摘出后放到了一起
    #if V8_HOST_ARCH_64_BIT
    constexpr int kSystemPointerSizeLog2 = 3;
    #endif
    constexpr int kTaggedSizeLog2 = kSystemPointerSizeLog2;
    constexpr int kSystemPointerSize = sizeof(void*);

    static const int kJSObjectHeaderSize = 3 * kApiTaggedSize;
    STATIC_ASSERT(kHeaderSize == Internals::kJSObjectHeaderSize);

    constexpr int kTaggedSize = kSystemPointerSize;
    static const int kMaxInstanceSize = 255 * kTaggedSize;
    static const int kMaxInObjectProperties = (kMaxInstanceSize - kHeaderSize) >> kTaggedSizeLog2;
    根据上面的定义,在 64bit 系统上、未开启指针压缩的情况下,最大数量是 252 = (255 * 8 - 3 * 8) / 8
  • 2020-03-21 17:04:23

    关于常规属性过多时候的表现我这里有2个问题想请教一下:
    1、我这里和老师实验结果不一样:我这里利用 Chrome 创建了 30个常规属性,我看了一下是没有使用对象内属性的,30 个属性以字典的形式保存的 properties 属性对象中
    2、当转化为字典后,properties 对象是怎么生成的,每个属性的值为什么会出现2次,那个属性的值的 key 是怎么生成的
    (判断属性是否过多是以 25为界限的)
  • Geek_7bd92b

    2021-12-13 14:51:06

    读着像天书,新手误入
  • 焦焦

    2021-11-19 16:35:14

    不建议使用delete来删除属性的话,推荐使用什么来完成这个操作呢
  • CondorHero

    2021-09-06 09:58:40

    看完快属性慢属性、隐藏类、内联缓存,对对象是非常的了解了,但是对 ES6 的 Map 和 Set 疑问就来了,它们很快,但是为什么快呢。

    希望老师加餐🌝
  • dellyoung

    2020-04-04 16:39:32

    排查内存泄露也需要用到,Memory,李大大能后边补充一节如何排查内存泄露吗,感觉挺常用,面试中也经常被问到,感谢!!!
  • Link

    2020-03-21 09:26:02

    在 Chrome 开发者工具中实践时,发现了一个问题:在当前这个页面,打开开发者工具,在 Console 中执行代码后,在 Memory 中生成快照,但是在快照中未找到 Foo,并且快照只有 761KB。此时,在保持开发者工具打开的状态下刷新页面,在 Memeory 中再次生成快照,这时在快照中找到了 Foo,并且快照有 77.1MB。老师能否解释下这个现象😄
  • Geek_7bd92b

    2021-12-13 14:48:27

    讲的一般般