V8 的内存管理和垃圾回收

2,717 阅读9分钟

前言

垃圾回收是很多高级语言都有的特性。

对于 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 算法,流程如下:

  1. 广度优先遍历 From-Space 中的对象,把存活的对象复制到 To-Space
  2. 遍历完成后,清空 From-Space
  3. 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,有这么几种说法:

写屏障 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 无法通过全局对象访问到,所以被回收,就没有上面的问题了。

参考链接