V8 引擎如何存储和访问对象属性?

255 阅读5分钟

【摘要】本文介绍 V8 引擎如何存储访问对象属性,以及对函数内对象访问的优化。

对象属性访问

element 和 property

JavaScript 对象是一系列键值对的集合,有 3 种类型可以作为对象的 key,它们分别是数字、字符串、Symbol。

V8 把对象的数字属性称为排序属性 ( element ),字符串属性称为常规属性 ( property )。

ECMAScript 规范中定义了它们的枚举顺序:

  1. element 按值大小升序枚举
  2. property 按创建顺序枚举
  3. symbol 属性按创建顺序枚举

为提升存储和访问性能,V8 使用了两个线性数据结构保存 elements 和 properties。 访问对象属性时,V8 先在 elements 中顺序查找,然后在 properties 中查找。

in-object property

将字符串属性保存到 properties 中,使得查找元素时需要先找到 property。虽然只是一步,但是对象属性访问是非常频繁的。

对此,V8 将部分 property 直接存储到对象本身,称为 in-object property。

为提高属性查找速度,in-object property 限制最多有 10 个,property 在少于 25 个时使用线性结构存储,称为快属性;超出时使用非线性结构存储,称为慢属性。它们最终形成一套阶梯存储结构。

对于字符串属性来说,按访问速度排序如下:

  1. in-object property
  2. 快属性:线性结构存储
  3. 慢属性:非线性结构存储(属性较多时,快属性降级为慢属性)

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 如下图。

slottypestatemapoffset
0LOADMONO[ 地址 ]8
1STOREMONO[ 地址 ]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。

slottypestatemapoffset
0POLYMONO[ 地址 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…