浅谈HiddenClass 隐藏类

531 阅读6分钟

初识隐藏类

我们先来看一段代码

const arr = [
    { a: 1, b: 1, c: 1 },
    { a: 2, b: 1, c: 1 },
    { a: 3, b: 1, c: 1 },
    { a: 4, b: 1, c: 1 },
];

const arr1 = [
    { a: 1, b: 1, c: 1 },
    { c: 1, a: 2, b: 1 },
    { b: 1, c: 1, a: 3 },
    { b: 1, a: 4, c: 1 },
];


let iterations = 1e7;
let time = performance.now();
let sum = 0;
while(iterations--){
    const p = arr[iterations&3].a;
    sum = sum + p;
}
console.log("--->1",performance.now() - time);

iterations = 1e7;

time = performance.now();

while(iterations--){
    const p = arr1[iterations&3].a;
    sum = sum + p;
}
console.log("--->2",performance.now() - time);

arr 和 arr1 两个数组中的对象的属性是一样的,只是属性的顺序不同,而arr的对象格式一致,但是相同的操作,运行时间却有一部分差距,格式规整的arr进行相关操作的时候运行速度要快一些。

对象的属性顺序为什么会对运行时长有影响呢?我们引出一个js对象的重要概念,叫做隐藏类。

隐藏类是js引擎中用来实现js对象可以动态设置属性的方式,我们暂时理解隐藏类为一个对象的“形状”,形状包括包含的属性,以及属性的顺序(这些属性不包括“非负整数”属性)。

image.png

如上图所示,一个js对象的属性值和属性是分开存储的,属性存在隐藏类里,每一个属性对应有一些信息,其中包括offset值,这个值代表对应属性值在对象属性值中的偏移量。如图中obj这个对象的x属性对应的offset是0,那么x属性值就是offset为0位置的值,所以x属性的值是5。

隐藏类不是一个对象所独有的,它像对象的prototype一样,是共享的,多个对象可以拥有相同的隐藏类,如下图:

image-1.png

隐藏类的复用

从js 对象中取对应属性的值是常见和频繁的操作, 如果多个对象的隐藏类相同,那么寻找相同的 属性对应的值,就能复用上次的信息。

有个专业的术语,内嵌缓存(Inline Cache, 后面简称IC), IC是保证 JavaScript 快速运行的关键因素!JavaScript 引擎使用 IC 来记忆对象属性的查找信息,以减少昂贵的查找次数。

上面那段代码中 arr 中的每个对象格式一样,它们具有相同的隐藏类,实现了复用,所以会运行快一些。

隐藏类的更新

动态增加属性 和 删除属性操作 都会更新对象的隐藏类,删除属性会降低属性的查找性能。

image-2.png

对obj动态增加z属性,它的隐藏类会更新为一个新的隐藏类,而不是在原来的基础上进行修改。

对以下几种操作分别执行 1e7次,测量运行时间,比较哪个最慢:

let iterations = 1e7;
while(iterations--){
const p = { x: 1, y: 2, z: 3};
  p.x = undefined; 	// 操作一
  p.y = undefined; 	// 操作二
  p.z = undefined; 	// 操作三
  delete p.x; 		// 操作四
  delete p.y; 	 	// 操作五
  delete p.z; 		// 操作六
  JSON.stringify(p)
}

经过尝试过后,发现设置undefined的操作时间基本都在1000ms左右,执行delete操作的运行时间都比较长。设置undefined不会改变一个对象的隐藏类,而执行delete会。

看起来一个对象的隐藏类会和属性的顺序有关, 包括初始化的属性顺序和动态增加的属性顺序,以及delete操作,那么还有什么会影响一个对象的隐藏类呢?不同的构造函数也会影响对象的隐藏类,看如下代码

let iterations = 1e7;
while(iterations--){
  class Point {
    constructor(x, y, z){
      this.x = x; this.y = y; this.z = z;
    }
  }
  const p = new Point(1, 2, 3); 
  JSON.stringify(p)
}

上面的代码运行时长接近13秒,你也许认为动态创建类会消耗很大性能,我们在while循环外增加相同的类,同时循环里使用外部的类,内部类声明保留,代码如下

class Point {
  constructor(x, y, z){
    this.x = x; this.y = y; this.z = z;
  }
}
let iterations = 1e7;
while(iterations--){
  class PointInner {
    constructor(x, y, z){
      this.x = x; this.y = y; this.z = z;
    }
  }
  const p = new Point(1, 2, 3); 
  JSON.stringify(p)
}

发现运行时长降到了4秒多,说明即使有动态创建类,也不是时长变长的主要原因

隐藏类的细节

接下来我们从js引擎内部的一些命令看看隐藏类的细节,首先你需要下一个 v8-debug 的命令工具,可以在电脑上全局安装 jsvu,参考 https://github.com/GoogleChromeLabs/jsvu。 装好d8后就可以使用 d8 --allow-natives-syntax启动

首先我们输入 const a = { x: 1, y: 1 }

然后我们通过一个内部命令看js对象在v8内部的构成,输入 %DebugPrint(a)

image-3.png

其中 - map: 0x34bc0029988d <Map20> [FastProperties], 这个就是对象a的隐藏类,它的后面有一个[FastProperties]的标记,代表是快属性访问模式

下面 - instance descriptors (own) #2: 0x34bc000484d5 <DescriptorArray[2]> 这个就是对象a的所有属性的集合

我们通过一个命令查看一下这个集合的具体内容,输入 %DebugPrintPtr(0x34bc000484d5)

image-4.png

我们看到第八行开始就是对象a的隐藏类所包含的属性,当我们动态给对象a增加属性,重复上面的步骤回看到对应的DescriptorArray会更新

现在我们对a对象执行 delete 操作,delete a.x,同时再次 %DebugPrint(a)

image-5.png

我们看到a的隐藏类变成了 - map: 0x34bc00294f0d <Map12> [DictionaryProperties],隐藏类后面的标记变成了 DictionaryProperties,只有delete操作会使对象的隐藏类变成 DictionaryProperties,属性查找不再是线性查找,而是字典查找,会使查找速度变慢,所以我们不推荐使用 delete 操作符去删除对象的属性

隐藏类的比较

两个对象的隐藏类是否相同,从直观上来看就是两个对象都有相同顺序的相同属性操作,这些操作都最终决定了隐藏类是否相同,例如 const a = { x: 1, y: 1 }const b = {}; b.x = 1; b.y = 1;虽然最终 a和b的属性相同,顺序也相同,但是他俩的对象来源的方式是不同的,最终也会导致隐藏类不同

我们使用 v8内部的命令去查看隐藏类是否相同,%HaveSameMap(a, b)

我们使用这个命令再看一下这个对比,%HaveSameMap({ x: 1 }, { x: 1, 2: 1 }),发现是 true,说明非负整数的属性确实不影响一个对象的隐藏类

image-7.png

小结

  • 一个js对象在js引擎内部会有一个隐藏类,这个隐藏类和这个对象的属性的顺序,增加的方式有关,这些属性不包括非负整数属性。
  • 使用delete操作符会让一个对象的属性查找变慢,不同的构造函数即使产出的对象的属性及属性的顺序都一样,隐藏类也不一样。
  • 多个对象可能有相同的隐藏类,如果隐藏类相同,js引擎会尝试复用隐藏类,复用成功会提高属性的查找速度。

本文参考了以下文章和链接