v8中的对象结构

124 阅读10分钟

在某次迭代中需要给一个表格加个选择框,原型如下:

image.png

后端给的状态值如下:

1: 已停止
2:运行中
3:正在启动
4:启动失败
5:正在停止
6:已禁用
7:休眠中
8:任务发布中

然后我的代码如下所示:

statusMap: { 
  0: '全部服务状态',
  2: '运行中', 
  3: '正在启动', 
  4: '启动失败', 
  5: '正在停止', 
  1: '已停止', 
  7: '休眠中', 
  6: '已禁用', 
  8: '任务发布中' 
}

<v-select v-model="status"> 
  <v-option 
    v-for="(value, key) in statusMap" 
    :key="'status' + key" 
    :value="key" 
    :label="value">
  </v-option>
</v-select>

结果最终的效果图如下所示:

image.png

似乎下拉框中的选项没有按照我的代码中的顺序渲染,而是按照从小到大的顺序重排了...

什么情况?

这是因为在ECMAScript中规定:如果是数字属性,则按照从小到大的顺序排序,如果是字符串属性,则按照创建的顺序排序。 ECMAScript2015规范

image.png

那么在v8中,js中的对象是怎么存储的呢?

注:下文中用到的%DebugPrint%DebugPrintPtr都是v8-debug中的命令,v8-debug可以通过jsvu安装,jsvu可以通过npm安装

我们直接通过demo来看下

const obj = {}
%DebugPrint(obj)

image.png

除了我们熟悉的prototype之外,可以看到对象上有MapElementProperties等。

Elements

v8中,elements是用来存储对象的数字属性的,称为索引属性indexed properties。 索引属性会按照索引从小到大存储在elements中。测试一下:

const obj = {}
obj[2] = 2
obj[0] = 0
obj[1] = 1
%DebugPrint(obj)

image.png

可以看到elements上的索引属性已经排好序了。

properties

v8中,properties是用来存储对象的字符串属性的,称为命名属性Named properties。命名属性会按照创建时的顺序存储在properties中。

const obj = {}
obj.a = 'a'
obj.b = 'b'
%DebugPrint(obj)

image.png

似乎测试的结果没有符合我们的预期,可以看到图中的properties的长度是0,而且列举出来的命名属性ab所在的位置是in-object

解释这个问题之前,我们先设想一个问题,v8是怎么查找属性值的?比如现在想知道obj.a是多少。因为命名属性保存在properties中,所以v8要先找到properties,然后再在properties中找到a。这样无疑降低了查询效率。

为了提升性能,v8采取了将部分命名属性直接存储在对象上的策略。这种直接存储在对象上的属性就叫in-object properties

通过上面的截图我们可以看到v8预置了4in-object properties的插槽。当命名属性的个数超过4个的时候。剩下的属性会存储到properties上。我们在上面的demo中增加几行代码测试下:

const obj = {}
obj.a = 'a'
obj.b = 'b'
obj.c = 'c'
obj.d = 'd'
obj.e = 'e'
obj.f = 'f'
%DebugPrint(obj)

image.png

所以通过上面的测试,我们可以得出如下结论:

  1. 索引属性和命名属性是分开存储的
  2. 为了提升访问效率,对象本身会预留4in-object位置存放命名属性,剩下的命名属性会存储到properties

js中,我们创建对象一般有两种方式,一种是通过上面例子中用到的字面量的方式创建,还有一种是通过构造函数的方式创建。那么当用构造函数的方式创建对象的时候,上面的结论还存在吗?测试一下:

function Foo() {}
const foo = new Foo()
for (let i = 0; i < 12; i++) {
 foo[i] = i
 foo['p' + i] = 'p' + i
}
%DebugPrint(foo)

image.png

通过上图可以看出,上面的结论还是成立的。不同点就是in-object的个数变成10个了。还有一个小细节就是elements的初始长度和扩容的规则不一样,不过这又是另外一个话题了,略过。

快属性和慢属性

先看两个例子:

function Foo() {}
const foo = new Foo()
for (let i = 0; i < 12; i++) {
  foo['p' + 1] = 'p' + 1
}
%DebugPrint(foo)

image.png

