~/ ?.log $
返回文章列表
10 min read
更新于 2026年3月4日

我不知道的 V8(04)— 隐藏类、快属性与慢属性:对象性能的核心

一个常见的认知是:JavaScript 是动态语言,对象可以随意增删属性,这天然就是慢的。但 V8 不接受这个结论。它在内部引入了隐藏类(Hidden Class)机制,让动态对象的属性访问接近静态语言的速度。理解这套机制,是理解 V8 对象优化的起点。

一个常见的认知是:JavaScript 是动态语言,对象可以随意增删属性,这天然就是慢的。但 V8 不接受这个结论。它在内部引入了**隐藏类(Hidden Class)**机制,让动态对象的属性访问接近静态语言的速度。理解这套机制,是理解 V8 对象优化的起点。

一、为什么动态语言需要隐藏类

考虑一个简单的属性访问:

const obj = { name: 'V8', version: '8.4' };
console.log(obj.name);

朴素的实现方式是:每次访问 obj.name,在对象的属性字典里查找 "name" 这个键,找到对应的值。这是 O(1) 的哈希查找,看起来够快了。

但在热点代码里,同样的属性访问可能每秒执行数百万次。每次都走哈希查找,代价是可以避免的。

隐藏类的思路:如果很多对象的结构完全一样(都有 nameversion 属性),那就只需要描述一次这个结构,所有相同结构的对象共享这份描述——这就是隐藏类。

二、快属性:线性存储 + 偏移量访问

当创建一个对象时,V8 会为它生成一个隐藏类:

const obj = { name: 'V8', version: '8.4' };

V8 的内部表示大致如下:

隐藏类 C0:
  name    → offset 0
  version → offset 1

obj:
  隐藏类指针 → C0
  属性存储: [ "V8", "8.4" ]

name 在 offset 0,version 在 offset 1。之后访问 obj.name 时,V8 不再查字典,而是直接读取 offset 0 的值。属性查找变成了内存偏移操作,这就是快属性(Fast Properties)。

如果后续创建了结构相同的对象,它们会共享同一个隐藏类:

const obj2 = { name: 'Node.js', version: '20.0' };
// obj2 的隐藏类也是 C0,name 还是 offset 0

这让内联缓存(IC)发挥作用——引擎记住”上次看到隐藏类 C0 的时候,name 在 offset 0”,下次直接复用这个结论,不需要重新查找。

三、隐藏类是如何演变的

隐藏类不是固定的。每次给对象增加新属性,V8 会创建新的隐藏类:

const obj = {}; // 隐藏类 C0(空对象)
obj.name = 'V8'; // 新隐藏类 C1:{ name: offset 0 }
obj.version = '8.4'; // 新隐藏类 C2:{ name: offset 0, version: offset 1 }

这一系列演变叫做隐藏类转移(Transitions),V8 会记录这条转移链。

重要推论:属性添加顺序不同的对象,会得到不同的隐藏类,即使最终属性集合相同:

const a = {};
a.x = 1;
a.y = 2;
// 隐藏类路径:C0 → C1(x) → C2(x,y)

const b = {};
b.y = 2;
b.x = 1;
// 隐藏类路径:C0 → C3(y) → C4(y,x)

ab 的属性一样,但隐藏类不同,内联缓存无法复用。这是一个常见的性能陷阱。

实践规则:初始化对象时属性顺序要一致。

四、慢属性:哈希表接管

快属性的前提是对象结构稳定。一旦对象变得”动态”,V8 就会放弃快属性,转换为慢属性(Slow Properties),用哈希表存储:

const obj = { name: 'V8' };
delete obj.name; // 触发:删除操作破坏隐藏类
obj.version = '8.4';

转换后的结构:

obj (慢属性模式):
  哈希表: { "version" → "8.4" }

隐藏类失效,每次属性访问都需要哈希计算。相比快属性的直接偏移量读取,慢属性有额外的常数时间开销,且无法被内联缓存高效缓存。

触发慢属性的常见场景:

// 1. 使用 delete
delete obj.name;

// 2. 属性数量过多(通常超过几十个属性时)
for (let i = 0; i < 100; i++) {
  obj[`prop${i}`] = i;
}

// 3. 无序添加数字索引属性
obj[10] = 'a';
obj[0] = 'b'; // 先添加 10,再添加 0

用 V8 的内部命令可以验证:

// node --allow-natives-syntax script.js
const obj = { name: 'V8' };
console.log(%HasFastProperties(obj)); // true

delete obj.name;
console.log(%HasFastProperties(obj)); // false

五、常规属性与排序属性

V8 还区分了两种属性类型,分开存储:

  • 常规属性(Named Properties):字符串键,如 obj.name
  • 排序属性(Indexed Properties):数字键,如 obj[0],按数字顺序存储
const obj = {
  0: 'zero', // 排序属性,存入专用的 elements 数组
  1: 'one',
  name: 'V8', // 常规属性,走隐藏类路径
};

两类属性走不同的访问路径。对于类数组对象或混合使用字符串键和数字键的对象,理解这个区别有助于分析性能。

六、如何保持快属性模式

原则一:预定义属性,避免动态增删

// 不推荐:动态添加属性,触发多次隐藏类创建
const user = {};
if (condition) user.name = 'V8';
if (otherCondition) user.version = '8.4';

// 推荐:初始化时声明所有属性
const user = { name: '', version: '' };
user.name = 'V8';
user.version = '8.4';

原则二:用赋值 undefined/null 代替 delete

// 不推荐:破坏隐藏类
delete obj.name;

// 推荐:保持隐藏类稳定,只改变值
obj.name = null; // 或 obj.name = undefined

原则三:保持属性添加顺序一致

// 工厂函数中始终以相同顺序初始化,确保共享隐藏类
function createPoint(x, y) {
  return { x, y }; // 简洁的字面量形式保证顺序一致
}

const p1 = createPoint(1, 2);
const p2 = createPoint(3, 4);
// p1 和 p2 共享同一个隐藏类 ✓

原则四:频繁增删键值对场景用 Map

// 对象频繁删改 → 退化为慢属性
const cache = {};
cache['key1'] = 1;
delete cache['key1']; // 慢属性风险

// Map 专为动态键值对设计,无此问题
const cache = new Map();
cache.set('key1', 1);
cache.delete('key1'); // 安全,不影响性能

七、快慢属性的性能对比

维度快属性(Fast Properties)慢属性(Slow Properties)
存储方式线性数组 + 隐藏类偏移哈希表
访问时间复杂度O(1) 直接偏移O(1) 平均,但有哈希计算开销
内联缓存支持高效受限
内存占用紧凑有额外哈希表开销
适用场景结构稳定的对象频繁动态增删属性的对象

性能差异在小对象或低频访问时可以忽略。在高频访问的热点代码里(如渲染循环、数据处理),快慢属性之间的差异会被显著放大。

八、总结

V8 用隐藏类把 JavaScript 动态对象的属性访问,优化成了接近静态语言的偏移量读取。代价是:对象结构必须保持稳定。

一旦结构发生意外变化——比如用 delete 删除属性、属性添加顺序不一致——隐藏类就会分裂,快属性就会退化成慢属性,内联缓存就会失效。

把这个机制刻在脑子里,再看”为什么不要用 delete”、“为什么要预定义属性”这些建议,就不再是规则记忆,而是自然推论。


本系列其他文章:

share.ts

// 觉得这篇文章有帮助?

// 欢迎分享给更多人

export const  subscribe  =  "/rss.xml" ;