[译]Orinoco|[译]Orinoco 垃圾收集器

原文链接 https://v8.dev/blog/trash-talk
任何垃圾收集器都有一些必须定期执行的基本任务:
1、识别不再使用/正在使用的对象
2、回收/重用不再使用的对象所占用的内存
3、压缩管理内存
这些任务可以按顺序执行,也可以任意交错。 一种直接的方法是在主程序中暂停JavaScript的执行来顺序执行这些任务,但可能会导致主线程上的卡顿和延迟问题
代际布局 V8中的堆被分成称为代的不同区域。 有新生代(进一步分为“托儿所”和“中间”子代)和老年代。 对象首先分配到托儿所。 如果它们在下一代垃圾回收中存活下来,它们仍然存在于年轻一代,但被认为是“中间”。 如果他们在另一个GC中幸存下来,他们就会进入老年代
在垃圾收集中有一个重要的术语:“代际假设”。 这基本上表明大多数物体都很年轻。 换句话说,从GC的角度来看,大多数对象都被分配,然后几乎立即变得无法访问。 这不仅适用于V8或JavaScript,也适用于大多数动态语言
V8中有两个垃圾收集器。 Major GC(Mark-Compact)从整个堆中收集垃圾。 Minor GC(Scavenger)在新生代收集垃圾。
Major GC(标记整理法) 标记整理法从整个堆中收集不再使用的内存
标记整理法的步骤分为三部分:
1、找到未使用的对象,进行标记
2、回收内存
3、对内存进行压缩管理
标记阶段 确定可以收集哪些对象是垃圾收集的重要部分。 垃圾收集器通过可访问性作为“活跃度”来实现此目的。 这意味着必须保留当前在运行时内可以访问的任何对象,并且收集任何无法访问的对象。
标记是找到可访问对象的过程。 垃圾收集器从一组已知的对象指针开始,称为根集。 这包括执行堆栈和全局对象。 然后它跟随每个指向JavaScript对象的指针,并将该对象标记为可访问。 垃圾收集器跟踪该对象中的每个指针,并以递归方式继续此过程,直到找到并标记了在运行时中可以访问得到的所有对象。
清除阶段 清除阶段是将不再使用的对象留下的内存空间添加到称为空闲列表(free-list)的数据结构中的过程。
标记完成后,垃圾收集器会找到不可访问的对象留下的连续内存空间,并将它们添加到相应的空闲列表中。 空闲列表由内存块的大小分隔。 在将来我们想要分配内存时,我们只需查看空闲列表并找到适当大小的内存块
压缩 标记整理法还根据碎片启发式选择撤离/压缩某些页面。我们将存活的对象复制到当前未被压缩的其他页面中(使用该页面的空闲列表)。 这样,我们可以利用不再使用的对象释放的小而分散的内存空间。
这种垃圾收集器的一个潜在弱点是,当我们分配大量长寿命对象时,我们需要花费很高的成本来复制这些对象。 所以我们选择仅压缩一些高度分散的页面,而只是对其他页面进行扫描,而不会复制存活的对象
Minor GC(Scavenger) Major GC可以有效地从整个堆中收集垃圾,但是代际假设告诉我们新分配的对象很可能需要进行垃圾收集。
Scavenger仅收集新生代,存活的对象会被撤离到另外的页面中。 V8在新生代中采用了“半空间”设计。 这意味着总空间的一半总是空的,以允许这个撤离步骤。 在清除过程中,这个最初为空的区域被称为“To-Space”。 我们复制的区域称为“From-Space”。 在最坏的情况下,每个对象都可以在清除过程中都可以存活,我们需要复制每个对象。
清除过程中,我们有一组根指针,是旧空间中的指针,指向新生代中的对象。 我们通过维护这组指针的列表,就不用为每个清除过程去跟踪整个堆。 当堆栈和全局变量结合使用时,我们就可以知道每一代对新生代的引用,而不需要追溯到整个老年代。
撤离步骤将所有幸存的对象移动到连续的内存块(在页面内)。 这样做的好处是可以完成删除碎片 - 不可访问对象留下的空白。 然后我们切换两个空间,即To-Space变为From-Space,反之亦然。 GC完成后,新的分配将在From-Space的下一个空闲地址发生。

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
image.png 清理的最后一步是更新引用已移动的原始对象的指针。 每个复制的对象都会留下一个转发地址,用于更新原始指针以指向新位置。

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
image.png 在清理时,我们实际上执行这三个步骤 - 标记,撤离和指针更新 。所有步骤是交错进行的
Orinoco
这些算法和优化在很多垃圾收集文献中都可以了解到,并且很多垃圾收集语言中已经实现了。
测量垃圾收集所花费时间的一个重要指标是主线程在执行GC时暂停的时间。 对于传统的“全局停顿”垃圾收集器(全局JavaScript暂停去执行垃圾收集任务)来说,这个时间会累加起来,而花在GC上的时间直接降低了用户体验,包括页面质量差,渲染和延迟。
Orinoco利用并行,增量和并发技术进行垃圾收集,以释放主线程。
并行 并行是指主线程和辅助线程同时执行大致相同数量的工作。 这仍然是一种“全局停顿”(当程序运行到这些“安全点”的时候就会暂停所有当前运行的线程)的方法,但总暂停时间现在除以参与的线程数(加上一些同步开销)。 这是三种技术中最简单的一种。因为没有JavaScript运行, JavaScript堆暂停,因此每个辅助线程只需要确保它可以同步地访问任何其他的辅助线程

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
image.png 增量 增量是指主线程可以间歇性地进行少量工作。 我们不会在增量暂停时执行整个GC,只会进行GC所需总工作量的一小部分。但 这更加困难,因为如果JavaScript在每个增量工作段之间执行,这意味着对象的状态已经发生改变,这可能使先前以增量方式完成的工作失效。 另外,从图中可以看出,利用增量并没有减少在主线程上花费的时间(事实上,它通常会略微增加),它只会随着时间的推移而扩展。不过,它可以解决主线程延迟的问题。 通过允许JavaScript间歇运行,同时执行垃圾收集任务,应用程序仍然可以响应用户输入并在一些动画上取得良好的效果。

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
将GC分割成一部分一部分进入主线程执行 并发 并发是当主线程不断执行JavaScript时,辅助线程完全在后台运行GC。 这是三种技术中最难的一种:JavaScript堆中的任何内容都可以随时更改,从而使我们之前完成的工作无效。 最重要的是,当辅助线程和主线程同时对同一对象进行读写操作,会产生读写竞赛机制。 这里的优点是主线程可以全部用于执行JavaScript - 尽管这里会有一小部分开销用于与辅助线程进行同步

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
image.png 当前几种垃圾收集器
Scavenging 【[译]Orinoco|[译]Orinoco 垃圾收集器】今天,V8在新生代中的GC通过在辅助线程上使用并行清理分配工作。 每个线程接收到许多指针,它们快速将所有活动对象撤离到To-Space。 在撤离对象过程时,清理任务必须同步进行读/写/比较和交换操作; 有可能另一个清理任务通过不同的路径找到了相同的对象并移动了它。 无论哪个辅助线程成功撤离对象,都会返回并更新指针。 它留下了一个转发指针,以便访问该对象的其他worker可以在找到它们时更新其他指针。 为了快速同步地分配存活对象,清理任务使用本地线程分配缓冲区。
Major GC
V8中的主要垃圾收集器(标记整理法)以并发标记开始。 当堆动态计算受到限制时,将启动并发标记任务。辅助线程跟踪所有的指针并标记每个找到的对象。 当JavaScript在主线程上执行时,并发标记完全在后台进行。 在辅助程序执行并发标记时,写入障碍用于跟踪JavaScript创建的对象之间的引用
当并发标记完成或者动态分配受到限制时,主线程执行快速标记完成步骤。 主线程暂停在此阶段开始。 这是标记整理法的总暂停时间。 主线程再次扫描根集,以确保所有活动对象都已经标记,然后与一些辅助程序一起启动并行压缩和指针更新。主线程在暂停期间启动并发清除任务。 它们并发地运行到并行压缩任务和主线程, 即使JavaScript在主线程上运行,它们也可以继续运行。
Idle-time GC
JavaScript用户无法直接访问垃圾收集器,它在实现的时候就定义好了。 但是,V8为一些嵌入器提供了一种触发垃圾收集的机制。 GC可以发布“空闲任务”,这些是可选的,同样最终都会被触发。 像Chrome这样的嵌入器就有空闲时间的概念。 例如,在Chrome中,每秒60帧,浏览器大约有16.6毫秒来渲染动画的每一帧。 如果动画工作提前完成,Chrome可以选择在下一帧之前的空闲时间运行GC创建的其中一些空闲任务。

[译]Orinoco|[译]Orinoco 垃圾收集器
文章图片
空闲GC利用主线程上的空闲时间主动执行GC工作 相关知识
V8中的垃圾收集器自成立以来已经走过了漫长的道路。 向现有GC添加并行,增量和并发技术是一项多年的努力,但已取得成效,将大量工作转移到后台任务。 它大大改善了暂停时间,延迟和页面加载,使动画,滚动和用户交互更加顺畅。 并行Scavenger将主线程新生代垃圾回收总时间减少了大约20%-50%,具体取决于工作负载。 空闲时间GC可以在闲置时将Gmail的JavaScript堆内存减少45%。 同时进行标记和扫描可以减少大型WebGL游戏中50%的暂停时间
大多数开发人员在开发JavaScript程序时不需要考虑GC,但了解一些内部结构可以帮助您考虑内存使用情况和有用的编程模式。 例如,对于V8堆的代际结构(新生代和老年代),从垃圾收集器的角度来看,存活期短的对象的开销是非常小的, 因为我们只需要承担在垃圾收集中存活下来的对象的开销。 这些类型的模式适用于许多垃圾收集语言,而不仅仅是JavaScript。

    推荐阅读