function Foo() {}
const foo = new Foo()
for (let i = 0; i < 26; i++) {
  foo['p' + 1] = 'p' + 1
}
%DebugPrint(foo)

image.png

通过上面两张图可以看到,properties的数据结构不一样。当属性个数是12的时候,properties的数据结构是数组,而当属性个数是26的时候,数据结构变成字典了。这也就是我们常说的快属性和慢属性。

那什么是快慢属性呢?我们将保存在线性结构中的属性称为快属性。因为访问速度快,通过偏移量就可以访问到。但是如果要对线性结构进行增加或者删除操作,那么效率则会低下。所以在属性过多的时候(经测试,大于25个),v8会采用非线性结构来存储属性,保存在非线性结构中的属性就称为慢属性

在日常编码中,我们经常使用的delete操作(非末尾元素)也会导致对象的属性从快属性变成慢属性。

function Foo() {};
const foo = new Foo();
for (let i = 0; i < 12; i++) {
 foo['p' + i] = 'p' + i;
}
delete foo.p1;
foo.p1 = 'p1';
%DebugPrint(foo);

image.png

关于v8中的快属性的更多细节可以阅读Fast properties in V8

既然properties有快属性之分,那么elements呢?那肯定也是有的。在上面所有的例子中elements的数据结构都是FixedArray。也就是线性存储的。那什么情况下elements会变成非线性存储呢?

在回答这个问题之前,我们先看下一个概念HOLEY_ELEMENTS。 还是先来两个demo

const arr1 = ['a', 'b', 'c'];
const arr2 = ['a', 'b', 'c'];
arr2[5] = 'e';
%DebugPrint(arr1);
%DebugPrint(arr2);

image.png

image.png

这样一看是不是比较好理解,如果数组中有元素等于the_hole_value(也就是我们说的empty),那么就是HOLEY_ElEMENTS(稀疏数组),如果数组中没有empty元素,则是PACKED_ELEMENTS(密集数组)。根据元素值的类型还可以细分出其它种类的HOLEY_ELEMENTS,比如PACKED_SMI_ELEMENTS(元素的值都是整数)等,PACKED_ELEMENTS同理。但是和本文主题无关,同样略过,对此感兴趣的可以查看Elements kinds in V8

