编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
        • 1. 案例与疑问引入
          • 1.1 同一构造函数的
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 V8 堆区全景图
          • 2.2 为什么要有新生代
        • 3. 隐藏类与Map
          • 3.1 隐藏类的变迁链规则
          • 3.2 属性顺序实验
          • 3.3 隐藏类变迁的不可
        • 4. 属性存储三态
          • 4.1 In-object
          • 4.2 Fast 属性
          • 4.3 Slow/Dic
          • 4.4 三态切换的条件与
        • 5. 元素六态详解
          • 5.1 PACKED_S
          • 5.2 HOLEY_SMI
          • 5.3 不可逆的退化路径
        • 6. 新生代 Scav
          • 6.1 Cheney
          • 6.2 晋升条件(两轮存活
          • 6.3 写屏障(Write
        • 7. 老生代回收详解
          • 7.1 三色标记法全流程
          • 7.2 增量标记与并发标记
          • 7.3 压缩(Compa
        • 8. 并行回收Oilp
          • 8.1 V8 10.x
          • 8.2 Oilpan
        • 9. 编码启示与反模式
          • 9.1 对隐藏类友好的代
          • 9.2 导致 Eleme
          • 9.3 短命对象 vs
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个对象从 ne
          • 10.3 设计哲学回扣
          • 10.4 速查表:隐藏类变迁
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

隐藏类与回收机制

# 02.隐藏类与分代垃圾回收

📍 上接第 01 篇《引擎执行管线全景》。我们知道了代码怎么被执行。本文回答:执行过程中创建的对象,在 V8 堆中如何存放、如何查找、如何被回收。

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 同一构造函数的
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 V8 堆区全景图
    • 2.2 为什么要有新生代
  • 3. 隐藏类与Map
    • 3.1 隐藏类的变迁链规则
    • 3.2 属性顺序实验
    • 3.3 隐藏类变迁的不可
  • 4. 属性存储三态
    • 4.1 In-object
    • 4.2 Fast 属性
    • 4.3 Slow/Dic
    • 4.4 三态切换的条件与
  • 5. 元素六态详解
    • 5.1 PACKED_S
    • 5.2 HOLEY_SMI
    • 5.3 不可逆的退化路径
  • 6. 新生代 Scav
    • 6.1 Cheney
    • 6.2 晋升条件(两轮存活
    • 6.3 写屏障(Write
  • 7. 老生代回收详解
    • 7.1 三色标记法全流程
    • 7.2 增量标记与并发标记
    • 7.3 压缩(Compa
  • 8. 并行回收Oilp
    • 8.1 V8 10.x
    • 8.2 Oilpan
  • 9. 编码启示与反模式
    • 9.1 对隐藏类友好的代
    • 9.2 导致 Eleme
    • 9.3 短命对象 vs
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个对象从 ne
    • 10.3 设计哲学回扣
    • 10.4 速查表:隐藏类变迁

# 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%!
1
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 倍!
1
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 的对象模型追问以下六个问题——每个问题对应中间的一章:

  1. ① 隐藏类机制:V8 为什么不给每个对象存一份属性名→偏移的映射?
  2. ② 属性存储三态:什么是 in-object / fast / slow?在什么条件下对象会在三者之间切换?
  3. ③ Elements Kinds:数组比对象更复杂——[1, 2, 3] 和 [1.5, 2, 3] 内部结构完全不同?为什么退化不可逆?
  4. ④ 新生代 GC:为什么不直接回收所有垃圾,而要分新生代和老生代?
  5. ⑤ 老生代 GC:三色标记法为什么比引用计数更"聪明"?并发标记期间 JS 还能继续跑吗?
  6. ⑥ 编码启示:从上面这些原理出发,什么样的代码对 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     │   机器码存这里        │
│  │  产出的机器码                        │                    │
│  └─────────────────────────────────────┘                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘
1
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% 在函数返回后就没人引用了——它们"朝生暮死"
1
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₁
            └─────────────────┘
1
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,无需查找
1
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 倍
1
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 的偏移位置互换了
1
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 倍
1
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
1
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: ...} │   进入字典模式
                │ (哈希表存储)               │
                └─────────────────────────┘
1
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();
1
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     │  │      &amp;obj + Map.offset[prop]
  │  │ ...                      │  │      一次指针加法,零间接访问
  │  └───────────────────────┘  │
  └─────────────────────────────┘
1
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
1
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] 偏移
1
2
3
4
5
6
7
8
9
10
11

比 in-object 多一次指针追踪,但仍然是 O(1) 偏移访问——比字典的哈希查找快得多。

# 4.3 Slow/Dic

当对象经历以下任一操作时,V8 放弃 Fast 模式,转为字典模式:

  1. delete 操作(见 §3.3)
  2. 属性添加顺序不一致(同一个构造函数的实例走了不同的属性路径)
  3. 大量属性的动态添加(超出 V8 为 Fast 模式预设的阈值)

字典模式内部是一个哈希表:

Dictionary(哈希表):
  ┌──────┬──────┬──────┬──────┬──────┐
  │ name │ hash │ value│ name │ hash │ ...  ← 属性名→值 的哈希映射
  └──────┴──────┴──────┴──────┴──────┘
  
  读取 obj.x:
  1) 哈希 "x" → 定位桶
  2) 检查桶中 name === "x"(可能发生哈希冲突)
  3) 读 value
1
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 倍
1
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=哈希查找)  │   ⚠ 此退化不可逆    │
│          └──────────────┘                   │
│                                             │
└─────────────────────────────────────────────┘
1
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"]
1
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  ← 回不去了
1
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 │  ← 每个槽存的是指向堆对象的指针
  └───────┴───────┴───────┘   通用类型(字符串/对象等)
