老生常谈,垃圾回收♻️

692 阅读13分钟

前言

读完这篇文章你能了解什么❓

在JavaScript中内存管理是自动的,在开发中我们不需要自己手动分配内存,释放内存。那干嘛看这篇文章?

当然是学思想和方法啦,难道是为了装逼13吗。技术都是互通的,懂的原理对性能优化也是有好处滴。

  • V8引擎的垃圾机制是如何工作的
  • 新一代垃圾收集器Orinoco如何减少垃圾回收的阻塞时间
  • 学到一些性能优化的思想

文章内容大多翻译至:v8.dev/blog/trash-… 感兴趣的小伙伴可以去康康大佬写的原文。

为什么要进行垃圾回收

因为电脑的内存不是无限的,如果不进行垃圾回收,内存就会炸掉,这就像像地球🌏的资源一样(可持续发展直呼内行)。

垃圾回收的作用就是只留下运行时必要的内存,其它不用的垃圾统统清理掉,留到后续要分配内存的时候使用。

垃圾车

垃圾回收痛点

众所周知,JavaScript是单线程的。为保证数据同步,在进行垃圾回收时,最简单的办法就是暂停JavaScript的执行,然后GC开始工作,完成之后再继续执行JavaScript。

垃圾回收器(grabage collector),简称GC

这么做势必会造成页面的卡顿,如:用户操作不响应,页面动画不连续。

在V8的老一代的垃圾回收机制中,采用的就是这种做法。而在谷歌V8团队研发的新一代垃圾回收器(代号:Orinoco)中,就是针对进行垃圾回收时会造成页面卡顿这个痛点进行优化。Orinoco做了很多优化手段,为的就是尽可能的减少垃圾回收对主线程的阻塞,使用户感觉不到页面的卡顿。

Orinoco的logo

垃圾回收基本原理

任何垃圾回收机制都会周期性的执行一些基本步骤:

  1. 找到哪些是垃圾(凉凉的对象,dead objects),哪些不是垃圾(活动对象,live objects)
  2. 回收再利用那些垃圾占用的内存
  3. 压缩/整理垃圾留下的碎片(可选)

这些任务可以按顺序进行,也可以任意交错进行。

V8中的GC是如何工作的

实际上,在V8中,有两个垃圾回收器,分别是

  • Major GC(主GC),又名Full Mark-Compact
  • Minor GC(次GC),又名Scavenger

每个GC工作在不同的阶段。

主GC工作流程

主GC是通过遍历整个堆内存来进行垃圾回收。主GC在标记(marking)、清除(sweeping)和压缩(compacting)三个阶段进行工作。

标记

标记是的区分垃圾对象和活动对象的其中一种方式。先来说说另一种方式:引用计数

引用计数

在老一代的浏览器中,是使用引用计数来区分当前对象是不是垃圾。

当引用计数为0时,这个对象就是个垃圾,当引用计数不为0时,这个对象就是活动对象。

举个例子🌰

let obj = { a: 1 }; // obj具有这个对象的引用,引用计数为1,不是垃圾
obj = null; // 引用没了,引用计数为0,恭喜{ a:1 }👏现在是一名合格的垃圾了

但是这个方法有个致命的问题,循环引用

无限循环

举个例子🌰

let obj1 = {}; // obj1具有这个对象的引用,这里称为对象a,对象a的引用计数为1
let obj2 = {}; // obj1具有这个对象的引用,这里称为对象b,对象b引用计数为1
obj1.foo = obj2; // obj1.foo具有对象b的引用,对象b引用计数为2
obj2.foo = obj1; // obj2.foo具有对象a的引用,对象a引用计数为2

现在把obj1和obj2都干掉

obj1 = null;
obj2 = null;

然后现在对象a和对象b还是互相引用的,引用计数永远为1,这个垃圾就永远清理不掉

image-20210430145640484

标记

由于引用计数有bug,所以现在的浏览器GC使用的大部分用的是标记清除法(mark-and-sweep)。

其中标记是GC工作的一个重要步骤。GC会从几组根指针开始往下找,直到把运行时的对象全部打上标记为止。找到的对象(reachable objects)就标记为活动对象,没找到的对象就标记为垃圾。

根指针是啥?

  • 当前函数的局部变量和参数。
  • 嵌套调用时,当前调用链上所有函数的变量与参数。
  • 全局变量。
  • (还有一些内部的)

清除

清除就是把标记为垃圾的对象全部干掉。

先来一张标记加清除一起工作的图:

