前言
垃圾回收是很多高级语言都有的特性。
对于 JS 来说,原始类型储存在栈内存,栈内存由操作系统管理;对象类型储存在堆内存,堆内存由引擎管理。这就涉及到 V8 的垃圾回收了。
V8 的垃圾回收器项目代号为 Orinoco。
弱分代假说 The Weak Generational Hypothesis 认为,大多数对象只会存活很短的时间。根据这个理论,V8 把需要执行 GC 的内存空间分为新生代和老生代两部分,分别放置生命周期长度不同的对象并使用不同的 GC 策略,从而显著提升 GC 效率。这种分类的做法叫做分代堆布局 Generational Layout。
V8 的内存构成:
- 新生代内存区,Young Generation 或 New Space。大多数对象都在这里
- 老生代内存区,Old Generation 或 Old Space。常驻内存的对象在这里
- 大对象区,Large Object Space。顾名思义。GC 不会回收这部分内存
- 代码区,Code Space。唯一拥有执行权限的内存
- Map 区,Map Space。TODO: Cell 和 Map
每个区域都由内存页构成,内存页是 V8 申请内存的最小单位,也是垃圾回收的单位。除了大对象区,其他区域的内存页大小都是 1 MB。
内存的大小是有限制的。64 位环境下,新生代内存默认最大值 32 MB,老生代内存默认最大值 1.4 GB。32 位环境减半。
限制最大值的原因,一个是浏览器端一般来说不会使用很大内存,另一个是太大会影响 GC 的效率和页面响应。因为 GC 时会阻塞 JS 执行,而 1.4 GB 完整 GC 一次需要 1s 以上。这种现象叫做全停顿 Stop-The-World。
咋!瓦鲁多!ときょとまれ!!
之所以会阻塞,是因为 GC 和程序都会修改对象,如果无法保证程序不会修改正在 GC 的对象,就需要暂停代码的运行,使得对象能够被顺利回收。
V8 并不是按照最大值一次性申请所有内存空间,而是在当前内存满了之后再申请更大的空间。《V8的内存管理与垃圾回收(一)》这篇文章在 Node 上做了测试。
Node 也是用的 V8 引擎,所以用 Node 操作大文件的时候要注意尺寸问题。
V8 的垃圾回收器包括两部分:
- 副垃圾回收器,Minor Garbage Collector,用于新生代
- 主垃圾回收器,Major Garbage Collector,用于整个堆,包括新生代和老生代
Major GC 作用于整个堆,这个说法毋庸置疑,是官方博客的文章《Trash talk: the Orinoco garbage collector》给出的。但是并没有看到什么文章专门提及 Major GC 在新生代的的使用,下面就不提 Major GC 和新生代的关系了。
V8 的垃圾回收通常是在需要给对象分配内存,而剩下的内存不够时触发,也有时候是达到内存使用量的阈值时触发。
新生代
新生代内存分为两个相等大小的 Semi-Space,分别称为 From-Space 和 To-Space。其中 From-Space 是真正使用的内存,To-Space 是空闲的,GC 的时候才会用到。也就是说,实际利用起来的新生代内存只占一半。
From-Space 又分为 Nursery 和 Intermediate 两块区域。对象第一次分配内存时在 Nursery,经历过一次 GC 后转移到 Intermediate。
Minor GC 使用了清道夫算法 Scavenge,其实现又使用了 Cheney 算法,流程如下:
- 广度优先遍历 From-Space 中的对象,把存活的对象复制到 To-Space
- 遍历完成后,清空 From-Space
- From-Space 和 To-Space 角色互换
复制后的对象在 To-Space 中占用的内存空间是连续的,不会出现碎片问题。
《了解 V8 内存管理》和《V8的内存管理与垃圾回收(一)》里介绍了详细的流程。
新生代的 GC 比较频繁。
新生代的对象转移到老生代称为晋升 Promote。晋升的情况有两种:
- 经过一次 GC 还存活的对象,即 Intermediate 中的
- 对象复制到 To-Space 时,To-Space 的空间已经使用 25% 以上
《V8源码-内存管理》提到了 25% 这个说法的源码。不过翻了翻最新的源码(的注释),heap.h 中只提了第一种情况
老生代
老生代内存也分为两部分:
- 指针区,Old Pointer Space。如果对象可能有指向其他对象的指针,保存在这里。大多数晋升的对象都在这里
- 数据区,Old Data Space。只保存原始对象,没有指向其他对象的指针
Major GC 使用了两种算法:
- 标记清除 Mark-Sweep
- 标记整理 Mark-Compact
两种算法一般合起来称呼,有 Mark-Sweep-Compact Algorithm 或 Full Mark-Compact 等名称。
Major GC 的流程有三步:
- Marking 标记
- Sweeping 清除
- Compaction/Compacting 整理
Marking 标记
标记就是找到所有可访问对象的过程。两种算法的标记流程是一样的。
使用了三色标记法:
- 值为 00,白色,未被引用
- 值为 10,灰色,被引用,但是其引用的对象还没有遍历完
- 值为 11,黑色,被引用,并且其引用的对象已经遍历完成
首先把所有对象标记为白色,然后从根集 Root Set(执行栈和全局对象)开始,以深度优先遍历的方式为访问到的对象添加灰/黑标记。
Sweeping 清除
清除被标记为白色的对象。这会造成内存空间不连续的情况。
清除的本质是将内存的地址标记为空闲,代码层面上是把内存地址保存到一个叫 free-list 的数据结构中。
Compaction/Compacting 整理
修改仍然存活的对象的内存地址,将不同内存页上的对象整合到一起,使得内存空间紧凑有序。这是比较消耗性能的操作。
对于何时执行/不执行 Compaction,有这么几种说法:
- 只有当剩余空间不足以放置新晋升的对象时才会进行。见《V8的内存管理与垃圾回收(二)》
- 只对高度分散的内存页执行,其他的内存页执行 Sweeping。见《「译」Orinoco: V8的垃圾回收器》
- 当一个内存页上的对象被引用次数很多时会跳过,因为会影响性能。见《V8 —— 你需要知道的垃圾回收机制》
写屏障 Write-Barrier
对象的引用可能存在于新生代和老生代之间,V8 通过写屏障 Write Barrier 维护一份引用列表,这样就不需要去体积巨大的老生代内存里查找了。
其他内存区域
其余的三个区域大对象区、代码区、Map 区都属于老生代,使用老生代的 GC 算法。见《解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法》。
在很多 V8 内存结构图里,这几个区域跟老生代是分开画的,比如《Visualizing memory management in V8 Engine (JavaScript, NodeJS, Deno, WebAssembly)》这篇文章里的
V8 的优化
除了上面基本的 GC,V8 还做了一些额外的优化。
如上所说,GC 有 Marking 和 Sweeping 两个阶段。关于下面提到的 Parallel/Incremental/Concurrent 三种优化方案,究竟是发生在 Marking 阶段还是 Sweeping 阶段,还是说两个阶段都有,暂时还没搞清楚。
按照《解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法》的说法,Incremental GC 发生在 Marking 阶段,也叫 Incremental Marking;Parallel GC/Concurrent GC 发生在 Sweeping 阶段,又叫做 Parallel Sweeping 和 Concurrent Sweeping。
而按照《Concurrent marking in V8》的说法,Marking 阶段也存在 Parallel 和 Concurrent。
这里就先以前者为准了。
增量标记 Incremental Marking
每当分配了一定量的内存或触发了一定次数的写屏障后,就暂停一下程序,做几毫秒到几十毫秒的 Marking,然后恢复程序的运行。
经过这样断断续续的 Marking,等到需要 Sweep 的时候大部分内存都已经扫描过了,就不需要再从头扫描一遍了。
并行/并发清理 Parallel/Concurrent Sweeping
对于已经确定要回收的对象,可以使用新的线程执行 Sweeping,不必担心与主线程有冲突,这就是 Concurrent Sweeping。
开启多个线程同时 Sweeping,就是 Parallel Sweeping。
其他
V8 4.x 引入了 Pretenuring 机制。当某些函数创建的对象经常晋升到老生代,或者说有很高的存活率 Survival Rate 时,这些函数之后创建的对象会直接分配到老生代。
其他 GC 算法
引用计数
原理:对象被引用时 +1,被取消引用 -1。回收没有被引用的对象。
当出现循环引用时,引用计数算法存在问题:
// 没有循环引用的场景
// f1 执行过后,虽然 b 被 a 引用,但是 a 没有被引用,所以 a 连同 b 一起被回收了
function f1() {
var a = {}
var b = {}
a.b = b
}
// 存在循环引用的场景
// f2 执行过后,a 和 b 都仍然存在引用,所以都不能被回收
function f2() {
var a = {}
var b = {}
a.b = b
b.a = a
}
而使用标记清除算法时,因为 a 和 b 无法通过全局对象访问到,所以被回收,就没有上面的问题了。
参考链接
- 浏览器中的垃圾回收与内存泄漏
- V8垃圾回收GC
- v8 内存浅析 - 石墨文档
- 内存管理 - MDN
- V8 —— 你需要知道的垃圾回收机制
- 一文读懂V8垃圾回收机制——新生代Scavenge、老生代Mark-Sweep和Mark-Compact
- V8的内存管理与垃圾回收(一)
- V8的内存管理与垃圾回收(二)
- V8 Memory Structure
- 了解 V8 内存管理
- Orinoco: young generation garbage collection - V8 官网
- 「译」Orinoco: V8的垃圾回收器
- 【上面文章的原文】Trash Talk - V8 官网
- 弱分代假说 The Weak Generational Hypothesis
- V8源码-内存管理
- 解读 V8 GC Log(一): Node.js 应用背景与 GC 基础知识
- 解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法