隐藏类与回收机制
# 02.隐藏类与分代垃圾回收
📍 上接第 01 篇《引擎执行管线全景》。我们知道了代码怎么被执行。本文回答:执行过程中创建的对象,在 V8 堆中如何存放、如何查找、如何被回收。
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 隐藏类与Map
- 4. 属性存储三态
- 5. 元素六态详解
- 6. 新生代 Scav
- 7. 老生代回收详解
- 8. 并行回收Oilp
- 9. 编码启示与反模式
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 同一构造函数的
打开 Chrome DevTools 的 Memory 面板,运行两段几乎一模一样的代码,记录堆快照:
// 写法 A:constructor 里按固定顺序赋值
class PointA {
constructor(x, y) {
this.x = x; // ← 先 x
this.y = y; // ← 后 y
}
}
const pointsA = [];
for (let i = 0; i < 1_000_000; i++) {
pointsA.push(new PointA(i, i + 1));
}
// Memory 面板:Shallow Size ≈ 32MB
// 写法 B:constructor 外面动态添加属性
class PointB {
constructor(x, y) {
this.x = x;
}
}
const pointsB = [];
for (let i = 0; i < 1_000_000; i++) {
const p = new PointB(i);
p.y = i + 1; // ← 在 constructor 外面加
pointsB.push(p);
}
// Memory 面板:Shallow Size ≈ 48MB —— 多了 50%!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
同一个 x/y 两个字段、同样 100 万个对象——内存差了 50%。
更诡异的是属性访问速度:
// 访问 PointA 的属性
console.time('PointA');
for (const p of pointsA) { const _ = p.x + p.y; }
console.timeEnd('PointA'); // → ~4ms
// 访问 PointB 的属性
console.time('PointB');
for (const p of pointsB) { const _ = p.x + p.y; }
console.timeEnd('PointB'); // → ~12ms —— 慢了 3 倍!
2
3
4
5
6
7
8
9
# 1.2 顺藤摸到根因
造成 50% 内存差和 3 倍速度差的,不是代码语义——两段代码做的事情完全相同(给对象设两个属性 x 和 y)。根因在 V8 对这两段代码产生的对象内部表示完全不同。
写法 A 在 constructor 里按固定顺序赋完所有属性 → V8 在对象上打了一个**隐藏类(Hidden Class)**标签,这个隐藏类告诉引擎:"这个对象的 x 在偏移 12 字节处,y 在偏移 20 字节处"——之后每次 p.x 都是一次简单的指针加法,不需要任何查找。
写法 B 在 constructor 外面追加属性 → V8 在 constructor 结束时认为对象"已完成"(打了隐藏类 A),出了 constructor 又加了一个属性 → V8 被迫生成一个新隐藏类 B,并且原来的对象必须从"快速模式"退化成能容纳任意新增属性的"字典模式"。字典模式里每次 p.x 都要做哈希查找——这就是为什么属性访问慢了 3 倍。
此外,字典模式需要额外的哈希表结构来存属性名→位置的映射——这就是为什么内存多了 50%。
# 1.3 我们要回答什么
本文沿着 V8 的对象模型追问以下六个问题——每个问题对应中间的一章:
- ① 隐藏类机制:V8 为什么不给每个对象存一份属性名→偏移的映射?
- ② 属性存储三态:什么是 in-object / fast / slow?在什么条件下对象会在三者之间切换?
- ③ Elements Kinds:数组比对象更复杂——
[1, 2, 3]和[1.5, 2, 3]内部结构完全不同?为什么退化不可逆? - ④ 新生代 GC:为什么不直接回收所有垃圾,而要分新生代和老生代?
- ⑤ 老生代 GC:三色标记法为什么比引用计数更"聪明"?并发标记期间 JS 还能继续跑吗?
- ⑥ 编码启示:从上面这些原理出发,什么样的代码对 V8 的 GC 和对象模型最友好?
最后,我们会用一个对象从 new 到被 GC 回收的完整生命周期串起所有这些阶段。
# 2. 架构全景概览
# 2.1 V8 堆区全景图
先建立 V8 堆的内存分块大图——这决定了对象"从出生到被回收"走哪条路:
┌──────────────────────────────────────────────────────────────┐
│ V8 堆内存全景 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 新生代 (Young Generation) │ ← 总大小 1~8 MB │
│ │ ┌──────────────┬──────────────────┐ │ │
│ │ │ From-Space │ To-Space │ │ ← 两半空间 │
│ │ │ (活跃对象) │ (始终为空) │ │ 交替使用 │
│ │ └──────────────┴──────────────────┘ │ │
│ │ 新生对象首先分配在此 │ │
│ │ GC 后存活对象→晋升到老生代 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 老生代 (Old Generation) │ ← 总大小可达几百 MB │
│ │ ┌─────────┐ ┌──────────────────┐ │ │
│ │ │ Old │ │ 大对象空间 │ │ │
│ │ │ Space │ │ (Large Object │ │ ← >256KB 直接进这 │
│ │ │ │ │ Space) │ │ │
│ │ └─────────┘ └──────────────────┘ │ │
│ │ 晋升到此的对象用 Mark-Compact GC │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 代码空间 (Code Space) │ ← JIT 编译产出的 │
│ │ 存放 Sparkplug/Maglev/TurboFan │ 机器码存这里 │
│ │ 产出的机器码 │ │
│ └─────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ℹ️ 本节中"新生代/老生代"的讨论以 V8 为主线。SpiderMonkey 和 JSC 有各自的分代策略(细节有差异,但分代思想一致),第 11 篇跨引擎对比时会展开。
# 2.2 为什么要有新生代
疑惑:为什么不把内存看成一个"大池子",所有对象都放在同一个池子里,用同一种 GC 算法管理?
论证:
这关系到一条被大量实证验证过的规律——分代假说(Generational Hypothesis):
绝大多数对象在创建后很快就会变成垃圾("朝生暮死");存活越久的对象越可能继续存活。
这条假说在 JS 的典型使用场景中尤其成立:
function renderUI(data) {
const temp = {}; // ← 函数结束即成为垃圾
const intermediate = []; // ← 函数结束即成为垃圾
const result = process(data); // ← 可能被 return 回去,活得更久
return result;
}
// renderUI 每次调用产生大量 temp 对象,
// 但 99% 在函数返回后就没人引用了——它们"朝生暮死"
2
3
4
5
6
7
8
分代假说直接引导了分代 GC 的设计:
| 新生代(Young Gen) | 老生代(Old Gen) | |
|---|---|---|
| 存活假设 | 大部分会很快死掉 | 大部分会继续存活 |
| 发生频率 | 频繁(每次满就触发) | 较少(只有在老生代满了才触发) |
| GC 算法 | Scavenge(复制):只关心活着的对象,开销正比于存活对象数 | Mark-Compact(标记-整理):标记所有活对象,整理碎片 |
| 单次 GC 耗时 | 1~5ms(极快,因为大部分都死了,活着的很少) | 10~100ms(较慢,因为大量对象都活着,都要标记) |
| 策略精髓 | "把活的复制出去,剩下的全扔掉" | "标记谁还活着,没标记的都回收" |
如果只用一种算法(没有分代)会怎样?
假设只用 Mark-Compact 管理整个堆——每次 GC 都要遍历堆中所有对象(包括那些刚创建、马上要死的临时对象)。一个页面生命周期中可能创建几十万个临时对象——对它们做标记-整理是巨大的浪费。分代 GC 把"快速死"的对象隔离在新生代里,用一种几乎免费的算法(Scavenge)回收它们——而老生代里那些"长期存活"的对象(如页面主 ViewModel、全局单例)很少被 GC 光顾。
结论:分代不是"为了分层而分层"——它是对"对象的自然生灭规律"的工程利用。把 90% 的死对象用极低成本回收,把 10% 的活对象用较贵但精确的算法管理,总体 GC 开销最小化。
# 3. 隐藏类与Map
# 3.1 隐藏类的变迁链规则
疑惑:静态语言(Java/C++)的对象在编译期就知道"这个对象有哪些字段、各在偏移多少"——因为类定义是固定的。但 JS 的对象可以随时添加/删除属性——V8 如何在不知道"这个对象最终长什么样"的前提下实现快速的属性访问?
论证:V8 的答案是——给每个对象分配一个"隐藏类",隐藏类之间形成一条"变迁链"。
每次向对象添加一个属性,V8 不修改已有的隐藏类——
而是创建一个"新隐藏类"并通过 transition 指针挂到旧隐藏类下面:
null (没有属性时的 Map)
│
│ 添加 .x
▼
┌─────────────────┐
│ Map₁ │
│ 属性: x @ 偏移12 │
│ 上一个Map: null │──→ null
└────────┬────────┘
│ 添加 .y
▼
┌─────────────────┐
│ Map₂ │
│ 属性: y @ 偏移20 │
│ 上一个Map: Map₁ │──→ Map₁
└─────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
上面的结构解释了写法 A 的 PointA 为什么高效:
function PointA(x, y) {
this.x = x; // this 从 Map₀ → Map₁ (x @ 偏移12)
this.y = y; // this 从 Map₁ → Map₂ (y @ 偏移20)
}
// 所有 PointA 实例最终都是 Map₂
// → V8 可以直接用"Map₂ + 偏移12"访问 this.x,无需查找
2
3
4
5
6
但如果同一个构造函数产生了不同的属性创建路径:
function PointBad(x, y, hasZ) {
this.x = x;
if (hasZ) {
this.z = 0; // ← 只有 hasZ=true 时才添加 z
}
this.y = y;
}
const p1 = new PointBad(1, 2, false); // x → y → Map₂(x,y)
const p2 = new PointBad(1, 2, true); // x → z → y → Map₃(x,z,y)
// ↑ p1 和 p2 的隐藏类不同!
// → V8 无法对它们使用"同一个偏移"访问属性
// → Feedback Vector 从单态(MONOMORPHIC)退化为多态(POLYMORPHIC)
// → 属性访问速度下降 3~5 倍
2
3
4
5
6
7
8
9
10
11
12
13
结论:隐藏类变迁链是一种 "属性添加历史的指纹"。两个对象如果有相同的属性列表和相同的添加顺序,就会共享同一个隐藏类——这正是 TurboFan(第 01 篇 §7.2)能做出"跳过查找、直接读偏移"这种激进优化的前提。
# 3.2 属性顺序实验
用 Chrome DevTools 的 %DebugPrint 亲眼看到隐藏类:
// 需要在 Node.js 中加 --allow-natives-syntax 标志
// node --allow-natives-syntax
function A() { this.x = 1; this.y = 2; }
function B() { this.y = 2; this.x = 1; } // ← 顺序反了
const a = new A();
const b = new B();
%DebugPrint(a);
// 输出(简化):
// - map: 0x12340000 <Map(HOLEY_ELEMENTS)>
// ...
// - x: 1 (const data field 0), offset: 12
%DebugPrint(b);
// 输出(简化):
// - map: 0x56780000 <Map(HOLEY_ELEMENTS)> ← 注意:地址不同!
// ...
// - y: 2 (const data field 0), offset: 12
// - x: 1 (const data field 1), offset: 20 ← x 和 y 的偏移位置互换了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
论证:a 的 Map 地址是 0x12340000,b 的 Map 是 0x56780000——顺序不同 → 隐藏类不同。虽然两个对象的"最终属性集完全相同"(都有 x 和 y),但它们是不同的隐藏类——这意味着 TurboFan 看到这两个对象时会把它当成"多态"来处理,而不是"单态快速路径"。
实测影响:在同一个热循环中交替使用 A 和 B 的实例:
const mix = [];
for (let i = 0; i < 10000; i++) {
mix.push(i % 2 === 0 ? new A() : new B());
}
// 访问 mix 中对象的属性
console.time('mixed');
for (const obj of mix) { const _ = obj.x + obj.y; }
console.timeEnd('mixed'); // → ~8ms
// 对比:全用 A
const pure = Array.from({length: 10000}, () => new A());
console.time('pure');
for (const obj of pure) { const _ = obj.x + obj.y; }
console.timeEnd('pure'); // → ~2ms —— 快 4 倍
2
3
4
5
6
7
8
9
10
11
12
13
14
结论:属性赋值顺序的一致性比"最终属性集相同"重要得多。 如果你有一个被频繁调用的构造函数,确保所有实例走同一套属性赋值路径——不要根据条件跳跃式地添加属性。
# 3.3 隐藏类变迁的不可
疑惑:如果一个对象后来被删除了属性,它的隐藏类会怎么变?
const obj = { x: 1, y: 2 };
delete obj.x; // ← 删除了 x
obj.z = 3; // ← 又加了 z
2
3
论证:删除属性后,V8 不会尝试"回溯"隐藏类链——因为隐藏类变迁链是单向的、不可逆的。
正常路径(无 delete):
Map₀ --add x--> Map₁(x) --add y--> Map₂(x,y) --add z--> Map₃(x,y,z)
有 delete 后的路径:
Map₀ --add x--> Map₁(x) --add y--> Map₂(x,y)
│
│ delete x
▼
┌─────────────────────────┐
│ Dictionary Mode │ ← 直接跳过所有快速路径
│ prop_table: {y: ..., z: ...} │ 进入字典模式
│ (哈希表存储) │
└─────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
一旦进入字典模式,对象的所有属性访问都变成哈希查找——即使后来没有再增删属性,也不会恢复成快速模式。这是隐藏类的单行道设计:V8 只为"连续添加"生成变迁链,"删除/混合变更"直接退化为字典。
验证:用 Node.js --allow-natives-syntax 查看字典模式的对象:
function test() {
const obj = {};
obj.a = 1;
obj.b = 2;
%DebugPrint(obj); // → Map ... FAST_HOLEY_ELEMENTS ...
delete obj.a;
%DebugPrint(obj); // → Map ... DICTIONARY_ELEMENTS ...
obj.c = 3;
%DebugPrint(obj); // → Map ... DICTIONARY_ELEMENTS ... ← 回不去了
}
test();
2
3
4
5
6
7
8
9
10
11
# 4. 属性存储三态
# 4.1 In-object
疑惑:V8 的对象到底有几个"放属性的抽屉"?
V8 对象的属性并不是"统一存到一处"——有三种存储介质,按访问速度从快到慢:
| 存储介质 | 访问速度 | 容量 | 什么时候用 |
|---|---|---|---|
| In-object | 最快(对象体内直接偏移) | 有限(通常前 N 个属性) | 隐藏类预计算的"固定布局"属性 |
| Fast(Properties Array) | 较快(线型数组偏移) | 无限(可溢出) | 超出 in-object 容量的属性 |
| Slow(Dictionary) | 最慢(哈希查找) | 无限 | 动态增删属性、delete 后 |
一个"刚分好隐藏类"的 V8 对象的内存布局:
┌─────────────────────────────┐
│ JSObject 头部(~32 字节) │
│ ┌───────────────────────┐ │
│ │ Map 指针(隐藏类地址) │ │ ← 8 字节
│ │ Properties 数组指针 │ │ ← 8 字节(指向 Fast/Slow 属性)
│ │ Elements 数组指针 │ │ ← 8 字节(指向数组元素)
│ │ In-object Property 1 │ │ ← 直接嵌在对象体内!
│ │ In-object Property 2 │ │ 读取时:
│ │ In-object Property 3 │ │ &obj + Map.offset[prop]
│ │ ... │ │ 一次指针加法,零间接访问
│ └───────────────────────┘ │
└─────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
In-object 属性的关键优势:不需要额外的内存间接访问。obj.x 被编译为 mov rax, [obj_addr + 12]——直接读对象体内偏移 12 的位置。没有指针追踪、没有数组偏移、没有哈希。
# 4.2 Fast 属性
当一个对象的属性数量超过隐藏类预分配的 in-object 槽位时:
function makeObject(n) {
const obj = {};
for (let i = 0; i < n; i++) {
obj['prop' + i] = i; // ← 连续添加的属性
}
return obj;
}
const small = makeObject(5); // 5 个属性 → 可能全部 in-object
const large = makeObject(50); // 50 个属性 → 前 N 个 in-object,后面的进 Properties Array
2
3
4
5
6
7
8
9
Fast 属性存储在外部的 Properties Array 中:
前 N 个属性(in-object)直接嵌在 JSObject 内
第 N+1 个开始的属性 → 进入独立的 Properties Array:
Properties Array (线性数组):
┌───┬───┬───┬───┬───┐
│ p5│ p6│ p7│...│p49│ ← 顺序存储,通过偏移索引
└───┴───┴───┴───┴───┘
读取 prop42:需要一次额外的指针间接访问
1) 读 JSObject → Properties 指针
2) 读 Properties[42 - N] 偏移
2
3
4
5
6
7
8
9
10
11
比 in-object 多一次指针追踪,但仍然是 O(1) 偏移访问——比字典的哈希查找快得多。
# 4.3 Slow/Dic
当对象经历以下任一操作时,V8 放弃 Fast 模式,转为字典模式:
delete操作(见 §3.3)- 属性添加顺序不一致(同一个构造函数的实例走了不同的属性路径)
- 大量属性的动态添加(超出 V8 为 Fast 模式预设的阈值)
字典模式内部是一个哈希表:
Dictionary(哈希表):
┌──────┬──────┬──────┬──────┬──────┐
│ name │ hash │ value│ name │ hash │ ... ← 属性名→值 的哈希映射
└──────┴──────┴──────┴──────┴──────┘
读取 obj.x:
1) 哈希 "x" → 定位桶
2) 检查桶中 name === "x"(可能发生哈希冲突)
3) 读 value
2
3
4
5
6
7
8
9
性能对比实测:
// Fast 模式 vs Slow 模式——属性访问速度对比
function benchFast() {
const obj = {};
for (let i = 0; i < 30; i++) obj['k' + i] = i;
const start = performance.now();
for (let j = 0; j < 1_000_000; j++) {
let sum = 0;
for (let i = 0; i < 30; i++) sum += obj['k' + i];
}
return performance.now() - start;
}
// → ~85ms
function benchSlow() {
const obj = {};
for (let i = 0; i < 30; i++) obj['k' + i] = i;
delete obj.k15; // ← 触发字典模式退化
for (let i = 0; i < 30; i++) obj['k' + i] = i;
const start = performance.now();
for (let j = 0; j < 1_000_000; j++) {
let sum = 0;
for (let i = 0; i < 30; i++) sum += obj['k' + i];
}
return performance.now() - start;
}
// → ~210ms —— 慢 2.5 倍
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 4.4 三态切换的条件与
┌─────────────────────────────────────────────┐
│ 属性存储三态的切换路径 │
├─────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ In-object │ ← 构造函数内按顺序 │
│ │ (O=1 指针加法)│ 赋了少量属性 │
│ └──────┬───────┘ │
│ │ 属性数量超 in-object 槽位 │
│ ▼ │
│ ┌──────────────┐ │
│ │ Fast Array │ ← 溢出属性用线型数组 │
│ │ (O=1 指针+偏移)│ │
│ └──────┬───────┘ │
│ │ delete / 动态增删 / 超过阈值 │
│ ▼ │
│ ┌──────────────┐ │
│ │ Dictionary │ ← 哈希表 │
│ │ (O=哈希查找) │ ⚠ 此退化不可逆 │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
结论:三态设计把"确定性"和"灵活性"的冲突压缩到了一个局部决策点上——如果你的代码行为足够规律(不变结构的对象),V8 给你最快的 in-object + Fast 路径;如果你的代码需要灵活的增删(比如动态扩展配置对象),V8 退而求其次,给了你字典——代价是 2~3 倍的访问开销。
# 5. 元素六态详解
# 5.1 PACKED_S
疑惑:为什么 [1, 2, 3] 和 [1.5, 2, 3] 的内部表示完全不同?
数组元素(Elements)的存储与对象属性(Properties)是两条独立的轨道。V8 对数组元素使用了更精细的分类系统——Elements Kinds,共六种状态:
Elements Kinds 的完整六态矩阵:
PACKED(密集,无空洞) HOLEY(稀疏,有空缺)
────────────────── ──────────────────
SMI (小整数) PACKED_SMI_ELEMENTS HOLEY_SMI_ELEMENTS
[1, 2, 3] [1, , 3]
│ (加浮点数退化) │
▼ ▼
DOUBLE (浮点) PACKED_DOUBLE_ELEMENTS HOLEY_DOUBLE_ELEMENTS
[1.5, 2, 3] [1.5, , 3]
│ (加非数字退化) │
▼ ▼
ELEMENTS (通用) PACKED_ELEMENTS HOLEY_ELEMENTS
[1, "hello", {}] [1, , "hello"]
2
3
4
5
6
7
8
9
10
11
12
13
14
关键规则:这条退化路径是严格单向的。一旦一个数组从 SMI 退化到 DOUBLE,它永远不会再变回 SMI——即使你把那个浮点数删了:
const arr = [1, 2, 3];
// arr 是 PACKED_SMI_ELEMENTS——最紧凑的表示
arr.push(1.5);
// 加了一个浮点数 → 整个数组退化为 PACKED_DOUBLE_ELEMENTS
arr.pop(); // 把 1.5 弹掉了
// arr 现在 = [1, 2, 3] —— 内容回到了纯整数!
// 但 Elements Kind 仍然是 PACKED_DOUBLE_ELEMENTS ← 回不去了
2
3
4
5
6
7
8
9
每种表示的内部内存布局完全不同:
PACKED_SMI_ELEMENTS:
┌───┬───┬───┐
│ 1s│ 2s│ 3s│ ← 每个元素是一个 Smi(带 tag 的整数值,直接存在槽里)
└───┴───┴───┘ 每个槽 4 或 8 字节(取决于平台)
PACKED_DOUBLE_ELEMENTS:
┌───────┬───────┬───────┐
│ 1.0 │ 2.0 │ 3.0 │ ← 每个元素是 IEEE 754 双精度浮点数
└───────┴───────┴───────┘ 每个槽 8 字节
PACKED_ELEMENTS:
┌───────┬───────┬───────┐
│ ptr_a │ ptr_b │ ptr_c │ ← 每个槽存的是指向堆对象的指针
└───────┴───────┴───────┘ 通用类型(字符串/对象等)
2
3
4
5
6
7
8
9
10
11
12
13
14
论证:SMI 能直接存值(不需要额外的堆分配),DOUBLE 也能直接存值但占更多空间,ELEMENTS 存的是指针(需要追踪到堆上的实际对象)。退化之所以"不可逆",是因为 V8 不会追踪"所有元素现在又都是整数了"这个事实——对引擎来说,跟踪状态改变比直接接受退化后的开销大得多。
# 5.2 HOLEY_SMI
疑惑:new Array(10000) 创建了一个"空洞数组"——它和 [1,2,3,...,10000] 在内存中是同一种东西吗?
论证:
const dense = [1, 2, 3]; // PACKED_SMI_ELEMENTS
const sparse = new Array(3); // HOLEY_SMI_ELEMENTS(全是空洞)
sparse[0] = 1; sparse[2] = 3; // 留下了 [1, hole, 3]
// 关键差异:HOLEY 数组每次访问元素时,
// V8 必须先检查"这个位置是不是空洞(hole)"
//
// 对于 HOLEY 数组 arr[1] 的访问:
// 1) 检查 index 1 是否 < elements.length
// 2) 检查 elements[1] 是否为 hole_sentinel(TheHole 标记值)
// 3) 如果是 hole → 沿原型链查找
// 4) 如果不是 → 返回实际值
//
// 对于 PACKED 数组 arr[1] 的访问:
// 1) 直接读 elements[1](无需额外检查)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
空洞的额外代价:
| 数组类型 | 元素访问开销 | 迭代器开销 |
|---|---|---|
| PACKED | 直接偏移读取 | for...of 只需要遍历实际元素 |
| HOLEY | 每次读先判洞 → 有洞则走原型链 | 迭代器需要检查每个位置,跳过 holes |
实测:
const packed = Array.from({length: 100000}, (_, i) => i);
const holey = new Array(100000);
for (let i = 0; i < 100000; i += 2) holey[i] = i; // 50% 是洞
console.time('packed for');
for (let i = 0; i < packed.length; i++) { const _ = packed[i]; }
console.timeEnd('packed for'); // → ~0.3ms
console.time('holey for');
for (let i = 0; i < holey.length; i++) { const _ = holey[i]; }
console.timeEnd('holey for'); // → ~1.2ms —— 慢 4 倍
2
3
4
5
6
7
8
9
10
11
# 5.3 不可逆的退化路径
Elements Kinds 的退化是单向的、不可逆的,完整路径:
PACKED_SMI ──→ PACKED_DOUBLE ──→ PACKED_ELEMENTS
│ │ │
(数组出现空洞) (数组出现空洞) (数组出现空洞)
│ │ │
▼ ▼ ▼
HOLEY_SMI ──→ HOLEY_DOUBLE ──→ HOLEY_ELEMENTS
退化触发条件:
SMI → DOUBLE:push 或赋值任何非整数值(1.5、NaN、Infinity)
DOUBLE → ELEMENTS:push 或赋值字符串/对象/undefined 等非数值
PACKED → HOLEY:delete、Array(n) 预分配、或赋值到越界位置
2
3
4
5
6
7
8
9
10
11
发现退化并验证:
// Node.js 中用 %HasFastPackedElements 检查
// node --allow-natives-syntax
const arr = [1, 2, 3];
console.log(%HasFastPackedElements(arr)); // → true (PACKED_SMI)
arr.push(undefined); // ← 甚至 undefined 也算"非数值"退化!
console.log(%HasFastPackedElements(arr)); // → false (PACKED_ELEMENTS)
delete arr[1]; // ← 制造空洞
console.log(%HasFastPackedElements(arr)); // → false (HOLEY_ELEMENTS)
// 这个数组已经是最差的 Elements Kind:HOLEY + ELEMENTS 双重退化
2
3
4
5
6
7
8
9
10
11
12
# 6. 新生代 Scav
# 6.1 Cheney
疑惑:新生代 GC 为什么能这么快(1~5ms)?
论证:新生代使用 Cheney 的半空间复制算法(Semi-space Copying GC),它的核心极其简洁:
┌─────────────────────────────────────────────┐
│ 新生代(两个半空间,各 1~4 MB) │
├─────────────────────────────────────────────┤
│ │
│ 分配阶段: │
│ ┌──────────────┬──────────────────┐ │
│ │ From-Space │ To-Space │ │
│ │ (正在使用中) │ (始终为空) │ │
│ │ [obj₁][obj₂] │ │ │
│ │ [obj₃] ↑ │ │ │
│ │ allocation│ │ │
│ │ pointer │ │ │
│ └──────────────┴──────────────────┘ │
│ │
│ GC 阶段(Scavenge): │
│ ┌──────────────┬──────────────────┐ │
│ │ From-Space │ To-Space │ │
│ │ (全部当作垃圾) │ ← 把活对象复制过来 │ │
│ │ [死对象][obj₁] │ [obj₁][obj₂] │ │
│ │ [obj₂][死对象] │ [obj₃] │ │
│ └──────────────┴──────────────────┘ │
│ ↓ GC 结束后 │
│ ┌──────────────┬──────────────────┐ │
│ │ From-Space │ To-Space │ │
│ │ (清空) │ (变成新的 From) │ │
│ └──────────────┴──────────────────┘ │
│ │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Scavenge GC 的步骤:
- 从根集(roots)出发:根集包括全局对象、当前执行栈上的局部变量、所有活跃的闭包引用
- 将根集直接引用的对象从 From-Space 复制到 To-Space
- 递归扫描复制到 To-Space 的对象,检查它们引用的其他对象——如果是 From-Space 中的对象,也复制到 To-Space
- 全部扫描完成后,From-Space 被一次性清空(所有没被复制过来的对象 = 垃圾)
- From 和 To 交换身份,下一次 GC 时重复此过程
为什么这么快?
因为 Scavenge 的开销正比于"存活对象数量",而不是"总对象数量"。在新生代中,根据分代假说,绝大部分对象是死的——GC 只需要处理那一小撮活着的对象:
假设 From-Space 有 10000 个对象,其中 9500 个已死亡:
1) 遍历根集,找到 50 个被根直接引用的活对象
2) 复制这 50 个对象到 To-Space
3) 递归扫描 50 个对象 → 又找到 450 个活对象 → 也复制
4) 总共复制了 500 个活对象(每个对象几百字节) → 总复制量 < 1MB
5) 耗时 ≈ 1~3ms
如果算法不是复制而是"标记-清扫"(老生代的做法),
它必须先遍历所有 10000 个对象来"标记谁说活" → 耗时 3~5 倍
2
3
4
5
6
7
8
9
# 6.2 晋升条件(两轮存活
一个对象在新生代中"活过两次 GC"后,V8 认为它够老了——晋升到老生代:
对象 obj 的生命事件:
创建 → 分配在 From-Space
GC-1 → 被复制到 To-Space(obj.age = 1)
GC-2 → 又被复制(obj.age = 2) → 🎉 晋升到老生代
晋升的含义:obj 的存储位置从"新生代"搬到"老生代"
→ 以后不再参与 Scavenge GC
→ 只在老生代满时才被 Mark-Compact GC 处理
2
3
4
5
6
7
8
此外还有直接晋升的特殊情况:如果 Scavenge 时发现某个对象过大(超过一个阈值),直接扔到老生代的"大对象空间"——避免在新生代的狭小半空间里反复复制大块内存。
# 6.3 写屏障(Write
疑惑:老生代的对象可以引用新生代的对象吗?如果能,老生代的 GC 怎么处理这种跨代引用?
论证:跨代引用是分代 GC 的最大难题之一。
// 老生代对象(长期存活)引用了新生代对象(新创建的)
const oldObj = window.myGlobal; // oldObj 在老生代(全局)
function create() {
const newObj = { parent: oldObj }; // newObj 在新生代
oldObj.child = newObj; // ← 跨代引用!
// oldObj (老生代) 引用 newObj (新生代)
}
2
3
4
5
6
7
问题来了:新生代 Scavenge GC 时只扫描根集 → 新生代的活对象。oldObj 在老生代里,不是新生代 GC 的扫描目标。如果新生代 GC 不"知道" oldObj.child 引用了 newObj,它会把 newObj 当成垃圾回收掉——即使 newObj 实际上是活的。
V8 的解决方案:写屏障(Write Barrier)
每次 JS 代码执行 oldObj.child = newObj(老生代 → 新生代的赋值)时:
1) V8 在赋值指令中插入一个"写屏障检查"
2) 检查内容:
if (oldObj 在老生代 && newObj 在新生代) {
记录这条引用:记住 oldObj 引用了 newObj
}
3) 记录的引用被存入一个"跨代引用表"(Remembered Set)
4) 新生代 GC 时,除了根集,额外扫描这个跨代引用表
→ 确保被老生代引用的新生代对象不会被误回收
2
3
4
5
6
7
8
9
10
11
12
13
写屏障在底层是一条汇编指令的插桩(在 Sparkplug/Maglev/TurboFan 生成的机器码中),在执行赋值操作的同时做地址空间的判断——开销极其微小(纳秒级),但对分代 GC 的正确性至关重要。
# 7. 老生代回收详解
# 7.1 三色标记法全流程
老生代使用三色标记法——一种经典的 GC 算法,由 Dijkstra 在 1970 年代提出,至今仍是所有现代 GC 的核心:
三色抽象:
┌──────┬──────────────────────────────┐
│ 颜色 │ 含义 │
├──────┼──────────────────────────────┤
│ 白色 │ 还没被扫描到(可能是垃圾) │
│ 灰色 │ 已经找到(是活的),但它的子引用还没看 │
│ 黑色 │ 自己处理完了,子引用也扫描完了 │
└──────┴──────────────────────────────┘
完整流程(7 步):
初始状态:所有对象都是白色,根集对象被设为灰色
步骤 1: 选一个灰色对象 G
步骤 2: 把 G 的所有子引用(G 字段引用的其他对象)从白色变为灰色
步骤 3: 把 G 变为黑色(处理完毕)
步骤 4: 重复 1~3,直到没有灰色对象
步骤 5: ── 此时所有"黑色"对象 = 活对象 ──
所有"白色"对象 = 垃圾(从未被任何活对象引用)
步骤 6: 清扫(Sweep):回收所有白色对象的内存
步骤 7: 压缩/整理(Compact):把活对象挪到一起,消除碎片
关键不变量:黑色对象永远不会引用白色对象。
(Write Barrier 负责维护这个不变量)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
与引用计数的对比(回答很多人的第一个疑惑:"为什么不直接用引用计数?"):
// 引用计数的致命缺陷:循环引用
function createCycle() {
const a = {};
const b = {};
a.friend = b; // b 的引用计数 = 2(变量 b + a.friend)
b.friend = a; // a 的引用计数 = 2(变量 a + b.friend)
// createCycle 返回后,变量 a 和 b 的生命周期结束
// 但 a.friend 指向 b,b.friend 指向 a → 引用计数各 = 1
// 引用计数 GC:这两个对象永远不会被回收 → 内存泄漏!
//
// 三色标记法 GC:
// 根集 = 当前栈变量(a 和 b 不再在根集中,因为函数已返回)
// 从根集出发,无法到达 a 或 b → a 和 b 都是白色 → 被回收 ✅
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 7.2 增量标记与并发标记
疑惑:老生代可能有好几百 MB 的对象。如果一次 GC 要花 200ms——JS 在这 200ms 里能继续执行吗?
论证:V8 用两种技术让老生代 GC 不阻塞 JS 执行:
(1)增量标记(Incremental Marking)
GC 不一次性完成整个标记阶段,而是切成多个 "增量步(step)",与 JS 执行交替进行:
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ 标记 │ JS │ 标记 │ JS │ 标记 │ JS │ ← 交错执行
│ 5ms │ 5ms │ 5ms │ 5ms │ 5ms │ 5ms │
└──────┴──────┴──────┴──────┴──────┴──────┘
每个增量步标记一部分对象,然后交还控制权给 JS
→ GC 总耗时没变(还是 200ms),但分成了 40 个 5ms 的小坑
→ 用户感知:"页面一直在响应,只是偶尔微卡一下"
→ 比 "一次卡 200ms" 的体验好得多
2
3
4
5
6
7
8
实现的关键:在 JS 执行期间,如果有代码修改了已标记的对象(比如把刚标记为黑色的对象改成引用了一个白色对象),写屏障会捕获这个变更——确保"黑色永远不引用白色"的不变量不被破坏。
(2)并发标记(Concurrent Marking)
更进一步,V8 让标记工作在单独的线程中运行,与 JS 主线程真正并行:
┌─────────────────────────────────────────────┐
│ 主线程 (JS) : 执行代码 执行代码 执行代码 │
│ │
│ 并发线程 (GC): 标记... 标记... 标记... 标记... │
│ │
│ 关键:并发线程在标记时"读"堆内存, │
│ JS 线程在"写"堆内存 │
│ → 写屏障协调两者,确保 GC 线程看到一致的内存视图 │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
最终阶段——STW(Stop-The-World):增量/并发标记完成后,仍有短暂的"同步暂停"(清扫 + 压缩)。V8 优化后,这个 STW 阶段通常只有几毫秒到十几毫秒(而非几百毫秒)。
# 7.3 压缩(Compa
疑惑:为什么老生代 GC 不光"清扫"(释放死对象),还要做"压缩"(搬动活对象)?
论证:标记-清扫后,老生代内部会变成"瑞士奶酪"——活得久的对象和死对象交替出现,释放的死对象空间形成空洞:
清扫后的老生代:
┌──┬──┬──┬──┬──┬──┬──┬──┐
│活│空│活│空│空│活│空│活│ ← 碎片化:无法分配大的连续对象
└──┴──┴──┴──┴──┴──┴──┴──┘
压缩后的老生代:
┌──┬──┬──┬──┬──────────┐
│活│活│活│活│ 空闲 │ ← 活对象被紧凑排列,后面是连续的大空闲区
└──┴──┴──┴──┴──────────┘
2
3
4
5
6
7
8
9
如果没有压缩:碎片化后,堆中虽然有 200MB 空闲空间(碎片加起来),但无法分配一个 10MB 的大对象(因为没有连续 10MB 的空闲块)——这就是"分配失败(Allocation Failure)",即使总空闲空间足够。
结论:压缩是用一次"搬家代价"换取长期的"分配顺利"。这种 tradeoff 在老生代中是划算的——因为老生代 GC 频率本就低(几分钟一次),每次多花几毫秒搬动对象,换来整个堆的健康状态。
# 8. 并行回收Oilp
# 8.1 V8 10.x
Orinoco 是 V8 自 9.x 起对 GC 架构的重命名,核心改进是并行化:
Orinoco 并行 GC 架构:
┌─────────────────────────────────────────────┐
│ │
│ 主线程 (JS): │
│ → 触发 GC │
│ → 启动并行/并发任务 │
│ → 等待任务完成 │
│ → 继续执行 JS │
│ │
│ 并行线程 1: 标记新生代 (Scavenge) │
│ 并行线程 2: 标记老生代 (Mark) │
│ 并行线程 3: 清扫 + 压缩 (Sweep + Compact) │
│ │
│ 线程数 = min(navigator.hardwareConcurrency, │
│ GC 任务可并行度) │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Orinoco 的并行模型不是简单的"开多个线程各扫一块",而是按任务阶段分工:
| 阶段 | 并行方式 | 线程 |
|---|---|---|
| 新生代 Scavenge | 多线程同时复制活对象 | 多个并行线程 |
| 老生代标记 | 并发(与 JS 并行) + 多线程分片标记 | 并发线程 + 多个工作线程 |
| 老生代清扫 | 多个工作线程分页清扫 | 多个并行线程 |
| 老生代压缩 | 多个工作线程并行搬动 | 多个并行线程 |
Orinoco 的命名灵感来自南美洲奥里诺科河——寓意就是"让垃圾像河水一样流走"。
# 8.2 Oilpan
疑惑:V8 本身是用 C++ 写的。V8 引擎的内部对象(AST 节点、字节码数组、编译器 IR)的 GC 怎么管理?这些 C++ 对象也走 JavaScript 的 GC 吗?
V8 的 C++ 内部对象由 Oilpan(CppGC)管理——它是 V8 内部的一个独立的、针对 C++ 对象的多代 GC:
┌──────────────────────────────────────────────┐
│ V8 双 GC 体系 │
├──────────────────────────────────────────────┤
│ │
│ JavaScript 堆 GC(Orinoco): │
│ → 管理 JS 对象(JSObject, JSArray...) │
│ → 使用 Scavenge + Mark-Compact │
│ → 分代:新生代 + 老生代 │
│ │
│ C++ 堆 GC(Oilpan / CppGC): │
│ → 管理 V8 内部 C++ 对象(AST, Bytecode...) │
│ → 使用并发三色标记 │
│ → 分代:新生代 + 老生代 │
│ │
│ 两者独立运行,但共享 GC 触发时机 │
│ │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Oilpan 的使用对 JS 开发者是不可见的——但理解它有助于解释某个现象:为什么 JS 的 GC 暂停和 V8 的 C++ 内部清理有关联。在 DevTools Memory 面板看到的垃圾回收暂停时间,是 Orinoco(JS GC)和 Oilpan(C++ GC)的叠加。
# 9. 编码启示与反模式
# 9.1 对隐藏类友好的代
从 §3 的原理出发,四条铁律:
铁律 1:在 constructor 中一次性按固定顺序赋完所有属性
// ✅ 好
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
// ❌ 坏
class Point {
constructor(x, y) {
this.x = x;
// ... 100 行别的逻辑
this.y = y; // ← 与 this.x 的赋值被间隔了很远
// 虽然还是同一个 constructor,但中间插入的其他操作
// 可能触发 GC 或影响编译器的内联决策
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
铁律 2:避免用条件分支动态决定属性创建路径
// ✅ 好:所有实例都有相同的属性结构
class User {
constructor(name, role) {
this.name = name;
this.role = role || 'user';
// ↑ 即使 role 是 undefined,属性也存在——隐藏类统一
}
}
// ❌ 坏:属性是否存在取决于运行时值
class User {
constructor(name, role) {
this.name = name;
if (role) {
this.role = role; // ← 只有 role 非空时才有这个属性
}
}
// 导致 User 的实例有两个隐藏类:
// 有 role 属性 和 没有 role 属性
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
铁律 3:避免 delete 删除属性,用赋值 null/undefined 替代
// ✅ 好:保留属性,只是值变
obj.x = null;
// ❌ 坏:触发 Dictionary Mode 退化
delete obj.x;
2
3
4
5
铁律 4:把嵌套对象的初始化也放在 constructor 中完成
// ✅ 好:所有子对象也一次性创建
class Config {
constructor() {
this.db = { host: 'localhost', port: 3306 };
this.cache = { enabled: true, ttl: 3600 };
}
}
// ❌ 坏:在外部逐层追加
const config = new Config();
config.db.username = 'root'; // ← 修改了 db 的形状
config.db.password = 'pass';
2
3
4
5
6
7
8
9
10
11
12
# 9.2 导致 Eleme
退化的常见陷阱 + 修复方案:
// ⚠ 陷阱 1:数组创建后立即 push 非整数
const arr = [1, 2, 3]; // PACKED_SMI
arr.push(1.5); // →退化为 PACKED_DOUBLE
// ✅ 修复:如果知道会有浮点数,一开始就写 [1.0, 2.0, 3.0]
// 或者在创建时明确标注类型
// ⚠ 陷阱 2:new Array(N) 预分配
const sparse = new Array(10000); // HOLEY_SMI —— 全是洞!
for (let i = 0; i < 10000; i++) sparse[i] = i;
// 虽然有 10000 个元素被填充,但 Elements Kind 仍然是 HOLEY_SMI
// ✅ 修复:用 Array.from 或 fill 初始化
const packed = Array.from({length: 10000}, (_, i) => i);
// 或 new Array(10000).fill(0).map((_, i) => i)
// ⚠ 陷阱 3:delete 制造空洞
const arr = [1, 2, 3, 4, 5];
delete arr[2]; // → HOLEY_SMI
// ✅ 修复:用 splice 删除
arr.splice(2, 1); // 元素被真正移除,数组保持 PACKED
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 9.3 短命对象 vs
新生代对象(临时/短命):
- 几乎免费:在 Scavenge GC 中,只有存货对象产生代价(复制),死对象零成本
- 不需要手动优化:把函数内创建的临时变量交给 GC 就好——它是被设计来高效处理这种情况的
老生代对象(全局/单例/缓存):
- 成本较高:每次老生代 GC 都要被扫描、检查引用、可能被搬动
- 值得优化:减少不必要的全局引用、卸载大对象
// ⚠ 隐藏的"长寿对象制造者":
// 1) 事件监听器引用了闭包中的大对象
element.addEventListener('click', () => {
process(largeData); // ← largeData 被闭包捕获 → 与 element 同寿
});
// ✅ 修复:用完及时解绑
const handler = () => process(largeData);
element.addEventListener('click', handler);
// ... 用完后
element.removeEventListener('click', handler);
// 2) 全局 Map/Set 永远不清除过期项
const cache = new Map();
function getData(key) {
if (!cache.has(key)) cache.set(key, fetchData(key));
return cache.get(key);
}
// ← cache 只增不减,所有条目都是"长寿对象"
// ✅ 修复:加 TTL 或 LRU 策略
class LRUCache {
constructor(maxSize) {
this.maxSize = maxSize;
this.map = new Map(); // Map 保证插入顺序
}
set(key, value) {
if (this.map.size >= this.maxSize) {
const firstKey = this.map.keys().next().value;
this.map.delete(firstKey); // 驱逐最旧的
}
this.map.set(key, value);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到 §1 的六个疑问——逐一作答:
① 隐藏类机制:V8 不给每个对象存属性名→偏移的映射表,而是让共享同一属性结构的对象借用模板(隐藏类)。const p = new Point(1,2) 所有实例共享一个隐藏类 → x 在偏移 12、y 在偏移 20 → TurboFan 可以把 p.x 编译为 mov rax, [p+12]。
② 属性三态:少量属性 → 嵌在对象体内(in-object,最快);属性多了溢出到外部 Properties 数组(Fast,较快);delete 或动态增删 → 退化为字典(Slow,哈希查找,最慢)。退化不可逆。
③ Elements Kinds:[1,2,3] 是 PACKED_SMI —— 直接存整数值。[1.5,2,3] 退化到 PACKED_DOUBLE —— 存浮点值,多占空间。[1,"hello",{}] 退化到 PACKED_ELEMENTS —— 存指针。加了空洞 → 加上 HOLEY 前缀,每次访问要判洞。这六态是有向无环图,只能向下退、不能向上恢复。
④ 新生代 GC:基于分代假说——大多数对象朝生暮死。Scavenge 算法只复制活对象、直接丢弃死对象,开销正比于存活数而不是总数——所以它极快(1~5ms)。
⑤ 老生代 GC:三色标记法突破循环引用——从根集出发,能到达的都是活的,到达不了的全是垃圾。增量/并发标记让 GC 和 JS 交替或并行执行——用户看见的是几个 5ms 小抖,而不是一个 200ms 大冻结。
⑥ 编码启示:constructor 里按固定顺序一次赋完 → 所有实例共享隐藏类 → 属性访问走 in-object 最快路径。避免 delete、避免 HOLEY 数组、避免动态增减属性。
# 10.2 一个对象从 ne
时刻 0: const obj = new Point(1, 2)
new → V8 在新生代 From-Space 分配内存
→ 分配 JSObject 头部(Map指针 + Properties指针 + Elements指针)
→ 前 N 个 in-object 槽直接嵌在体内
→ 执行 constructor:this.x = 1 → 隐藏类更新为 Map₁ (x@12)
this.y = 2 → 隐藏类更新为 Map₂ (x@12, y@20)
→ obj 当前隐藏类:Map₂
时刻 1: obj 被多次使用(在函数局部范围内)
→ 读取 obj.x → V8 查 Map₂ → x @ 偏移12 → mov rax, [obj+12]
→ 纯指针加法,耗时 ~0.5ns
→ 其他局部变量陆续死亡,obj 还在栈上
时刻 2: 函数返回,obj 不再被栈引用
→ obj 变成"不可达" → 等待 GC
时刻 3: 新生代 From-Space 满了 → 触发 Scavenge GC
→ GC 从根集出发扫描
→ obj 已不可达 → 不被复制到 To-Space
→ From-Space 清空 → obj 的内存被回收
→ 总耗时 < 1ms ✓
如果 obj 被闭包或全局引用捕获了(没有死亡):
时刻 2': obj 被一个全局 Map 引用 → 一直可达
时刻 3': 新生代 Scavenge → obj 被复制到 To-Space(age = 1)
时刻 4': 又一次 Scavenge → obj 被复制(age = 2)
→ 🎉 晋升!obj 从新生代搬到老生代
时刻 100': 老生代满了 → 触发 Mark-Compact GC
→ 三色标记:从根集出发,obj 仍然被全局 Map 引用 → obj 被标记为黑色(存活)
→ 清扫:所有白色对象被回收
→ 压缩:obj 可能被搬动到老生代的另一块连续区域
→ 总耗时 ~几十ms,obj 继续存活
时刻 ??': 全局 Map 被 delete → obj 的最后一个引用消失
→ 下一次老生代 Mark-Compact → obj 是白色 → 被清扫回收
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 10.3 设计哲学回扣
从对象被分配、被 GC、到最终回收这条完整生命周期中,提炼三条设计哲学:
哲学一·「模板复用的妥协」——隐藏类就是 JS 的"编译期类型"
JS 没有静态类型系统。V8 无法在"编译期"知道对象有哪些属性。但它可以在 "首次运行期" 观察到属性被逐一添加的顺序,并将这种"观察结果"固化为隐藏类。后续创建的具有相同属性顺序的对象直接复用同一个隐藏类——这些对象获得了接近静态类型语言的属性访问速度。这是一种"用运行时的观察替代编译期的定义"的原创设计——它不是免费的,但性价比极高。
哲学二·「年老的值得保护」——分代回收就是对时间的分级定价
新生代 GC 几乎免费(仅处理少数活对象)——它利用的是"大部分对象活不过两轮 GC"的统计规律。老生代 GC 成本较高——但老生代 GC 频率低得多。这种按年龄分级定价的策略让 GC 的总开销维持在可接受范围内——而不是每次都要为所有对象付出相同的代价。分代是对"对象不同生命阶段的管理成本差异"的工程化表达。
哲学三·「不追踪逆转」——单向退化是性能的保险绳
无论是 Elements Kinds(SMI→DOUBLE→ELEMENTS)还是属性存储三态(in-object→Fast→Dictionary),退化都是严格单向的。V8 故意不去追踪"能否恢复"——因为追踪逆转的开销(判断"现在所有元素都是整数了吗?")大于退化的代价。不是所有"理论上可逆"的事都值得用代码实现——考虑到运行时开销,有时候接受退化比尝试逆转更经济。这是一个深刻的设计决策:放弃"理论最优"换取"工程最优"。
# 10.4 速查表:隐藏类变迁
隐藏类友好写法检查清单:
| 检查项 | 友好写法 | 不友好写法 | 影响 |
|---|---|---|---|
| 属性赋值路径 | constructor 中按固定顺序一次性赋完 | 条件分支动态创建属性 | 隐藏类分裂→多态→慢 |
delete | 赋值为 null/undefined | delete obj.prop | 触发 Dictionary Mode |
| 属性顺序一致性 | 所有实例的 constructor 里同一顺序 | A 实例先 x 后 y,B 实例先 y 后 x | 不同隐藏类 |
Elements Kinds 退化矩阵:
| 操作 | 原状态 | 新状态 |
|---|---|---|
[1,2,3].push(1.5) | PACKED_SMI | PACKED_DOUBLE |
[1,2,3].push('x') | PACKED_SMI | PACKED_ELEMENTS |
delete arr[1] | 任意 PACKED | 对应 HOLEY |
new Array(10000) | — | HOLEY_SMI |
GC 策略速查:
| 区域 | 算法 | 频率 | 单次耗时 | 何时触发 |
|---|---|---|---|---|
| 新生代 | Scavenge(复制) | 高频 (每秒多次) | 1~5ms | From-Space 满 |
| 老生代 | Mark-Compact(标记-整理) | 低频 (每分钟几次) | 10~100ms | 老生代满 |
| 增量/并发标记 | 切分标记为小步 | 持续 | 每步 ~5ms | 老生代标记阶段 |
下一步:对象怎么存、怎么回收已经完全清楚了。那么 JS 的「类型系统」——为什么 0.1 + 0.2 !== 0.3?[] == ![] 为什么是 true?进入 03.类型隐式转换精算。