v8 是如何进行垃圾回收的

本文最后更新于:2022年4月22日 上午

v8 是如何进行垃圾回收的, v8 为了高效的进行回收做了哪方面的努力?

JavaScript 中我们是不需要手动来管理内存的,v8 会自动帮我们做内存管理,确定不需要哪些数据,并将其清除,这个过程叫做 垃圾回收(GC)

v8 使用了 分代垃圾回收器 它会将内存分为 新生代内存老生代内存

当我们先创建一个对象时,默认都会分配在新生代内存,而全局对象等,会在老生代内存

新生代内存

存活时间较短的对象被称为新生代内存 ,新生代内存最高只有 16M

Scavenge算法 (早期算法)

新生代内存采用 Scavenge算法 来进行垃圾回收

  • Scavenge 算法是一种采用复制的方式实现的垃圾回收算法,它会将新生代内存一分为二, 只有一边是在使用的, 另外一边是空的, 使用的一边称为 from, 空的一边称为to

  • 当进行垃圾回收的时候,会将 from 中存活的对象复制到 to 空间,不存活的对象则将被释放,新生代内存整个垃圾回收机制就是对象的复制

  • 该算法是典型的利用空间换时间算法 ,无法进行大规模的垃圾回收机制,它比较适合新生代内存,因为新生代内存的生命周期较短

  • 当一个对象经历过一次复制(Scavenge)之后,则会认为该对象是生命周期较长的对象,会将该对象添加到老生代内存,这个过程被称之为 晋升

晋升的主要条件有两个

  • 如果该对象已经 经历过内存回收, 则会直接晋升到老生代内存

  • 如果 to 的空间使用 超过了25% , 则这个对象会直接晋升到老生代空间, 设置 25% 的原因是经历完这次回收后, 这个 to 空间会变成 from 空间, 接下来的内存分配会在这个空间进行


老生代内存

存活时间长的内存被称为老生代内存
新生代内存采用 标记清除 (mark-sweep) 算法标记整理 (mark-compact) 算法 配合来进行垃圾回收

引用计数算法

在早期的时候,浏览器也使用过引用计数的算法来进行gc,但是这个方法会有一个弊端,就是当对象循环引用时,容易造成无法回收的情况,2012年后浏览器引擎都不采用这种算法了

标记清除 (mark-sweep) 算法

  • 标记清除 (mark-sweep) 分为 标记清除 两个阶段, 标记清除 (mark-sweep) 会在 标记阶段 遍历堆中所有存活的对象, 在随后的 清除 阶段 只会清除未被标记的对象, 因为在老生代内存中, 死亡的对象只占小部分, 这也是内存回收高效的原因

  • 标记清除 (mark-sweep) 还存在一个问题, 在进行完一次清除回收后, 会出现内存空间不连续的情况, 如下图所示, 这就会对后续的内存分配出现很大的问题,这也是引入 标记整理 (mark-compact) 的原因

  • 标记阶段会从 root (全局对象 和 当前活动函数) 开始标记,先将 root标记为活动的,然后遍历 (可以理解为 图遍历) 对象的指针来标记更多的活动对象,这个标记的时间取决于活动对象的数量,对于大型 web 程序,这个耗时可能需要花费 100ms 以上

灰色块为存活对象, 空白块为被清除后的内存空间, 可以看到会出现内存空间不连续的情况

标记整理 (mark-compact) 算法

标记整理后存活对象会往一端移动

  • 标记整理 (mark-compact) 会在一次 标记清除 (mark-sweep) 后,进行一次标记整理, 它会把存活的对象往一端移动

  • 但是由于 标记整理 (mark-compact) 会移动对象, 所有它的 执行过程会比较耗时, 老生代内存回收主要使用 标记清除 (mark-sweep), 如果有对象从新生代空间晋升来后内存不够需要进行空间分配时 才会使用 标记整理 (mark-compact)

增量标记

由于 js 是单线程的,所以在gc的过程中,如果这一次 gc 的时间过长,浏览器是会造成卡顿的,这个现象称为 全停顿(stop-the-world)

所以为了解决这个问题,v8 引入了 增量标记,即原来要遍历堆里面所有的对象来打上标记,变成只遍历一部分对象,然后把控制权交还给主线程,等到空闲的时候再进行标记

如果老年代几乎已满,并且估计为任务提供的截止日期足够长以完成回收,则在空闲任务期间安排一次完整的垃圾回收。根据标记速度乘以分配的对象数,可以预测回收停顿时间。只有在网页闲置了相当长的时间后,才执行带有额外内存压缩的完整垃圾回收。

gc 是如何进行增量标记的

v8 会使用三个颜色来描述内存中的对象

  • 白色 (默认所有对象都会是白色)

  • 灰色 (在标记过程中的是灰色,表示这个对象的所有引用未标记完)

  • 黑色 (标记结束的对象为黑色)

如果有新对象被分配到老生代中,则会立刻被标记为黑色,这样可以老生代的标记工作不会因为新的内存分配而增加,被标记为黑色的对象会在标记过程中跳过

v8 会从根节点开始标记,默认所有的对象都是白色,每标记到一个对象时,会将其变为灰色,并且将其推送到 标记工作列表 中,当这个对象的所有引用都被访问到了,则会将这个对象标记为黑色。那么剩下没有被标记到的白色对象,就会在下一次 gc 时回收掉,这个标记被称为 三色标记法

图示:从根节点开始标记

图示 . 收集器通过处理其指针将灰色对象变为黑色

图示 Figure . 标记完成后的最终状态

为什么需要灰色标记

这是因为在 增量标记 时,标记 的工作是穿插在主线程的。所以当下一次标记的时候,为了记录住上一次标记的位置,需要使用 灰色 来表示当前还未标记完的对象,那么这一次就会从这个灰色对象继续标记,直到将其子对象全部标记完,则这个对象会被标记为 黑色