现在回到上面那个问题,什么时候elements会非线性存储呢?和properties类似,当elements有大量(经测试,大量是指不小于1024hole的时候。elements会变成非线性结构。可以通过下面的demo验证下。

const obj = {};
obj[1023] = 0;
%DebugPrint(obj);

image.png

const obj = {};
obj[1023] = 0;
%DebugPrint(obj);

image.png

// 测试下是不是有hole的元素个数>=1024个从线性变非线性
const obj = {};
obj[1] = 0;
obj[1023] = 0;
%DebugPrint(obj);

image.png

tips: 所谓的快慢属性只是我们的习惯叫法,并没有优劣之分,它们有各自使用的场景,搬运v8某个开发者的一段话:

image.png 更多细节可以点击这里

Map

什么是隐藏类

v8中,每个对象的第一个属性就是自己的隐藏类map。其实在之前的例子中我们已经见过隐藏类了,就是下图红色框框的内容。(注意下蓝色框框的内容instance descriptors,这是隐藏类中一个比较重要的属性)

image.png

为什么需要隐藏类

先来几行代码

const obj = {
  name: 'lily',
  age: 10
}
console.log(obj.name)

当执行obj.name的时候,v8会怎么查找?因为javaScript是动态语言,也就是说对象的属性是可以随意增减的,所以当执行obj.name的时候,v8obj上有没有name都不知道,就不要说obj.name的值了。为了找到obj.name的属性值,v8需要先在in-object properties上查找是否有name,没找到则去properties上找,还没找到则需要顺着原型链去找。可以看到这整个的过程是很耗时的。追求高性能的v8肯定不会用这样的方式查找对象的属性值。所以就借鉴了静态语言的类和结构体的概念引入了隐藏类的策略。下面我们来看下隐藏类是怎么提升查找对象属性值的?

还是上面的obj。我们先用%DebugPrint(obj)看下objmap地址,然后查看下map上的instance descriptors中的详细内容:

image.png

image.png

有了map之后,当执行obj.name的时候,v8会直接在objmapinstance descriptors中找到name属性相对于obj的偏移量,根据obj的内存地址加上name的偏移量0就得到了name属性在内存中的地址了,此时就可以直接取值了,是不是快多了?

可以这么说:静态语言根据类或者结构体生成对象,而v8根据对象生成隐藏类

但是如果每个对象都产生一个隐藏类的话,那么时间和内存开销也是两个大问题,所以就出现复用隐藏类了,那么什么情况下会复用隐藏类呢?要符合三个条件:属性的名称、顺序和个数要完全相同。用下面的demo测试下:

const obj1 = { name: '小红', age: 10 };
const obj2 = { name: '小米', age: 11 };
const obj3 = { name: '小花' };
const obj4 = { age: 10, name: '小草' };
%DebugPrint(obj1);
%DebugPrint(obj2);
%DebugPrint(obj3);
%DebugPrint(obj4);

image.png

image.png

image.png

image.png

从截图可以看到,只有obj1obj2map是同一个。

v8引入隐藏类是基于一个假设:对象创建出来形状就固定了,即属性不会新增或者删除。但事实是js是动态语言,对象的属性可以随意增减,那现在我们来看下对象属性增减的时候隐藏类如何变化?

注意下面这个demo截图中mapback pointer的变化~

// 先初始化一个obj
const obj = {}

image.png

image.png

obj.name = '小红'

image.png

image.png

image.png

obj.age = 10

image.png

image.png

image.png

从上面三张图可以得出的结论就是:对象每添加一个新属性,就会产生一个新的隐藏类,而且新的隐藏类中的 back pointer 总是指向旧的隐藏类。上面例子中三个隐藏类(按照出现的先后顺序分别命名为map0map1map2)的关系如下图所示:

image.png

其实上面的链条就是v8中的转换树transition tree。它的作用是当以相同的顺序添加相同的属性的时候能确保最后得到相同的隐藏类。来测试一下:

新加一行代码obj1 = {},如下图所示:

image.png

要给obj1来个name属性呢?

image.png

但是此时给obj1来个不一样的属性呢?比如phone

image.png

此时可以隐藏类之间的转换树如下:

image.png

现在来看看删除属性的情况,为了方便观察删除属性时map如何变化,我们先给obj增加一个height属性。

obj.height = 150

运行到上面这行代码的时候,此时内存中的各对象之间的关系如下所示:

image.png

好了,开始删除了,我们先删除最后一个属性height

image.png

发现了什么?objmap退回到了0x02410015ad75上了

image.png

再删一个看看,这次我们删除非末尾元素name

image.png

是不是发生了点不一样的东西?是的,如果删除的属性不是最后一个属性的话,那么v8会不再维护map之间的转化关系,而是转成非线性结构存储了。

日常编码建议

  • 初始化属性相同的对象时,保证属性的顺序保持一致
  • 尽量一次性初始化完属性对象
  • 避免使用delete

说完了v8中的对象表达,现在回到最开始的那个问题,那怎么还原原型上的顺序:

<v-select v-model="status"> 
  <v-option label="全部服务状态" :value="0"></v-option>
  <v-option label="运行中" :value="2"></v-option>
  <v-option label="正在启动" :value="3"></v-option> 
  <v-option label="启动失败" :value="4"></v-option> 
  <v-option label="正在停止" :value="5"></v-option> 
  <v-option label="已停止" :value="1"></v-option> 
  <v-option label="休眠中" :value="7"></v-option> 
  <v-option label="已禁用" :value="6"></v-option> 
  <v-option label="任务发布中" :value="8"></v-option> 
</v-select>
statusMap: new Map([ 
  [0, '全部服务状态'], 
  [2, '运行中'], 
  [3, '正在启动'], 
  [4, '启动失败'], 
  [5, '正在停止'], 
  [1, '已停止'], 
  [7, '休眠中'], 
  [6, '已禁用'], 
  [8, '任务发布中'] 
]),

<v-select v-model="status"> 
  <v-option 
    v-for="[key, value] in statusMap" 
    :key="'status' + key" 
    :value="key" 
    :label="value">
  </v-option>
</v-select>

参考资料