我不知道的 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) 的哈希查找,看起来够快了。
但在热点代码里,同样的属性访问可能每秒执行数百万次。每次都走哈希查找,代价是可以避免的。
隐藏类的思路:如果很多对象的结构完全一样(都有 name 和 version 属性),那就只需要描述一次这个结构,所有相同结构的对象共享这份描述——这就是隐藏类。
二、快属性:线性存储 + 偏移量访问
当创建一个对象时,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)
a 和 b 的属性一样,但隐藏类不同,内联缓存无法复用。这是一个常见的性能陷阱。
实践规则:初始化对象时属性顺序要一致。
四、慢属性:哈希表接管
快属性的前提是对象结构稳定。一旦对象变得”动态”,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”、“为什么要预定义属性”这些建议,就不再是规则记忆,而是自然推论。
本系列其他文章:
- 上一篇:函数是如何变得可调用的
- 下一篇:为什么不建议使用 delete 删除属性
- 延伸阅读:内联缓存的秘密:属性访问如何被加速
- 延伸阅读:字典模式下的非线性优化与限制