mark and sweep

再看看主GC具体的怎么做的:

主GC会将垃圾占用的内存添加到一个叫**空闲列表(free-list)**的数据结构中。在完成标记操作之后,GC就会找到相连的”垃圾“们,将他们添加到合适的空闲列表中。空闲列表的内存块通过内存块大小分割,方便查找。当需要重新分配内存的时,只需去空闲列表中找一个大小合适的内存块。

image-20210430090703579

压缩

主GC会使用启发式算法对部分页面页面进行压缩,这个过程有点像window的磁盘清理功能。

image-20210430094525931

GC会将一个页面中的活动对象的复制到另一个未进行压缩处理的页面,从而最大化利用内存空间。

在复制活动对象的时候,有个隐患,如果这个对象的寿命很长,那会复制这些对象会付出很高昂的代价(使用中的对象的值随时可能变化,要是在这个过程复制移动对象,很有可能会出现找不到当前对象等不可预知的问题)。所以主GC只会选择那些高度碎片化的压面进行压缩操作,其它页面只会选择进行清理操作

image-20210430094704457

次GC工作流程

世代布局

在说次GC工作流程的时候,先说说世代布局的概念。

在V8中使用的堆会被分成几个区域,这些个区域被称为世代(generations),世代分为年轻一代(young generation)和老一代(old generation),年轻一代又进一步分为幼儿园(nursery)和中学生(intermediate)。

  • 一开始对象会在分配到幼儿园区域;
  • 如果在幼儿园经历了一轮GC操作后没被干掉,这个对象就会移动到中学生区域;
  • 如果再经历一轮GC还没被干掉,就会移动到老一代中。

这么说有点抽象,来一张图:

image-20210430095757127

世代假说

在垃圾回收机制中有个重要的概念叫世代假说,这个假说说的就是大部分对象都活不久。在GC看来,很多对象基本上在刚声明不久就无法访问了。比如,你在函数中声明的对象,这个函数一执行完,里面的声明的对象马上就访问不到了。这个概念不是V8或者JavaScript独有的,任何动态语言都适用。

V8的世代布局就充分的利用世代假说这个事实。虽然在GC工作的时候复制对象成本很高,但是根据世代假说,我们知道实际上能活下来的只是一小部分的对象。存活不久的放在年轻一代中,存活得久的转移到老一代中,然后使用不同的GC进行操作,区别对待。其实V8支付的成本只是和这长久活下来的一小部分对象成正比的,而不是声明的多少对象就花费多少成本。

工作流程

上面有说到,V8有两个垃圾清理器,主GC是在整个堆上工作的,而次GC是在年轻一代上工作的。

主GC已经可以在整个堆上进行垃圾回收了,为什么还需要次GC?

因为根据世代假说,新分配的对象寿命很短👶,很需要垃圾回收。

次GC的工作步骤有哪些?

次GC主要做三个步骤:标记(marking)、撤离(evacuating)、指针更新(pointer-updating)。这三个步骤是在不同阶段交替进行的。标记之前说了这里就不重复了。

撤离

主GC是通过启发式算法对部分高度碎片化的页面(压缩)进行碎片清理工作,而次GC则是使用撤离的方式进行碎片清理。

V8为年轻一代使用一个叫semi-page的设计,为了做撤离操作,总空间中有一半是空的。总空间被分为两部分,一开始空的那个空间叫To-Space,需要复制的地方叫From-Space,其实就是对应幼儿园和中学生。

首先,次GC会将所有活动对象撤离到一个连续的内存空间中,从而完成碎片清理工作。

image-20210430104635380

接着,GC会调换From-Space和To-Space,新分配的对象会先添加到From-Space最下面,然后马上撤离到To-Space。

image-20210430104757470

更新指针

这样换下去,很快空间就会用完,所有在第二轮GC的时候会把剩下的活动对象迁移到老一代中,最后更新一下原来指针的指向。

image-20210430105148846

Orinoco

好了,终于说到我们的重点了,原来的垃圾处理机制是会阻止主线程的运行直到垃圾回收的整个过程完成,衡量垃圾回收性能的一项重要指标是GC执行时主线程的暂停时间。我们现在来看看Orinoco做了哪些优化。

Orinoco主要使用并行(parallel)、增量(incremental)和并行(concurrent)这三种技术来释放主线程。

并行

并行就是指主线程和辅助线程在同一个时间做大致等量的工作。这虽然还是会阻塞主线程,但是阻塞的时间会减少,总阻塞时间会变成原来的n(线程的总数)分之一。

