前端开发··2 阅读·预计 9 分钟

V8 对象属性的隐藏税:从 In-object 属性到字典模式的性能塌方

V8 对象属性的隐藏税:从 In-object 属性到字典模式的性能塌方

大多数前端对 V8 的认知停留在「隐藏类(Hidden Class / Map)加速属性访问」这一层。但真正让对象性能分化的,是属性值到底存在哪、怎么存。同一份语义的对象,只因一次 delete,读写速度就可能慢一个数量级。本文拆解 V8 的三种属性存储形态,用可复现的 benchmark 对照正反例。

属性到底住在哪?三种存储形态

V8 中,一个 JS 对象从逻辑上由「隐藏类 + 内联属性槽 + 外置属性数组(可选)+ 元素数组」构成。属性的值可以落在三个地方:

  1. In-object 属性(内联属性):直接嵌在对象本体的固定槽位里,访问零额外跳转,最快。
  2. Fast 属性(外置描述符数组):对象本体存一个指向 FixedArray 的指针,通过隐藏类里的偏移量索引,稍慢一点。
  3. Dictionary 模式(字典属性):属性退化成一个哈希表(NameDictionary),每次读写都要哈希查找,最慢。

关键决策发生在对象首次分配时:V8 根据构造函数里声明的属性数量,预留内联槽位。超出预留的部分,会被提升到外置属性数组里。

正例:让属性稳定地待在内联槽

function Point(x, y, z) {
  this.x = x;  // 内联槽 0
  this.y = y;  // 内联槽 1
  this.z = z;  // 内联槽 2
}

const points = new Array(1_000_000);
for (let i = 0; i < points.length; i++) {
  points[i] = new Point(i, i + 1, i + 2);
}

// 预热后测量读取
let sum = 0;
const t0 = performance.now();
for (let i = 0; i < points.length; i++) {
  sum += points[i].x + points[i].y + points[i].z;
}
const t1 = performance.now();
console.log('fast read:', (t1 - t0).toFixed(2), 'ms');

三个属性都落在内联槽,读取走的是「基址 + 固定偏移」,TurboFan 能直接把它编译成一条内存加载指令。

反例一:delete 触发字典模式塌方

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

const pts = new Array(1_000_000);
for (let i = 0; i < pts.length; i++) {
  const p = new Point(i, i + 1, i + 2);
  delete p.y;              // 灾难:Fast 模式被打破,跌入字典模式
  pts[i] = p;
}

let sum = 0;
const t0 = performance.now();
for (let i = 0; i < pts.length; i++) {
  sum += pts[i].x + pts[i].z;
}
const t1 = performance.now();
console.log('dict read:', (t1 - t0).toFixed(2), 'ms');

delete 是触发字典退化的首要元凶。一旦某个对象进入字典模式,它和后续共享同一隐藏类的对象都会被拖下水:V8 需要把一个 Fast 隐藏类「转译」成字典形态,并生成新的过渡 Map,内联缓存(IC)随之失效。

替代方案:与其删除,不如置空并约定跳过。

p.y = undefined;           // 保留槽位,隐藏类结构不变
// 序列化时统一过滤 undefined,读取时成本几乎为零

反例二:动态扩容把属性挤出内联槽

function Vec() {}
const vs = new Array(1_000_000);
for (let i = 0; i < vs.length; i++) {
  const v = new Vec();
  v.a = i; v.b = i + 1; v.c = i + 2;
  v.d = i + 3; v.e = i + 4; v.f = i + 5;  // 超过内联槽预留数,溢出到外置属性数组
  vs[i] = v;
}

构造函数里没有任何 this.x = ...,V8 预留的内联槽数量降到最低(通常 0~2 个),后续 v.dv.ev.f 全部溢出成为 Fast 外置属性。虽然仍是 Fast 模式,但每次访问都要先解引用属性数组指针,多一次间接跳转。

工程化正解:在构造函数里「显式声明」全部属性,哪怕先赋 null

function Vec() {
  this.a = null; this.b = null; this.c = null;
  this.d = null; this.e = null; this.f = null;  // 全部内联
}

反例三:乱序初始化制造 Map 分裂

function makeA() { const o = {}; o.x = 1; o.y = 2; return o; }
function makeB() { const o = {}; o.y = 2; o.x = 1; return o; }  // 顺序不同

makeAmakeB 生成的两个对象,属性赋值顺序不同,隐藏类树会走上两条不同的分支,得不到同一个共享 Map。若它们混在同一个热路径里被循环处理,IC 就会在多态甚至超态(polymorphic / megamorphic)之间反复横跳,反而比一致顺序慢。

保持所有「同构」对象的属性初始化顺序完全一致,是让 IC 保持单态(monomorphic)的前提。

如何验证对象处于何种模式

Node 环境可用 %DebugPrint(需 --allow-natives-syntax)直接观察:

node --allow-natives-syntax -e "
const o = { a: 1, b: 2 };
%DebugPrint(o);
const d = { a: 1, b: 2 };
delete d.a;
%DebugPrint(d);
"

输出里关注两点:map 的类型,以及属性是否出现 dictionary 字样。Fast 模式显示 properties: ... NumberDictionary 之外的 FixedArray,字典模式则明确标注 dictionary mode

四条工程化准则

  1. delete 热对象属性:置 undefined / null 代替,序列化阶段统一过滤。
  2. 构造函数显式声明全部属性:把内联槽预留拉满,避免溢出到外置数组。
  3. 固定属性初始化顺序:同构对象保持同一份 Map,喂给 IC 单态路径。
  4. 高频对象集中创建:避免「先建后补」的散装写法,让布局在分配那一刻就确定。

小结

隐藏在「对象属性」背后的,是一套可退化的存储分级:内联槽最快、外置描述符次之、字典模式最慢。绝大多数日常代码不需要手动干预,「隐藏类 + 内联缓存」会默默兜底;但 delete、动态补属性、乱序初始化这三类写法,会在不知不觉中把对象推下性能悬崖。理解了属性住在哪,才能真正写出让 V8 舒服的代码。

0 评论

评论区

登录 后参与评论