【摘要】本文介绍 V8 引擎如何存储访问对象属性,以及对函数内对象访问的优化。
对象属性访问
element 和 property
JavaScript 对象是一系列键值对的集合,有 3 种类型可以作为对象的 key,它们分别是数字、字符串、Symbol。
V8 把对象的数字属性称为排序属性 ( element ),字符串属性称为常规属性 ( property )。
ECMAScript 规范中定义了它们的枚举顺序:
- element 按值大小升序枚举
- property 按创建顺序枚举
- symbol 属性按创建顺序枚举
为提升存储和访问性能,V8 使用了两个线性数据结构保存 elements 和 properties。 访问对象属性时,V8 先在 elements 中顺序查找,然后在 properties 中查找。
in-object property
将字符串属性保存到 properties 中,使得查找元素时需要先找到 property。虽然只是一步,但是对象属性访问是非常频繁的。
对此,V8 将部分 property 直接存储到对象本身,称为 in-object property。
为提高属性查找速度,in-object property 限制最多有 10 个,property 在少于 25 个时使用线性结构存储,称为快属性;超出时使用非线性结构存储,称为慢属性。它们最终形成一套阶梯存储结构。
对于字符串属性来说,按访问速度排序如下:
- in-object property
- 快属性:线性结构存储
- 慢属性:非线性结构存储(属性较多时,快属性降级为慢属性)
map
V8 为了提升对象的属性访问速度,引入了隐藏类 ( hidden class ) 机制。
静态类型语言是根据属性的偏移量查找对象属性的。由于 JavaScript 对象可以在运行时改变,因而无法确定偏移量,所以只能在 element、property 中查找。
所谓隐藏类就是将“用偏移量访问属性”运用到 JavaScript 对象中。
具体怎么做呢?
每个对象都有一个 map 属性指向它的隐藏类,它记录了属性相对于对象地址的偏移量。
举个例子,ponit 对象 { x: 1, y: 1 }的结构如下图。
访问对象属性时,先在隐藏类中找到偏移量,根据偏移量计算出属性的地址。与在 properties 中查找相比,大大提升了查找效率。
形状相同的对象可以共用隐藏类。
如果两个对象的形状 ( shape ) 相同 ( 属性名称、个数、顺序相同 ),它们可以共用同一个隐藏类。
举个例子,以下两个对象的 map 指向同一个地址。
const point1 = { x: 1, y: 1 }
const point2 = { x: 2, y: 2 }
关于复用隐藏类,有以下几点需要注意:
- 如果 shape 改变,需要重新创建隐藏类。
- 属性值的类型改变不会创建新的隐藏类。
因此,为了尽量利用隐藏类,需要做到以下几点:
- 保持对象 shape 不变
- 一次性初始化全部对象属性
- 避免使用 delete 方法
函数内对象访问优化:内联缓存
内联缓存 ( inline cache ) 是针对函数内对象访问的优化。
来看一段代码:
const load = obj => obj.x
let obj = { x: 1 }
for(let i = 0; i < 100000; i++) {
load(obj)
}
通过前面的内容我们知道,V8 会不断地找到 obj 的 map 地址,然后根据偏移量查找属性值。 以上过程不断重复,内联缓存可以优化这个过程。
内联缓存为每个函数维护一个反馈向量 ( FeedBack Vector ),记录函数执行过程中的对象访问。
反馈向量是一个表结构,表的每一行称为一个 slot,数据存储在 slot 中。
举个例子:
function load(o) {
o.y = 4 // 访问对象
return o.x // 访问对象
}
load 函数中有两个对象访问点 ( callSite ),分别对应一个 slot,load 函数的 FeedBack Vector 如下图。
| slot | type | state | map | offset |
|---|---|---|---|---|
| 0 | LOAD | MONO | [ 地址 ] | 8 |
| 1 | STORE | MONO | [ 地址 ] | 12 |
反馈向量的写入过程
load 函数代码如下。
function load(obj) {
return obj.x
}
将 load 转换为字节码。
StackCheck # 检查栈是否溢出
LdaNamedProperty a0, [0], [0] # 取出参数 a0 的第一个属性值,放入累加器
Return # 返回累加器的值
LdaNamedProperty 有三个参数,它们的含义如下:
- a0:load 函数的第 1 个参数
- [0]:取出对象 a0 的第 1 个属性值
- [0]:将对象访问数据写入第 1 个 slot
slot 的各项含义如下。
- state:状态,MONO 表示单态,后面会介绍
- type:对象操作类型
- map:对象的隐藏类的地址
- offset:属性 x 的偏移量
type 支持以下 3 种值。
- LOAD 访问对象属性值
- STORE 存储对象属性值
- CALL 调用函数
根据反馈向量,执行 load 函数时就可以通过 map 和 offset 提高对象操作效率。
多态与超态
当对象的形状是固定的,反馈向量缓存信息的过程如上所述。 如果形状不固定会怎么样呢?
来看一段代码:
function load(obj) {
return obj.x
}
let o1 = { x: 1 }
let o2 = { x: 1, y: 2 }
for(let i = 0; i < 10000; i++) {
load(o1)
load(o2)
}
可以看到对象 o1 和 o2 是不一样的,这意味着隐藏类是不同的。
第一次执行时 load 时,记录 o1 的 map 和 offset。 再次调用 load 时,取出反馈向量中记录的 map,发现和 o2 不同,然后记录新的 map。
这时,slot 里包含了两个 map 和 offset。
| slot | type | state | map | offset |
|---|---|---|---|---|
| 0 | POLY | MONO | [ 地址 1 ] | 8 |
| [ 地址 2 ] | 8 |
- 一个 slot 中包含 1 个隐藏类,称为单态 ( monomorphic, MONO )
- 一个 slot 中包含 2 ~ 4 个隐藏类,称为多态 ( polymorphic, POLY )
- 一个 slot 中超过 4 个隐藏类,称为超态 ( magamorphic, MAGA )
很明显,单态的性能优于多态和超态,所以我们需要稍微避免多态和超态的情况。
要避免多态和超态,就尽量默认所有的对象属性是不变的。比如传入 load 函数的对象的形状要尽量保持一致。
参考
[1] 【字节前端 ByteFE】Chrome 浏览器运行原理你了解多少?mp.weixin.qq.com/s/wjrcO2Ej7… [2] 【Elab 团队】Google V8引擎浅析-面向对象 juejin.cn/post/705268…