这是三种技术中最简单的一种。由于这时候js是停止执行的,所以只需要解决各个辅助线程在访问同一个对象时的数据同步问题即可。

并行

增量

增量是指主线程间歇性的执行一小部分工作。把整个GC操作切成一小块一小块的,每次执行GC的一小部分工作。这种方式要难很多,因为JavaScript执行是夹杂在增量中的,所以很有可能在因为堆状态的变化,导致之前的一个增量工作无效。

从下图可以看出,这并不会减少GC任务的总执行时间,甚至稍微会增加一点。但是这是一个减少JavaScript阻塞时间的好方法。通过允许JavaScript间歇性的运行可以保证程序在GC操作中依然可以响应用户的输入和动画的执行。

增量

并发

并发指的是完全不阻塞JavaScript执行,GC的步骤全部通过辅助线程在后台执行。

这是三种技术中最难的一个,在任何时间JavaScript堆的任何东西都有可能改变,导致之前做的清理工作无效。主线程和辅助线程还有可能同时读取和修改相同的对象,会存在read/write races的问题

并发的好处是除去一点点和辅助线程同步的开销,可以完全释放主线程去执行JavaScript。

并发

Orinoco是如何将三种优化的技术运用到GC的各个阶段的❓

在次GC工作的时候(Scavenging)

在年轻一代GC工作的时候,V8会使用并行的方式将清理工作分布到多个辅助线程和主线程之中。

每个线程会分别收到几个指针,然后将这些指针指向的活动对象撤离到To-Space。在撤离时,这些清理任务必须使用原子的读入、写入、比较和交换操作来保证数据同步。

在一个辅助线程工作的时候,另一个线程可能通过其它路径先找到了相同的对象,并试图移动这个对象。不管是哪个线程先找到这个对象,都会更新这个对象的指针地址并返回。它会留下一个转发指针给其它线程告知这个对象更新完之后的地址是什么。

image-20210430114057855

在主GC工作的时候(Major GC)

主GC使用并发技术进行标记和清理操作,使用并行技术进行压缩和指针更新操作。

在V8中主GC是通过并发的标记开始的。当堆接近动态计算的临界点时,并发标记任务就会开始。整个并发标记都发生在后台,不影响JavaScript主线程的运行。

V8会使用Write barriers记录在并行标记期间生成的新对象的引用。

具体步骤:

  • 每个辅助线程都会被分配几个指针,然后根据这个指针去找,给找到的对象打上标记。

  • 当并发标记完成,或者到达动态分配的临界值时,主线程会进行一个很快的标记收尾步骤。

  • 主线程就这这个时候被GC暂停。然后主线程再次从根节点开始扫描,确保标记了所有的活动对象,然后和几个辅助线程开始并行执行压缩和指针更新操作。

  • 清理工作在主线程暂停期间开始并行工作,不影响JavaScript执行。

不是所有旧空间(old-space)里的页面都需要进行压缩操作,那些没有压缩的页面会使用之前提到的空闲列表进行清理。

image-20210430114712248

闲暇时间的GC(Idle-time GC)

JavaScript没有直接可以操作GC的方法。但是V8给宿主环境提供了触发GC的操作。GC会发送一些闲暇任务(Idle Tasks),就算宿主不主动触发这些任务,这些任务也会被GC自动执行。

像谷歌浏览器这种宿主会有一些空闲时间的概念。谷歌浏览器每秒会刷新60次,每刷新一次(一帧)占用的时间大概是16.6ms,在这16.6ms内的动画如果提前执行完成了,剩下的时间就是闲暇时间,可以用来执行GC的闲暇任务。

GC会利用主线程上的空闲时间来主动执行GC工作。利用空闲时间,每次执行一点点🤏🏻,其实就是增量

image-20210430115141940

总结

  1. V8中有两个GC,一个是主GC,在整个堆上工作,另一个是次GC,在年轻一代上工作。
  2. 由于大部分对象活动时间不长(世代假说),所以V8将内存堆分成了几个区域,不同的GC在不同额区域进行不同的处理。
  3. 主GC会执行标记、清除和压缩三个步骤。次GC会执行标记、撤离、指针更新三个步骤。
  4. 新一代GC(Orinoco)使用并行、增量和并发技术减少GC对主线程的阻塞时间。
  5. 次GC使用并行技术进行撤离操作。主GC使用并发技术进行标记和清理操作,使用并行技术进行压缩和指针更新操作。

参考: