学习目标

理解:

  • JavaScript 为什么需要 GC
  • GC 如何判断对象是否存活
  • GC 如何避免长时间卡顿
  • GC 如何与 JavaScript 一起运行
  • 现代 V8 GC 的整体设计思想

一、为什么需要 GC

JavaScript 没有:

1
2
delete obj;
free(ptr);

对象什么时候释放,由 V8 Garbage Collector 自动决定。

GC 的目标:

回收已经无法访问的对象,释放 Heap 内存。


二、JavaScript 内存模型

Stack(栈)

存放:

  • 局部变量
  • 参数
  • 返回地址
  • 对象引用(Reference)

例如:

1
2
3
function test() {
let a = {};
}

运行时:

1
2
3
4
5
6
Stack

a
│
▼
Heap Object

Stack 保存的是引用。

不是对象。


Heap(堆)

真正对象存放的位置:

1
2
3
4
5
6
7
{}
[]
Function
Map
Set
Date
DOM Wrapper

GC 回收的是 Heap。


三、GC 如何判断对象是否存活

现代 GC:

不是:

Reference Counting(引用计数)

而是:

Root Reachability(Root 可达性)


Root

GC 从 Root 开始遍历对象图。

主要 Root:

1
2
3
4
5
Global Object

Call Stack

当前执行上下文

例如:

1
let a = {};
1
2
3
4
5
6
7
8
9
Root

↓

a

↓

{}

对象可达。

不会回收。


函数结束:

1
2
3
4
5
function test() {
let a = {};
}

test();
1
2
3
4
5
Root

×

{}

对象不可达。

GC 可回收。


四、循环引用不会导致泄漏

例如:

1
2
3
4
5
let a = {};
let b = {};

a.child = b;
b.parent = a;

对象图:

1
2
3
4
5
6
7
A

↓

B

↑

只要:

1
2
3
4
5
Root

×

A

整个环都会释放。

原因:

现代 GC 使用 Reachability。

不是 Reference Count。


五、Mark-Sweep

GC 分两步:

Mark

从 Root 开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
Root

↓

A

↓

B

↓

C

标记:

1
2
3
4
5
A ✓

B ✓

C ✓

Sweep

所有没有标记:

1
2
3
4
5
D

E

F

全部释放。


六、为什么需要 Stop-The-World(STW)

假设:

GC:

1
扫描 A

同时:

JS:

1
A.child = C;

对象图发生变化:

1
2
3
4
5
6
7
8
9
Root

↓

A

↓

C

GC:

可能已经扫描完 A。

不会再回来。

于是:

C 被漏掉。

因此:

传统 GC:

1
2
3
4
5
6
7
8
9
暂停 JavaScript

↓

扫描对象图

↓

恢复 JavaScript

目的:

保证对象图一致。


七、Incremental Marking(增量标记)

传统:

1
2
3
4
5
Mark

↓

300ms

页面卡顿。

现代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
JS

↓

Mark 一点

↓

JS

↓

Mark 一点

↓

JS

↓

Mark 一点

一次大暂停。

拆成很多小暂停。


八、Tri-color Marking(三色标记)

对象分三种状态。


White(白)

1
2
3
GC 还没有发现。

当前认为可能是垃圾。

Gray(灰)

1
2
3
4
5
已经发现。

但是:

引用的对象还没有扫描完成。

例如:

1
2
3
4
5
6
7
8
9
Root

↓

A(Gray)

↓

B(White)

Black(黑)

1
2
3
自己扫描完成。

所有引用对象也扫描完成。

例如:

1
2
3
4
5
6
7
8
9
Root

↓

A(Black)

↓

B(Gray)

最终:

1
2
3
4
5
6
7
8
9
Root

↓

A(Black)

↓

B(Black)

九、为什么需要 Write Barrier

问题:

1
2
3
4
5
A(Black)

↓

扫描完成

JS:

1
A.child = C;

变成:

1
2
3
4
5
Black

↓

White

GC:

不会重新扫描 Black。

于是:

C 被漏标。


Write Barrier

执行:

1
A.child = C;

内部概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
修改引用

↓

Write Barrier

↓

通知 GC:

对象图发生变化

↓

GC 加入 WorkList

↓

以后继续扫描

注意:

Write Barrier:

不是负责扫描。

它只是:

通知 GC。


十、GC 调度

GC:

不是:

1
2
3
4
5
Event Loop

↓

GC Task

GC 并不是普通 Task。

而是:

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
执行 JS

↓

Safe Point

↓

检查是否需要 GC

↓

需要

↓

暂停 JS

↓

GC

↓

恢复 JS

所以:

GC 更像:

Runtime Maintenance

不是:

Business Task


十一、Safe Point(安全点)

Safe Point:

不是:

  • 操作系统提供
  • 浏览器提供
  • Event Loop 提供

而是:

V8 编译器主动插入的检查点。


例如:

源码:

1
2
3
4
5
6
7
8
9
function foo() {

a();

b();

c();

}

概念模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {

a();

CheckSafepoint();

b();

CheckSafepoint();

c();

CheckSafepoint();

}

注意:

这里不是:

1
GC();

而是:

1
CheckSafepoint();

十二、CheckSafepoint

作用:

1
2
3
4
5
6
7
8
9
if (gc_requested) {

PauseJavaScript();

RunGC();

ResumeJavaScript();

}

如果:

1
gc_requested == false

立即返回:

1
继续执行 JS

绝大多数时候:

1
2
3
4
5
CheckSafepoint()

↓

什么都不会发生

十三、为什么需要 Safe Point

GC 必须知道:

1
2
3
4
5
6
7
8
9
Stack

↓

寄存器

↓

局部变量

哪些对象属于 Root。

如果:

CPU:

1
2
3
4
5
对象写了一半

寄存器更新一半

引用关系还没建立

GC:

无法得到一致对象图。

因此:

只能停在:

V8 提前设计好的位置。


十四、GC Scheduler(GC 调度器)

现代 GC:

不是:

1
2
3
每100ms

GC一次

而是:

根据:

1
2
3
4
5
6
7
8
9
10
11
12
13
Heap 使用率

↓

内存压力

↓

增量标记是否完成

↓

后台 GC 状态

决定:

1
gc_requested = true

等待:

1
Safe Point

开始 GC。


十五、GC 线程模型

早期

1
2
3
4
5
6
7
8
9
Main Thread

↓

Mark

↓

Sweep

全部主线程完成。


现代 V8

主线程:

1
2
3
4
5
6
7
8
9
JavaScript

+

Safe Point 检查

+

部分 GC

后台线程:

1
2
3
4
5
Concurrent Mark

Concurrent Sweep

Concurrent Compact

多个线程协同工作。

涉及对象图一致性的阶段:

仍然需要:

短暂 STW。


十六、Write Barrier 与 Safe Point 的区别

Write Barrier

发生:

1
obj.child = other;

作用:

1
2
3
4
5
6
7
8
9
对象图发生变化

↓

通知 GC

↓

避免漏标

解决:

对象图一致性。


Safe Point

发生:

1
执行 JavaScript 过程中

作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
检查:

GC 是否请求暂停?

↓

如果需要

↓

暂停 JS

↓

GC

↓

恢复 JS

解决:

什么时候可以安全暂停 JavaScript。


十七、现代 GC 的完整流程

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
           JS

│

执行 JavaScript

│

Safe Point

│

CheckSafepoint()

│

gc_requested ?

│ │

│否 │是

▼ ▼

继续执行 Pause JS

│

Incremental GC

│

Mark / Sweep / Compact

│

Resume JS

│

继续执行

十八、当前形成的完整知识体系

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
JS Object
│
▼
Heap
│
▼
Object Graph
│
▼
Root
│
▼
Reachability
│
▼
Mark-Sweep
│
▼
Stop-The-World
│
▼
Incremental Marking
│
▼
Tri-color Marking
│
▼
Write Barrier
│
▼
Safe Point
│
▼
CheckSafepoint
│
▼
GC Scheduler
│
▼
Concurrent GC

今天真正掌握的知识

GC 基础

  • JavaScript 为什么需要 GC
  • Stack 与 Heap 的区别
  • Root 是什么
  • Reachability 如何判断对象存活
  • 为什么循环引用不会泄漏

GC 算法

  • Mark-Sweep
  • Stop-The-World
  • Incremental Marking
  • Tri-color Marking

GC 与 JavaScript 协作

  • Write Barrier
  • Safe Point
  • CheckSafepoint
  • GC Scheduler
  • 主线程 GC
  • 后台并发 GC

总结

①

GC 判断对象是否存活,不看引用计数,而看 Root Reachability(可达性)。


②

Write Barrier 不负责回收对象,它负责通知 GC:”对象图发生变化了。”


③

Safe Point 不执行 GC,它只是检查:”现在是否可以安全暂停并运行 GC?”


④

现代 V8 的 GC 是一个持续运行、增量推进、并发协作的运行时系统,而不是一个偶尔执行的大任务。