JS `Hidden Classes` / `Maps` 结构体的内存布局与查找优化

大家好,欢迎来到今天的内存漫游奇妙夜!今晚我们要聊的是JavaScript引擎里那些“隐形富豪”—— Hidden Classes 和 Maps。别被这些名字吓跑,其实它们是JavaScript对象性能优化的秘密武器。准备好了吗?让我们一起扒开它们的底裤,啊不,是底层实现!

开场白:对象的烦恼

想象一下,你是一个JavaScript引擎,每天的任务就是不停地创建对象、修改对象。最开始,你可能很天真,每次都老老实实地把对象的属性名和值都存储在一个简单的哈希表里。但是,随着时间的推移,你会发现这样做效率很低,因为每次访问对象的属性,你都需要进行哈希查找,这太慢了!

举个例子:

let obj = { x: 10, y: 20 };
console.log(obj.x); // 引擎需要查找 'x' 属性
obj.z = 30; // 引擎需要更新哈希表
console.log(obj.z); // 引擎又需要查找 'z' 属性

每次访问属性都要查表,这谁顶得住啊!于是,聪明的引擎开发者们开始思考:有没有什么办法可以优化属性查找的速度呢?

第一幕:Hidden Classes 闪亮登场

Hidden Classes,顾名思义,就是隐藏的类。但它并不是我们JavaScript里用class关键字定义的类,而是一种引擎内部使用的结构,用来描述对象的形状(shape)。

什么是对象的形状?简单来说,就是对象有哪些属性,以及这些属性的类型和顺序。

举个例子:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

let p1 = new Point(1, 2);
let p2 = new Point(3, 4);

p1p2都是Point类型的对象,它们都具有相同的属性xy,并且属性的类型和顺序也相同。因此,它们可以共享同一个Hidden Class。

Hidden Class的内部结构大致如下:

属性 描述
prototype 指向对象的原型
name Hidden Class的名称(通常是构造函数的名称)
layout 一个数组,存储属性的偏移量(offset)
transitions 一个哈希表,存储属性添加或删除时的Hidden Class转换规则

Hidden Class 的工作原理

  1. 初始状态: 当你创建一个新对象时,引擎会为它创建一个初始的Hidden Class,通常是空的。

  2. 属性添加: 当你向对象添加属性时,引擎会检查当前Hidden Class中是否已经存在该属性。如果不存在,引擎会创建一个新的Hidden Class,并将该属性添加到layout数组中,同时在transitions哈希表中记录从旧的Hidden Class到新的Hidden Class的转换规则。

  3. 属性访问: 当你访问对象的属性时,引擎首先找到对象的Hidden Class,然后从Hidden Class的layout数组中获取属性的偏移量。有了偏移量,引擎就可以直接从对象的内存地址中读取属性值,而不需要进行哈希查找。

代码示例:

function Point(x, y) {
  this.x = x; // 第一次添加属性 'x'
  this.y = y; // 第二次添加属性 'y'
}

let p1 = new Point(1, 2);
console.log(p1.x); // 引擎可以直接通过偏移量访问 'x'

在这个例子中,当创建p1对象时,引擎会经历以下步骤:

  1. 创建一个空的Hidden Class (HC0)。
  2. 添加属性x:创建一个新的Hidden Class (HC1),将x的偏移量添加到HC1的layout数组中,并在HC0的transitions中记录x属性的添加规则,指向HC1。
  3. 添加属性y:创建一个新的Hidden Class (HC2),将y的偏移量添加到HC2的layout数组中,并在HC1的transitions中记录y属性的添加规则,指向HC2。

当访问p1.x时,引擎会首先找到p1对象的Hidden Class (HC2),然后通过HC2的layout数组找到x的偏移量,直接读取x的值。

Hidden Class 的好处

  • 提高属性访问速度: 通过偏移量直接访问属性,避免了哈希查找的开销。
  • 减少内存占用: 多个具有相同形状的对象可以共享同一个Hidden Class,减少了内存占用。

第二幕:Maps (又名 Dictionarys) 的反击

虽然Hidden Classes在大多数情况下都能很好地工作,但是当对象的形状发生变化时,比如属性的添加顺序不同,或者属性被删除时,Hidden Classes就会失效。

举个例子:

let obj1 = { x: 10, y: 20 };
let obj2 = { y: 20, x: 10 }; // 属性添加顺序不同

console.log(obj1.x);
console.log(obj2.x);

在这个例子中,obj1obj2虽然具有相同的属性xy,但是由于属性添加的顺序不同,它们会被分配到不同的Hidden Class。这意味着引擎无法对obj2的属性访问进行优化。

为了解决这个问题,一些JavaScript引擎引入了Maps(或者称为Dictionaries)作为Hidden Classes的备选方案。

Maps 的工作原理

Maps本质上就是一个哈希表,它存储了对象的属性名和值之间的映射关系。当引擎无法使用Hidden Classes进行优化时,它会退回到使用Maps来存储对象的属性。

与Hidden Classes相比,Maps的优点是:

  • 灵活性: Maps可以处理任意形状的对象,不受属性添加顺序和属性删除的影响。