1
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](无需额外检查)
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 倍
1
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) 预分配、或赋值到越界位置
1
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 双重退化
1
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)   │        │
│  └──────────────┴──────────────────┘        │
│                                             │
└─────────────────────────────────────────────┘
1
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 的步骤:

  1. 从根集(roots)出发:根集包括全局对象、当前执行栈上的局部变量、所有活跃的闭包引用
  2. 将根集直接引用的对象从 From-Space 复制到 To-Space
  3. 递归扫描复制到 To-Space 的对象,检查它们引用的其他对象——如果是 From-Space 中的对象,也复制到 To-Space
  4. 全部扫描完成后,From-Space 被一次性清空(所有没被复制过来的对象 = 垃圾)
  5. From 和 To 交换身份,下一次 GC 时重复此过程

为什么这么快?

因为 Scavenge 的开销正比于"存活对象数量",而不是"总对象数量"。在新生代中,根据分代假说,绝大部分对象是死的——GC 只需要处理那一小撮活着的对象:

假设 From-Space 有 10000 个对象,其中 9500 个已死亡:
  1) 遍历根集,找到 50 个被根直接引用的活对象
  2) 复制这 50 个对象到 To-Space
  3) 递归扫描 50 个对象 → 又找到 450 个活对象 → 也复制
  4) 总共复制了 500 个活对象(每个对象几百字节) → 总复制量 &lt; 1MB
  5) 耗时 ≈ 1~3ms
  
  如果算法不是复制而是"标记-清扫"(老生代的做法),
  它必须先遍历所有 10000 个对象来"标记谁说活" → 耗时 3~5 倍
1
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 处理
1
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 (新生代)
}
1
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 在老生代 &amp;&amp; newObj 在新生代) {
         记录这条引用:记住 oldObj 引用了 newObj
     }
  
  3) 记录的引用被存入一个"跨代引用表"(Remembered Set)
  
  4) 新生代 GC 时,除了根集,额外扫描这个跨代引用表
     → 确保被老生代引用的新生代对象不会被误回收
1
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 负责维护这个不变量)
1
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 都是白色 → 被回收 ✅
}
1
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" 的体验好得多
1
2
3
4
5
6
7
8

实现的关键:在 JS 执行期间,如果有代码修改了已标记的对象(比如把刚标记为黑色的对象改成引用了一个白色对象),写屏障会捕获这个变更——确保"黑色永远不引用白色"的不变量不被破坏。

(2)并发标记(Concurrent Marking)

更进一步,V8 让标记工作在单独的线程中运行,与 JS 主线程真正并行:

┌─────────────────────────────────────────────┐
│  主线程 (JS)  :  执行代码   执行代码   执行代码    │
│                                              │
│  并发线程 (GC):  标记...  标记...  标记...  标记...  │
│                                              │
│  关键:并发线程在标记时"读"堆内存,               │
│        JS 线程在"写"堆内存                     │
│        → 写屏障协调两者,确保 GC 线程看到一致的内存视图  │
└─────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

最终阶段——STW(Stop-The-World):增量/并发标记完成后,仍有短暂的"同步暂停"(清扫 + 压缩)。V8 优化后,这个 STW 阶段通常只有几毫秒到十几毫秒(而非几百毫秒)。

# 7.3 压缩(Compa

疑惑:为什么老生代 GC 不光"清扫"(释放死对象),还要做"压缩"(搬动活对象)?

论证:标记-清扫后,老生代内部会变成"瑞士奶酪"——活得久的对象和死对象交替出现,释放的死对象空间形成空洞:

清扫后的老生代:
┌──┬──┬──┬──┬──┬──┬──┬──┐
│活│空│活│空│空│活│空│活│  ← 碎片化:无法分配大的连续对象
└──┴──┴──┴──┴──┴──┴──┴──┘

压缩后的老生代:
┌──┬──┬──┬──┬──────────┐
│活│活│活│活│  空闲     │  ← 活对象被紧凑排列,后面是连续的大空闲区
└──┴──┴──┴──┴──────────┘
1
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 任务可并行度)                │
└─────────────────────────────────────────────┘
1
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 触发时机                  │
│                                              │
└──────────────────────────────────────────────┘
1
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 或影响编译器的内联决策
  }
}
1
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 属性
}
1
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;
1
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';
1
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
1
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);
  }
}
1
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 的内存被回收
    → 总耗时 &lt; 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 是白色 → 被清扫回收
1
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.类型隐式转换精算。

上次更新: 2026/06/16, 12:36:20
引擎解析编译执行
类型隐式转换精算

← 引擎解析编译执行 类型隐式转换精算→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式