前言
很多小伙伴聊到垃圾回收机制时,只知道标记清除法和引用计数法,但是更详细一点的内容就不知所言了。今天我们来深入了解一下V8的垃圾回收机制,从堆内存的分配和垃圾回收机制的发展过程,帮助大家更全面的认识垃圾回收机制。
什么是GC
GC(Garbge Collection)即垃圾回收,我们在开发过程中会声明很多变量,程序会为这些变量分配内存,当这些变量不再被引用时,我们需要去清除掉这些变量的内存空间,而GC就是帮我们干这件事情的。GC工作在引擎内部,所以在日常开发时我们对于GC是无感的,GC的执行过程也就是垃圾回收机制的执行相对于我们也是无感的。
广义上来讲,GC用于描述查找和删除那些不再被其他对象引用的对象的过程。
为什么需要GC
我们知道JavaScript的引用数据类型值是保存在堆内存的,但是会在栈内存中保存一个对堆内存中实际对象的引用。而当我们声明一个引用数据类型的变量时,再将这个变量重新赋值,原本的对象引用关系就不再被需要了,此时就需要及时释放它,不然当越来越来的无用对象占据内存,轻则影响程序性能,重则导致进程崩溃。
垃圾回收策略
在JavaScript的内存管理中有一个概念叫做可达性,就是以某种方式可访问或者可用的值,它们会被保存在内存中,反之不可访问则需要回收。
然后就是如何发现这些不可达对象并清除的问题。而JavaScript的GC的原理其实就是定期找出那些不再被使用的变量,然后清除其所占用的内存。
整个GC流程涉及到了一些算法策略,其中目前最常见的两种就是
- 标记清除算法
- 引用计数算法
标记清除算法
标记清除算法目前是JavaScript引擎中最常用的,顾名思义它分为两个阶段分别是标记和清除阶段。标记阶段会给所有的活动对象打上标记,清除阶段就是将所有非活动对象所占用的空间清除掉,
引擎在执行GC时,首先需要从根对象出发去遍历内存中的所有对象打上标记,这个根对象可以是全局window对象、文档DOM树等。
整个标记清除算法的大致过程如下:
- 给内存的所有变量都加上一个标记,假设内存中的所有对象都是垃圾,全标记为0
- 然后从根对象出发,把活动对象标记为1
- 清除所有非活动对象也就是标记为0的对象所占用的空间
- 最后,把内存中的所有对象标记修改为0,等待下一轮GC
优点
标记清除算法的优点其实就是简单,变量的状态无非就是0和1也就是活动与非活动,遍历完所有内存中的对象,将非活动对象清除即可,非常简单。
缺点
而标记清除算法有一个非常大的缺点,也就是会产生大量的不连续内存碎片。在清除后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了内存碎片(如下图所示),这就会影响内存分配。
那么如何去解决这个问题呢,归根结底其实只需要解决清除阶段后剩余对象位置不变导致的空闲内存不连续的问题。
标记整理法
而标记整理算法就可以有效的解决这个问题,它的标记阶段和标记清除算法没有什么不同,只是在标记结束后,标记整理算法会将活动对象向内存一端移动,最后清理掉边界内存。
引用计数算法
引用计数算法其实是最早的一种垃圾回收算法,它把对象是否不再被需要简化定义为对象有没有其他对象引用到它,如果没有其他对象引用指向该对象,那么该对象就会被垃圾回收机制回收。
引用计数算法的工作机制是:每个对象维护一个引用计数器,记录有多少个地方引用了该对象
- 引用增加:当新的引用指向对象时,计数器加 1
- 引用减少:当引用被移除(如变量赋值为
null
、对象属性被删除)时,计数器减 1 - 垃圾回收:当计数器变为 0 时,对象立即被回收
与标记清除算法相比,它是一种即时回收的策略,而不是周期性回收。但是它有一个主要的缺点,就是遇到循环引用的变量时无法清除,比如我们看下面的代码。
function createCycle() {
const objA = {};
const objB = {};
objA.b = objB;
objB.a = objA;
return [objA, objB];
}
调用后,即使[objA, objB]被销毁,objA和objB仍保持相互引用,此时他们引用数量都不为0,无法被回收。
优点
当对象不再被引用时能够及时被回收,减少内存的峰值。
不用像标记清除算法一样需要周期性的执行,GC的执行过程中会阻塞JS引擎执行线程的执行,这个也被称为全停顿,引用计数算法无需像标记清除那样暂停整个应用。
缺点
引用计数算法的缺点也很明显,我们需要为所有变量都创建一个计数器,计数器所占用的空间开销很大。
循环引用问题,当两个或多个对象相互引用时,即使它们不再被外部访问,引用计数也不会为 0,导致内存泄漏,这是最严重的。
V8对GC的优化
上面说到标记清除算法是目前最为常用的,所以V8亦是对其进行了一些优化加工处理。
分代式垃圾回收
试想一下,内存中的所有对象中肯定会存在生命周期长、存活时间长、生命周期短、存活时间短的不同变量,如果这些变量都执行相同的检查频率十分不好,因为前者并不需要如此频繁地进行清理,而后者恰好相反,所以分代式垃圾回收就是对这个问题的一个优化。
新老生代
V8 将堆内存分为两大区域:新生代(Young Generation)和老生代(Old Generation),并采用不同的回收策略。
新生代区主要存放一些生命周期短,新声明的变量,通常支持1~8M的容量,老生代区主要存放一些存活时间长,已经被GC处理过的变量,容量通常比较大。
V8 整个堆内存的大小就等于新生代空间加上老生代空间的内存(如下图)
新生代垃圾回收
新生代空间中又将内存分为From空间和To空间,From空间主要存放一些初始化的新对象,To空间用于复制存活对象。
新加入的对象会被放到From空间,当From空间快被写满时,会执行一次GC。
从根对象开始,标记所有可达对象,标记完成后将From空间的所有活动对象复制到To空间并且排序,随后进入垃圾清除阶段,清除所有非活动对象所占用的空间。最后进入角色互换阶段,将From空间和To空间互换。
当一个对象经过多次复制后依然存活,那么这个对象会被认为是生命周期较长的对象,随后会被移入到老生代区中,采用老生代的垃圾回收机制策略管理。
还有一种情况,如果复制一个对象到To空间时,To空间的内存占比超过25%时,那么这个对象会被直接晋升到老生代空间中,设置为25%的原因是,若To空间内存占比过大会影响后续互换阶段的内存分配。
老生代垃圾回收
相比于新生代,老生代的垃圾回收机制其实采用的就是标记清除法。
首先从一组根对象出发,递归遍历这组根对象,遍历过程中可访达的对象称为活动对象,那么不可访达的对象也就称为非活动对象。清除阶段就是将非活动对象所占用的空间清除掉,之后标记清除算法会产生大量的不连续内存碎片,也就是通过上面我们所说的标记整理法来优化空间。
并行回收
我们知道JavaScript是一门单线程语言,上一篇文章有详细讲解,GC是运行在主线程上的,所以在进行垃圾回收的时候必然就是阻塞JavaScript引擎执行线程的执行,需要等待GC执行完毕再恢复脚本的执行,这个现象被称为全停顿
。
为了优化全停顿的阻塞时间,V8团队采用了并行回收的策略,如果一次GC耗时很长,那么开辟多个辅助线程同时执行相同的GC任务呢,全停顿的时间也就肯定会大大减少了。
新生代空间其实采用的就是并行回收的策略进行优化,在执行垃圾回收的过程中,会启动多个子线程来执行垃圾清理,这些线程同时将From空间的活动对象移动到To空间,这个过程中对象的地址发生了变化,所以也需要同步更新引用这些对象的指针,这个就是并行回收。
增量标记
我们上面说的并行回收虽然能减少全停顿的时间,但其本质依旧是全停顿式GC,对于老生代来说,空间很大需要进行GC的变量很多,对于这些变量哪怕执行并行回收仍然会耗时很久。
所以V8团队为了优化这个问题,在后面又对GC进行了优化,用增量标记取代了并行回收。
增量就是将一次完整的GC标记过程,分成多个小步,每执行完一小步就让脚本执行一会,这样交替多次完成一轮完整的GC标记。
可是将一次完整的GC标记分次执行,那么在每一小次GC标记执行完后如何暂停来去执行任务程序呢,之后又怎么恢复呢?并且如果脚本执行过程中修改了变量的引用关系又怎么办呢?
V8对这些问题的解决方案就是三色标记法和写屏障
三色标记法
我们知道老生代采用的是标记清除法,没有采用增量算法之前,单纯用黑色和白色来标记对象即可,其标记流程是,在执行一轮完整的GC前,垃圾回收器会将所有数据设置为白色,然后垃圾回收器会从一组根对象出发,将所有可访达的对象标记为黑色,遍历结束后,标记为黑色的就是活动对象,最后清除掉白色也就是非活动对象所占用的空间。
如果采用黑白两种颜色来标记,增量标记过程中,会出现被标记为黑色和白色的变量都有,所以就无法得知下一步走到哪里了。
为了解决这个办法,V8团队采用了一种特殊方式:三色标记法
三色标记法通过将对象标记为三种颜色来分为三类:
-
白色对象:
- 尚未被垃圾回收器访问到的对象
- 在初始阶段,所有对象均为白色
- 最终仍为白色的对象会被视为垃圾并回收
-
灰色对象:
- 已被垃圾回收器访问到,但尚未处理完其所有引用的对象
- 灰色对象表示正在处理中的对象
- 垃圾回收器会从灰色对象出发,继续标记其引用的对象
-
黑色对象:
- 已被垃圾回收器访问到,且其所有引用的对象都已被处理完毕的对象
- 黑色对象表示已经处理完成的对象
- 黑色对象不会被再次扫描
初始阶段,所有对象都标记为白色,然后从一组根对象出发,先将这组根对象标记为灰色然后推入到工作表中,当垃圾回收器从工作表中弹出该对象并且访问该对象引用的对象时,将其自身由灰色变为黑色,引用的变量由白色变为灰色。
就这样一直往下走,直到没有可标记为灰色的变量,也就是没有可访达的变量时,那么剩下的白色变量都是非活动对象,等待回收。
三色标记法可以很好的配合增量回收来暂停回收以及跟踪进度,从而减少全停顿的时间。
写屏障
此外,如果脚本执行过程中可能会修改变量的引用关系。比如脚本执行过程中修改了一个变量的引用关系,那么就会出现一个黑色的变量引用的白色的变量。
为了解决这个问题,V8增量回收使用写屏障机制,即一旦由黑色变量引用白色变量,该机制会强制将白色变量变为灰色,保证下一轮GC能够正常标记,而不再被引用的变量等待下一轮GC回收即可。
总结
GC即垃圾回收,V8的GC主要基于分代式垃圾回收。当我们声明一个引用数据类型变量时,它的值会被存放在堆内,整个堆内存又被分为新生代区和老生代区,新生代区主要存放一些生命周期短,新声明的变量,老生代区主要存放一些存活时间长,生命周期长的变量,新生代区和老生代区采用不同的GC方式管理。新生代区又分为使用区和空闲区,使用区主要存放一些新声明的变量,空闲区存放一些以及被GC处理过的变量,当执行一次GC时,首先会将使用区的活动对象打上标记,标记完成后将活动对象复制到空闲区并且排序,然后进入到垃圾清理阶段,清除掉非活动对象占用的空间,最后进入角色互换阶段,将使用区和空闲区进行互换。如果一个变量经过多次互换依然存活,那么他会被直接晋升到老生代区,还有一种情况就是当空闲区内存占比超过25%时,空闲区变量会直接晋升到老生代区,设置为25%的原因是不影响后续互换阶段的内存分配。而老生代采用的标记清除法,顾名思义其分为标记和清除两个阶段。首先从根对象出发,这个根对象可以是全局Windows对象,也可以是文档DOM树等,然后递归遍历这组元素,将可以访达的变量标记为活动对象,不可访达的元素标记非活动对象,最后清除掉非活动对象占用的空间。但此时会遇到内存碎片的问题,标记清除会在内存中产生大量的不连续碎片,V8团队采用了标记整理法来解决整个问题,也就是将活动对象统一向内存一端移动,然后清除掉边界内存。此外,由于在GC执行的过程会阻塞JS引擎执行线程的执行,这个现象被称为“全停顿”,为了优化这个问题,V8团队首先采用了并行回收的策略,也就是开辟多个辅助线程同时执行相同的GC任务,减少了全停顿的时间。后面发现如果资源过大全停顿时间仍然过长,所以后续又通过增量法来优化这个问题,通过将一整段完整的GC分为多个小段,让GC执行一会,再让JS引擎线程执行一会后再让GC执行一会,这样反复多次完成一轮完整的GC。虽然此时很大优化了全停顿的问题,但仍然存在问题,比如脚本执行过程中可能修改变量的引用关系,V8团队最终采用了三色标记法和写屏障法来解决这个问题。三色标记法会将变量分为白、灰、黑三种颜色,最初所有变量都是白色,从一组根对象出发,递归遍历这组元素,将根对象存入到工作表中,其由白色变为灰色,再从工作表中弹出根对象去访问其引用的变量,此时根对象由灰色变为黑色,引用的变量由白色变为灰色,就这样一直往下走,直到没有可标记为灰色的对象,此时GC执行完毕,最后清除掉白色也就是非活动对象即可。写屏障法规定如果一个黑色变量引用了一个白色变量时,会直接将这个白色变量转换为灰色,等待下一轮GC正常标记即可,原本不再被引用的黑色变量等待下一轮GC正常回收即可。