Maps的缺点是:

  • 性能: 每次访问属性都需要进行哈希查找,性能比Hidden Classes差。
  • 内存占用: 每个对象都需要维护自己的Maps,增加了内存占用。

Hidden Classes 和 Maps 的配合

通常情况下,JavaScript引擎会尝试使用Hidden Classes来优化对象的属性访问。只有当对象的形状过于复杂,或者Hidden Classes失效时,引擎才会退回到使用Maps。

可以这样理解:Hidden Classes是“快车道”,Maps是“普通车道”。引擎会尽量让对象走快车道,只有在快车道拥堵或者无法通行时,才会走普通车道。

第三幕:优化技巧与注意事项

了解了Hidden Classes和Maps的工作原理,我们就可以有针对性地进行代码优化,提高JavaScript程序的性能。

  1. 保持对象形状一致: 尽量让具有相同类型的对象具有相同的属性和属性添加顺序。这样可以使这些对象共享同一个Hidden Class,提高属性访问速度。

    // 优化前
    function createPoint(x, y, z) {
      let obj = {};
      obj.x = x;
      obj.y = y;
      if (z !== undefined) {
        obj.z = z;
      }
      return obj;
    }
    
    let p1 = createPoint(1, 2);
    let p2 = createPoint(3, 4, 5); // p1 和 p2 的形状不同
    
    // 优化后
    function createPoint(x, y, z) {
      let obj = { x: x, y: y, z: z === undefined ? null : z }; // 确保每个对象都有 x, y, z 属性
      return obj;
    }
    
    let p1 = createPoint(1, 2);
    let p2 = createPoint(3, 4, 5); // p1 和 p2 的形状相同
  2. 避免属性删除: 删除对象的属性会导致Hidden Class失效,引擎可能会退回到使用Maps。如果必须删除属性,可以考虑将其设置为nullundefined

    // 优化前
    let obj = { x: 10, y: 20, z: 30 };
    delete obj.z; // 删除属性 'z'
    
    // 优化后
    let obj = { x: 10, y: 20, z: 30 };
    obj.z = null; // 将属性 'z' 设置为 null
  3. 避免动态添加属性: 尽量在对象创建时就定义好所有的属性,避免在运行时动态添加属性。

    // 优化前
    let obj = { x: 10, y: 20 };
    obj.z = 30; // 动态添加属性 'z'
    
    // 优化后
    let obj = { x: 10, y: 20, z: undefined }; // 在对象创建时就定义好属性 'z'
    obj.z = 30;
  4. 使用构造函数: 使用构造函数创建对象可以使引擎更容易推断对象的形状,从而更好地利用Hidden Classes进行优化。

    // 优化前
    let obj1 = { x: 10, y: 20 };
    let obj2 = { x: 30, y: 40 };
    
    // 优化后
    function Point(x, y) {
      this.x = x;
      this.y = y;
    }
    
    let p1 = new Point(10, 20);
    let p2 = new Point(30, 40);
  5. 小心使用 Object.freezeObject.seal 虽然这两个方法可以防止对象被修改,但过度使用也会影响性能。引擎可能会对冻结的对象进行特殊的处理,这可能会导致Hidden Classes的优化失效。

  6. 了解不同引擎的实现细节: 不同的JavaScript引擎(例如V8、SpiderMonkey、JavaScriptCore)在Hidden Classes和Maps的实现上可能有所不同。了解引擎的实现细节可以帮助你更好地进行代码优化。可以通过查阅引擎的官方文档或者阅读引擎的源代码来了解更多信息。

案例分析:性能测试

为了验证上述优化技巧的有效性,我们可以进行一些简单的性能测试。

// 测试代码
function testHiddenClasses(count) {
  let points = [];
  for (let i = 0; i < count; i++) {
    points.push({ x: i, y: i * 2 }); // 优化:所有对象形状相同
    // points.push({x: i});  // 未优化:对象形状不同
  }

  let sum = 0;
  for (let i = 0; i < count; i++) {
    sum += points[i].x + points[i].y;
  }
  return sum;
}

console.time("Hidden Classes Test");
testHiddenClasses(1000000);
console.timeEnd("Hidden Classes Test");

通过修改testHiddenClasses函数中的对象创建方式,我们可以比较优化前后代码的性能差异。在Chrome浏览器中运行这段代码,你会发现优化后的代码执行速度明显更快。

总结:Hidden Classes 和 Maps 的哲学

Hidden Classes 和 Maps 是 JavaScript 引擎为了提高对象属性访问速度而引入的两种技术。Hidden Classes 通过利用对象的形状信息进行优化,可以显著提高属性访问速度。Maps 则作为 Hidden Classes 的备选方案,可以处理任意形状的对象。

在编写 JavaScript 代码时,我们应该尽量保持对象形状一致,避免属性删除和动态添加属性,以便引擎更好地利用 Hidden Classes 进行优化。同时,我们也应该了解不同引擎的实现细节,以便更好地进行代码优化。

最后,记住一点:性能优化是一项需要不断学习和实践的技能。只有深入理解 JavaScript 引擎的底层实现,才能编写出高效、优雅的 JavaScript 代码。

希望今天的内存漫游奇妙夜能让你对 Hidden Classes 和 Maps 有更深入的了解。祝大家编码愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注