深入 V8 引擎:揭秘 JavaScript 对象背后的‘隐藏类’

48 阅读7分钟

一、从一个老生常谈的问题说起

咱们写 JavaScript 的,都知道它是一门动态语言。最直观的体现就是,对象(Object)特别灵活,你可以随时给它加个属性,或者删掉一个属性,玩得不亦乐乎。

let myObject = {};
myObject.name = '卡尔'; // 加个 name
myObject.age = 30;    // 加个 age
delete myObject.age;  // 哎,我又不要 age 了,你来打我呀

但你有没有想过一个问题:这么灵活的背后,代价是什么?为什么很多性能优化的文章都告诉我们,尽量不要动态修改对象的结构?

相比之下,像 C++ 或 Java 这样的静态语言,一个对象的“形状”(有哪些属性,每个属性是什么类型)在编译的时候就定死了。访问一个属性,比如 obj.x,编译器直接把它翻译成“访问内存地址 obj 往后偏移 N 个字节的位置”,这速度快得飞起。

那 JavaScript 呢?引擎在拿到 obj.x 这行代码的时候,它不知道 obj 到底长啥样。它得先去查一下:“嘿,obj,你有没有一个叫 x 的属性啊?” 这个过程,就像查字典一样,专业点叫“动态查找”,性能上肯定是有开销的。

如果每次访问属性都这么查一次,那性能肯定好不到哪儿去。所以,V8 引擎(Chrome 和 Node.js 用的就是它)就想了个办法,在动态和性能之间找到了一个绝妙的平衡点。这个办法,就是我们今天的主角——隐藏类(Hidden Classes),官方现在更喜欢叫它 Maps

二、V8 的骚操作:给对象偷偷“分类”

V8 的工程师们有个很聪明的假设:虽然 JavaScript 语法上很动态,但我们开发者写代码,其实是很有规律的。我们通常会创建很多“形状”相同的对象。

比如,我们可能会写一个构造函数:

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

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

p1p2 这两个对象,虽然值不一样,但它们的“形状”是一样的:都有一个 x 属性,和一个 y 属性,而且添加的顺序也是一样的。

V8 就利用了这一点。它会在背后,偷偷地为这些形状相同的对象创建一个“隐藏类”。这个隐藏类描述了对象的结构,比如有哪些属性,以及每个属性在内存中的偏移量是多少。

当 V8 发现 p1p2 的形状一样时,就会让它们共享同一个隐藏类。这样一来,当 V8 的优化编译器(TurboFan)在处理一段代码时,如果它能确定一个对象的隐藏类,那访问这个对象的属性时,就可以像静态语言一样,直接通过内存偏移来读取,省去了动态查找的开销。性能一下子就上来了!

三、它是怎么玩的呢?一个“连连看”游戏

隐藏类的创建和转换,就像一个“连连看”游戏。我们来看一个简单的例子:

  1. 创建一个空对象

    let obj = {};
    

    V8 会创建一个初始的隐藏类,我们叫它 C0C0 表示这是一个没有任何属性的空对象。

  2. 添加第一个属性

    obj.x = 10;
    

    V8 会创建一个新的隐藏类 C1,它描述了“一个拥有属性 x 的对象”。同时,V8 会在 C0C1 之间建立一个“转换”(Transition):从 C0 状态的对象,如果添加了属性 x,就转换到 C1 状态。现在,obj 这个对象的隐藏类就变成了 C1

  3. 添加第二个属性

    obj.y = 20;
    

    V8 又会创建一个新的隐藏类 C2,描述“一个拥有属性 xy 的对象”。同样,在 C1C2 之间建立一个转换:从 C1 状态,如果添加了属性 y,就转换到 C2 状态。obj 的隐藏类就变成了 C2

这个过程就像一条转换链:C0 -> C1 -> C2

重点来了:如果我们再创建一个新对象,并且以完全相同的顺序添加属性:

let obj2 = {};
obj2.x = 100;
obj2.y = 200;

V8 会发现,obj2 的属性添加过程,和 obj 走的是完全一样的“转换路径”。所以,V8 不会再创建新的隐藏类,而是直接让 obj2 复用 obj 的那套隐藏类(C0, C1, C2)。最终,objobj2 会共享同一个隐藏类 C2

因为它们共享同一个隐藏类,V8 就知道,它们的 y 属性都在同一个内存偏移位置。当 JIT 编译器优化代码时,就可以生成非常高效的机器码来访问这些属性。

四、需要注意的几个点(别让 V8 的努力白费了)

理解了隐藏类的工作原理,我们就能明白为什么有些写法会影响性能了。

1. 属性的顺序很重要

看下面这两行代码:

let obj1 = { x: 1, y: 2 };
let obj2 = { y: 2, x: 1 };

虽然 obj1obj2 最终的属性是一样的,但因为它们属性的添加顺序不同,V8 会为它们创建两条完全不同的隐藏类转换路径。它们最终的隐藏类也是不一样的。这就导致 V8 无法对它们进行统一的优化。

结论: 在创建对象时,尽量保证属性的初始化顺序一致。

2. 动态添加/删除属性是大忌

如果你在一个函数里,反复地给一个对象动态添加属性,会发生什么?

function update(obj) {
  obj.z = 3; // 动态添加属性
  return obj.x + obj.y + obj.z;
}

let o1 = { x: 1, y: 2 };
let o2 = { x: 1, y: 2 };

update(o1);
update(o2);

每次调用 update(o1) 时,o1 的隐藏类都会从 {x, y} 的状态转换到 {x, y, z} 的状态。这会创建新的隐藏类,并且可能导致之前 JIT 编译器做的优化失效(这个过程叫“去优化”或“Deoptimization”),因为对象的“形状”变了。

结论: 尽量在构造函数或对象字面量里一次性把所有属性都定义好,避免在后面再动态地去修改对象的结构。

3. “字典模式”——V8 的放弃治疗

如果你对一个对象的操作过于“狂野”,比如用 delete 删除属性,或者添加非常多的属性,V8 最终会“放弃治疗”。它会把这个对象的模式切换到“字典模式”(Dictionary Mode)。

在字典模式下,对象的属性就真的存一个类似哈希表(或字典)的数据结构里了。访问属性就变回了慢速的动态查找。虽然灵活,但性能就差远了。

五、总结一下

好了,今天就聊到这儿。我们来简单总结一下:

  • JavaScript 对象的动态性给性能优化带来了挑战。
  • V8 引擎通过“隐藏类”这个机制,为形状相同的对象创建共享的结构信息,从而实现快速的属性访问。
  • 隐藏类的创建和转换依赖于属性的添加顺序。
  • 为了帮助 V8 更好地优化我们的代码,我们应该:
    1. 在构造函数或对象字面量中初始化所有成员。
    2. 始终保证属性的初始化顺序一致。
    3. 尽量避免在对象创建后动态添加或删除属性。

理解了隐藏类的概念,你就能更深刻地理解为什么某些 JavaScript 的“最佳实践”是那样建议的。它不是玄学,背后都是有科学道理的。