热搜:前端 nest neovim nvim

浏览器原理系列-V8引擎对象存储的优化

lxf2023-05-15 01:24:29

转载请保留这部分内容,注明出处。
另外,头条号前端团队非常 期待你的加入

在开始本文前,我们来思考一个问题,在JS语言中,访问一个对象的属性复杂度是多少?是O(1)吗?如果是O(1),为什么能做到O(1)的复杂度?让我们带着这些问题继续往下看。

V8如何存储JS对象

我们知道JS一门动态语言,这意味着在代码执行过程中,变量的类型是不定的,可以被改变,非常灵活,这也是JS语言的特性之一。但这也带来了一个问题,我们访问一个JS对象的某个属性时不能直接根据偏移量计算出需要访问的存储位置,这在静态语言里是很容易做到的。那JS是怎么存储对象呢?

JS对象存储在堆中,它更像一个字典,字符串作为键名,任意对象都可以作为键值,通过键名读写键值。然而在 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]}`)
}

我们来观察这段代码的执行结果:

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

浏览器原理系列-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),即快属性。

浏览器原理系列-V8引擎对象存储的优化

不过对象内属性的数量是固定的,默认是 10 个,虽然常规属性存储多了一层间接层,但可以自由地扩容。 通常,我们将 保存在线性数据结构中的属性称之为“快属性” ,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果 从线性结构中添加或者删除大量的属性时,则执行效率会非常低 ,这主要因为会产生大量时间和内存开销。 因此, 如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

浏览器原理系列-V8引擎对象存储的优化

我们来实践一下,看看下面的代码:

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

此时内存中的结构如下图:

浏览器原理系列-V8引擎对象存储的优化

此时bar对象有10个常规属性,全都升级为快属性,没有 properties 属性。

我们修改调用 Foo 函数的参数为:

var bar = new Foo(100,10)

此时 bar 属性的常规属性超过10个,由于快属性有10个数量的限制,可以看到 properties 属性。

浏览器原理系列-V8引擎对象存储的优化

浏览器原理系列-V8引擎对象存储的优化

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

  • 10 属性直接存放在 bar 的对象内 ;

  • 90 个常规属性以非线性字典的这种数据结构方式存放在 properties 属性里面 ;

  • 10 个数字属性存放在 elements 属性里面。

隐藏类

前文我们提到,由于JS语言是一门动态语言,不像静态语言可以通过偏移量来查询对象的属性值,那V8是否可以把这种高查询效率的方法引入呢?

答案是V8的确这样做了。一个思路是将JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设:

  • 对象创建好了之后就不会添加新的属性;

  • 对象创建好了之后也不会删除属性。

符合这两个假设之后,V8 就可以对 JavaScript 中的对象做深度优化了,那么怎么优化呢? 具体地讲, V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:对象中所包含的所有的属性;每个属性相对于对象的偏移量。 回到前文的截图,可以看到每个对象下有一个 map 属性,这个属性就是隐藏类。

有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。

我们可以结合一段代码来分析下隐藏类是怎么工作的:

let point = {x:100,y:200}

当 V8 执行到这段代码时,会先为 point 对象创建一个隐藏类,在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如 point 对象的隐藏类就包括了 x 和 y 属性,x 的偏移量是 4,y 的偏移量是 8。

浏览器原理系列-V8引擎对象存储的优化

在这张图中,左边的是 point 对象在内存中的布局,右边是 point 对象的 map。有了 map 之后,当你再次使用 point.x 访问 x 属性时,V8 会查询 point 的 map 中 x 属性相对 point 对象的偏移量,然后将 point 对象的起始位置加上偏移量,就得到了 x 属性的值在内存中的位置,有了这个位置也就拿到了 x 的值,这样我们就省去了一个比较复杂的查找过程。

这就是将动态语言静态化的一个操作,V8 通过引入隐藏类,模拟 C++ 这种静态语言的机制,从而达到静态语言的执行效率。在 V8 中,每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类。这样有两个好处:

  • 减少隐藏类的创建次数,也间接加速了代码的执行速度;

  • 减少了隐藏类的存储空间。

那么,什么情况下两个对象的形状是相同的,要满足以下两点: 1、相同的属性名称;2、相等的属性个数。

重新构建隐藏类

关于隐藏类我们之前有两个假设,对象创建之后不会添加或删除属性,但是JS是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类,这对于 V8 的执行效率来说,是一笔大的开销。通俗地理解, 给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类。

这解释了为什么不推荐使用 delete 关键字删除对象的属性。解答我多年的一个疑惑呀~

对象操作的最佳实践

前文我们知道频繁改变对象的属性或属性值的数据类型会导致频道重新构建隐藏类的性能问题,基于此我们可以推测出操作对象的最佳实践。

使用字面量初始化对象时,尽量保证属性的顺序一致。

我们看一个