写屏障 的作用

因为上面说过,默认所有的对象都会是 白色 ,那么新添加进的对象,默认也会是白色的,这时就会出现黑色对象指向白色对象的情况了。很显然,这样的现象是不应该发生的,因为白色对象会在下一次的 gc 中进行回收,为了避免这种现象,v8 添加了写屏障

当给一个对象添加了新的属性时,如果这个对象本身已经为 黑色 ,那么,会将这个新添加进来的属性修改为 灰色。这样的话,再下一次 gc,就不会把这个新添加的属性进行回收了

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  // 判断对象是否为黑色,且新添加的属性是否为白色,如果是的话,则会想新添加进来的属性标记为灰色
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

并行标记

虽然v8 引入了 增量标记来降低对主线程的占用,但是因为增量标记依旧是在主线程中执行的,而且加上写屏障 的成本,当主线程繁忙的时候,增量标记 可能会降低程序的吞吐量。为了优化,可以引入 并行标记

并行标记如下图所示,当主线程开始标记的时候,会额外开启辅助线程来并行标记 ,加快标记的效率,这是 全停顿(stop-the-world) 的多线程版本,新生代内存的gc 使用的就是并行标记

并行标记 中,v8 设计了一个 标记工作列表,它的实现用来平衡本地线程对标记的高效处理以及分配一部分工作给辅助线程,防止其它线程空闲。

在下图中,当 标记工作列表变满的时候,会将它发布到一个全局池中,在全局池中其它线程可以来运行这部分的工作

并发标记

并发标记允许 主线程 执行 js代码,辅助线程来访问堆内存中的对象,进行标记。

但是 并发标记 容易引发出 竞态 问题,即主线程在写入对象的同时,辅助线程在读取这个对象,竞态 会让 gc 不知道该如何处理这个对象,从而可能回收掉这个对象,或者将其原始值和指针混在一起

主线程在对对象做以下操作时,可能会发生潜在的 竞态 ,以下是几个高频操作

  • 对象分配

  • 给对象添加属性

  • 对象结构发生变化

  • 代码补丁等

对于这些操作,主线程 需要和 辅助线程 同步,同步的成本和复杂性取决于具体的操作,大多数的操作可以通过原子内存操作来进行同步

并发标记的写屏障

并发标记的写入操作会修改之前的 写屏障 ,它不会再判断之前的对象是否为 黑色,且新添加的值会通过 原子操作 直接将其变为灰色

// Called after atomic_relaxed_write(&object.field, value);
write_barrier(object, field_offset, value) {
  if (color(value) == white && atomic_color_transition(value, white, grey)) {
    marking_worklist.push(value);
  }
}

得益于 并发标记 ,还减少了在 node 中垃圾收集中的卡顿,因为在 node 中之前一直没有实现空闲时间的垃圾回收调度。在 node V10 之后加入了 并发标记。

在浏览器中,因为ChromeBlink 任务调度器 可以在主线程空闲时间调度小的增量标记步骤,而不会导致卡顿。如果空闲时间可用,此优化非常有效。

Q&A

  • 什么是 竞态

    在这里可以理解为,两个线程在同时操作一个对象,a线程在读取,b线程在写入,如果b线程先进行了写入,那么a线程读取的值就是正确的。但是如果 a线程先进行读取,而b线程再进行了写入,那么这时候a线程读取的值就是错误的。

    在前端领域理解竞态有这样的一个例子,页面中有一个按钮,每次点击按钮都会请求一次最新的数据,如果用户快速点击多次按钮,那么就会发出多次 AJAX 请求,我们知道,由于 AJAX 是异步的,所以响应的顺序不一定是按照我们请求时发出的顺序,所以页面上最终展示的内容,会是最后一次响应的数据。

  • 什么是 原子操作

    原子操作就是将这些人类认为是 单个操作 但计算机视为 多个操作 的操作,让计算机也将它们视为 单个操作

    这就是为什么它们被称为 原子操作 。这是因为它们执行的操作通常会有多条指令(指令可以暂停和恢复),这使得它们看起来都是瞬间发生的,就好像它是一条指令一样。它就像一个不可分割的原子。

有关 竞态 的更多内容,可以参考 firefox 团队的 这篇文章

集成并发标记

v8 最终将 并发标记 集成到了 增量标记 的基础架构中

主线程在通过扫描根然后填充填充到 标记工作列表 中,之后,辅助线程会同时开始进行 标记 ,多个工作线程协作完成 标记工作列表 中的任务,来使的主进程有更充裕的时间可以用来处理 JavaScript 或者其它任务,主线程会偶尔也会参与标记工作,一旦 标记工作列表 中没有任务了,主进程就会开始 垃圾收集,在收集的过程中,主进程会重新遍历 根,这个过程可能又会发现一些 白色对象,与此同时,辅助线程会被并行标记。

总结

  • 新生代的 gc 工作通常会很快

  • 标记整理阶段耗时较长,较为消耗性能

  • 标记采用 三色标记法 进行标记

参考资料

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management (mozilla 内存管理简介)

https://blog.chromium.org/2011/11/game-changer-for-interactive.html

https://v8.dev/blog/orinoco-parallel-scavenger (新生代内存引入 并行 Scavenger)

https://v8.dev/blog/orinoco (新生代和老生代的清理)

https://v8.dev/blog/concurrent-marking (并发标记)

https://v8.dev/blog/free-garbage-collection (gc与空闲时间任务调度)

https://v8.dev/blog/high-performance-cpp-gc

https://hacks.mozilla.org/2014/09/generational-garbage-collection-in-firefox/ (firefox 中的gc)


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议,转载请注明出处。