V8引擎是如何提升对象属性访问速度的?

V8 是什么?

简单来说就是 JavaScript 的引擎,主要将 JavaScript 转换成计算机能理解的语言。在 V8 出现之前,其他引擎都是通过解释执行的方式,这是导致 JavaScript 执行过慢的一个主要原因。

V8 引擎引入即时编译(JIT)的双轮驱动的设计,混合了编译执行解释执行这两种计算机执行高级语言的方式,极大得提高了 JavaScript 的执行速度。

另外 V8 也引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了 JavaScript 的执行速度。

解释执行

指的是在运行时逐行解析和执行源代码。解释执行的语言会逐行读取源代码,并将其转换为与机器码等价的中间形式,然后再进行相应的执行。每执行一行代码时,都需要进行解析、编译和执行的过程。

这种方式灵活,可以动态地执行代码,但由于每次执行都需要解析和编译,执行效率相对较低。

编译执行

指的是在程序运行之前,将源代码一次性转换为机器码或字节码等等效的形式。编译执行的语言会先进行一个整体的编译过程,将源代码转换为可以直接执行的形式,然后再进行执行。在执行过程中,无需再进行解析和编译,因此执行效率较高。

编译执行的语言通常需要经过预处理、词法分析、语法分析、语义分析、优化和代码生成等多个阶段。

即时编译(JIT)

即时编译(Just-in-Time Compilation,简称 JIT 编译)是一种动态编译技术,用于在程序运行时将源代码或中间代码即时转换为机器码以进行执行。与传统的静态编译不同,即时编译是在程序执行过程中,根据实际的代码路径和执行环境进行编译和优化。

即时编译的基本原理是,在程序运行时,通过监测代码的执行情况,发现热点代码(Hotspot),即经常被执行的代码片段。然后,将这些热点代码进行及时编译,将其转换为机器码,并替换原有的解释执行或预编译的形式。这样,在后续的执行过程中,就可以直接执行已编译的机器码,而无需再进行解析和编译,从而大大提高代码的执行速度和效率。

即时编译通常结合了解释执行编译执行的优点。初始阶段,使用解释执行或者预编译的方式快速启动程序,并在解释执行的过程中收集代码的执行信息。当发现某些代码频繁执行时,即时编译器会将这些代码编译成机器码。通过动态地优化热点代码,及时编译可以根据实际的运行情况对代码进行更好的优化,以达到更高的执行效率。

JS 对象

JavaScript 中的对象是由一组组属性和值的集合,从 JavaScript 语言的角度来看,JavaScript 对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。

然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略。
未命名文件.png
接下来我们就来分析下 V8 采用了哪些策略提升了对象属性的访问速度。

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

function Fn() {
  this[333] = 'henshao-333'
  this[1] = 'henshao-1'
  this["B"] = 'jpd-B'
  this[50] = 'henshao-50'
  this[9] = 'henshao-9'
  this[8] = 'henshao-8'
  this[1.5] = 'jpd-1.5'
  this[3] = 'henshao-3'
  this[5] = 'henshao-5'
  this["A"] = 'jpd-A'
  this["C"] = 'jpd-C'
  this["8.5"] = 'jpd-8.5'
}
var test = new Fn()

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

下面就是执行这段代码所打印出来的结果:

key:1 value:henshao-1
key:3 value:henshao-3
key:5 value:henshao-5
key:8 value:henshao-8
key:9 value:henshao-9
key:50 value:henshao-50
key:333 value:henshao-333
key:B value:jpd-B
key:1.5 value:jpd-1.5
key:A value:jpd-A
key:C value:jpd-C
key:8.5 value:jpd-8.5

对象包含了整数、浮点数、整数字符串、字符串,但是输出结果明显没有按照我们所设置的顺序。

而且通过观察发现,数字属性是被最先打印出来的,并且按照其大小进行输出;字符串或者数字字符串和浮点类型是按照之前设置的顺序进行输出。

所以 V8 引擎应该是对不同类型的对象属性的存储进行了一定的约束。下面开始介绍具体规则。

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

在 V8 的对象中有两种属性,排序属性 (elements)和常规属性 (properties)。

  • 把对象中的数字属性称为排序属性,在 V8 中被称为 elements。数字属性应该按照索引值大小升序排列。
  • 字符串属性就被称为常规属性,在 V8 中被称为 properties,字符串属性根据创建时的顺序升序排列。

两个属性都存在时,排序属性 (elements)先于常规属性(properties)

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性,具体结构如下图所示:
未命名文件 (1).png
通过上图我们可以发现,test 对象包含了两个隐藏属性:elements 属性和 properties 属性。

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

内属性、快属性和慢属性

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

基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)。 对象在内存中的展现形式你可以参看下图:
未命名文件 (2).png
采用对象内属性之后,常规属性就被保存到 test 对象本身了,这样当再次使用test.B来查找 B 的属性值时,V8 就可以直接从 test 对象本身去获取该值就可以了,这种方式减少查找属性值的步骤,增加了查找效率。

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

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

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

相较于对象内属性,在properties中的常规属性需要额外多一次 properties 的寻址时间,之后便是与对象内属性一致的线性查找(properties 的属性是有规律的类似数组、链表存放)

【排序属性】、【对象内属性】、部分【常规属性】都属于线性数据结构,所以都叫【快属性】。
非线性数据结构是【慢属性】

实例:在 Chrome 中分析对象布局

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

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

常规属性小于等于10个

function Fn(element_num, property_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 test = new Fn(10,10)

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

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

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

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

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

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

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

常规属性大于10个小于等于20个

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

var test2 = new Fn(10, 20)

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

我们可以看到,构造函数 Foo 下面已经有了两个对象了,其中一个 test,另外一个是 test2,我们点开第第一个 Fn 对象,内容如下所示:
image.png

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

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

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

常规属性大于20个

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

var test3 = new Fn(10, 100)

然后以同样的方式打开 test3,查看其内存布局,最终如下图所示:
image.png

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

  • 10 个常规属性直接存放在 test3 的对象内 ;
  • 90 个剩余的常规属性以非线性的散列表(字典)(哈希-分离链路)形式存储在 properties 属性里面 ;
  • 10 个数字属性存放在 elements 属性里面。

数字属性的索引是无序或者是稀疏数组

我们可以看到上面用数组正常遍历出来的数字属性是线性结构。
image.png
接下来我们在控制台执行下面这行代码:

test3[333] = ‘element333’

然后以同样的方式打开 test3,查看其内存布局,最终如下图所示:
image.png

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

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

总结

  • 排序顺序数字按大小排序,字符串按先后执行顺序排序
  • 在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按 照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。
  • 对象内属性,将部分常规属性 直接存储到对象本身, 对象内属性的数量是固定的,默认是 10 个(也就是说属性小于等于10个会生成内部属性),如果添加的属性超出了对象分配的空间,它们将被保存在常规属性properties存储中(大于10个在 properties 里线性存储, 数量再大的情况改为散列表存储)。
  • 排序属性 element 的索引为无序或者为稀疏数组时、常规属性 properties 超出一定数量时,采用了另一种存储策略:慢属性,慢属性内部有独立的非线性数据结构作为属性存储容器。
  • 如果对象中的属性过多时(没有确定的数),或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值