JS垃圾回收机制【图文解析】

3,512 阅读17分钟

什么是GC?

在JavaScript中,"gc"代表垃圾回收(Garbage Collection) 。垃圾回收是一种自动内存管理机制,用于检测和清除不再使用的对象,以释放内存空间。当一个对象不再被引用时,垃圾回收器会将其标记为垃圾,然后在适当的时候清除这些垃圾对象,并将内存回收给系统以供其他对象使用。垃圾回收的目的是减少内存泄漏和提高程序的性能
CC++等语言中,开发者需要手动管理内存。虽然这种方式可以给开发者更大的灵活性和控制性,但也容易引起内存泄漏悬挂指针等问题。而在JavaScript中,垃圾回收是由JavaScript引擎自动执行的,开发者无需手动管理内存。

垃圾产生的原因

  • 在JavaScript中,垃圾是指不再被程序所使用的对象或数据。以下是垃圾产生的一些常见情况:
  1. 对象不再被引用:当一个对象不再被任何变量或属性引用时,它就成为垃圾。例如,当一个函数执行完毕后,其中创建的局部变量将成为垃圾,因为它们无法再被访问到。

  2. 对象之间形成循环引用:当两个或多个对象相互引用,并且它们之间没有外部引用时,它们将成为垃圾。这种情况下,即使这些对象不再被任何其他代码引用,它们也无法被垃圾回收器清除。

  3. 动态创建的对象没有被及时销毁:如果在代码中频繁地创建新的对象,但没有及时销毁这些对象,就会产生垃圾。特别是在循环或递归等情况下,如果没有正确地释放内存,垃圾会不断积累。

  4. 内存泄漏:当代码错误地保留了不再需要的对象引用时,就会发生内存泄漏。这通常是由于没有合理使用闭包、未解除定时器或忘记解除事件监听等引起的。

垃圾回收算法

引用计数(Reference Counting)

  • 定义:引用计数(Reference Counting)算法通过跟踪每个对象被引用的次数来确定对象是否为垃圾。
  • 每个对象都有一个引用计数器,引用计数的过程如下:
    • 当一个对象被创建时,其引用计数器初始化为1。
    • 当该对象被其他对象引用时,引用计数器加1。
    • 当该对象不再被其他对象引用时,引用计数器减1。
    • 当引用计数器减至0时,意味着该对象不再被引用,可以被垃圾收集器回收。
// 创建一个对象
let obj = { name: "test" };
// 创建一个引用指向对象
let ref1 = obj;//引用计数+1 1

// 创建另一个引用指向对象
let ref2 = obj;//引用计数+1 2

// 引用失效
ref1 = null;//引用计数-1 1
ref2 = null;//引用计数-1 0

// 引用计数为0,对象可以被回收
  • 优势

    • 实时回收:引用计数可以在对象不再被引用时立即回收,不需要等待垃圾收集器的运行。这可以减少内存占用和提高程序的性能。
    • 简单高效:引用计数是一种简单的垃圾收集算法,实现起来相对容易,不需要复杂的算法和数据结构。
  • 存在的问题:

    • 循环引用:当两个或多个对象相互引用时,它们的引用计数都不为零,即使它们已经不再被其他对象引用,也无法被回收。这导致内存泄漏,因为这些对象仍然占据内存空间,却无法被释放。
    const objA = {};
    const objB = {};
    //`objA`和`objB`相互引用,没有其他对象引用它们。
    objA.ref = objB;//objA引用objB
    objB.ref = objA;//objB引用objA
    
    • 计数开销:维护每个对象的引用计数需要占用额外的内存空间,而且每次添加、删除引用都需要更新计数,增加了额外的开销。

标记-清除(Mark and Sweep)

  • 定义:标记-清除(Mark and Sweep)算法通过标记不再使用的对象,然后清除这些对象的内存空间,以便后续的内存分配使用。

  • 它分为两个阶段:标记阶段清除阶段

    1. 标记阶段
      在标记阶段,垃圾回收器会对内存中的所有对象进行遍历,从根对象开始(通常是全局对象)递归地遍历对象的引用关系。对于每个被访问到的对象,垃圾回收器会给它打上标记,表示该对象是可达的,即不是垃圾。这个过程确保了所有可达对象都会被标记。
    2. 清除阶段
      在清除阶段,垃圾回收器会遍历整个内存,对于没有标记的对象,即被判定为垃圾的对象,会被立即回收,释放内存空间。这样,只有被标记的对象会被保留在内存中,而垃圾对象会被清除。
  • 在下面的图中,蓝色的元素代表被访问到的对象,即可达对象,灰色代表没有被访问到的对象,即不可达对象 1.png

  • 优势

    • 简单有效:标记-清除算法相对简单,容易实现。它可以准确地找到不再被引用的对象,并回收内存。
    • 处理循环引用:标记-清除算法能够处理循环引用的情况。当对象之间存在循环引用时,即使它们不再被任何其他对象引用,引用计数算法也无法将它们识别为垃圾,而标记-清除算法可以通过遍历的方式找到并清除这些对象。
  • 存在的问题:

    • 垃圾回收过程中的停顿:标记-清除算法会暂停程序的执行,进行垃圾回收操作。当堆中对象较多时,可能会导致明显的停顿,影响用户体验。
    • 内存碎片化:标记-清除算法会在回收过程中产生大量的不连续的、碎片化的内存空间。这可能导致后续的内存分配难以找到足够大的连续内存块,从而使得内存的利用率降低。

标记-整理(Mark and Compact)

  • 定义:标记整理(Mark and Compact)可以看作是标记清除的增强操作,他在标记阶段的操作和标记清除一致,但是清除阶段会先执行整理,移动对象位置,对内存空间进行压缩。

  • 它分为三个阶段:标记阶段整理阶段清除阶段

  1. 标记阶段:将所有活动对象进行标记。 2.png
  2. 整理阶段:将内存中的活动对象移动到一端,使得空闲空间连续,并且没有碎片化。 3.png
  3. 清除阶段:将未标记的对象进行清除操作,并回收其占用的内存空间。 4.png
  • 优势

    • 解决了标记-清除算法的碎片化问题:标记-整理算法在清除阶段会将标记的对象整理到内存的一端,从而解决了标记-清除算法产生的碎片化问题。这样可以使得内存空间得到更好的利用,减少了空间的浪费。
    • 处理循环引用:标记-整理算法也能够处理循环引用的情况。
  • 存在的问题:

    • 垃圾回收过程中的停顿:标记-整理算法同样会暂停程序的执行,进行垃圾回收操作。当堆中对象较多时,可能会导致明显的停顿,影响用户体验。

V8是什么?

V8是一种用于执行JavaScript的开源引擎,主要用于浏览器和Node.js。它由Google开发,并且被用于Google Chrome浏览器中。V8引擎负责将JavaScript代码转换成机器代码,以便计算机可以理解和执行。它使用了即时编译技术,将JavaScript代码转换为高效的机器码,从而提高了JavaScript的执行速度。

V8垃圾回收策略

分代式垃圾回收

  • 为了提高垃圾回收的效率和性能,V8 引擎使用了分代式垃圾回收。分代式垃圾回收的基本思想是根据对象的存活时间将内存划分为不同的代(Generation),每一代都有不同的回收策略。根据统计数据,大部分对象的生命周期很短,而只有少部分对象会存活较长时间。因此,将内存按照对象的生命周期进行划分,可以更精确地对不同代的对象采取不同的回收策略,从而提高垃圾回收的效率和性能。

  • 具体来说,V8将内存划分为新生代(Young Generation)和老生代(Old Generation)两个代:

    • 新生代:存放的是存活时间较短的对象(经过一次垃圾回收后,就被释放回收掉),采用了基于Scavenge算法的快速垃圾回收策略,通过将内存分为两个半空间来进行垃圾回收,优化了对象的分配和回收过程。
    • 老生代:存放的是存活时间较长的对象(经过多次垃圾回收后仍存在),采用了基于标记-整理-清除算法的全垃圾回收策略,通过对整个堆进行标记和整理,以减少内存的碎片化,提高内存利用率。

通过采用分代式垃圾回收,V8能够根据对象的生命周期进行针对性的优化,减少不必要的垃圾回收操作,提高垃圾回收的效率和性能,从而提升JavaScript的执行速度和用户体验。

新生代垃圾回收

  • 在V8引擎中,副垃圾回收器主要负责管理新生代的垃圾回收。
  • 新生代的垃圾回收是基于Scavenge算法的快速垃圾回收策略,而Scavenge算法的具体实现中,主要采用了一种基于复制的Chenney算法
  • 新生代的内存空间被划分为两个等大小的空间,分别称为From空间To空间

5.png

  • 新对象首先被分配到From空间中,当From空间被占满时,就会触发垃圾回收机制。回收过程分为以下几个阶段:

    1. 标记阶段:从根对象(通常是全局对象)开始,通过引用关系进行遍历并标记所有活动对象。
    2. 复制阶段:将所有活动对象从From空间复制到To空间,并且进行排序,使得To空间成为连续的内存块。
    3. 清除阶段:对From空间进行清理,回收非活动对象所占用的内存空间。
    4. 空间交换:在清除阶段完成后,From空间和To空间的角色会发生交换,即From空间变为To空间,To空间变为From空间。这样,下一次的垃圾回收就可以在新的To空间中进行。
  • 新生代对象晋升机制:

    • 年龄达到阈值:每个对象都有一个年龄计数器,初始为0。每次经过一次垃圾回收,如果对象仍然存活,它的年龄计数器就会加1。当年龄计数器达到阈值时,对象就会被晋升到老生代内存。
    • To空间的内存占用达到一定比例:当To空间的内存占用超过一定比例(通常是25%到50%)时,也会触发对象的晋升。这是为了避免新生代内存过快地被填满,导致频繁的垃圾回收。

老生代垃圾回收

  • 在V8引擎中,主垃圾回收器主要负责管理老生代的垃圾回收。
  • 由于Scavenge算法在处理长时间存活和大规模对象存储时存在效率和内存利用率方面的不足,V8 引擎选择使用标记-清除(Mark-Sweep)和标记-整理(Mark-Compact)等算法来处理老生代的垃圾回收。这两种算法前面已经提到的,在标记清除的基础上将内存空间中产生大量不连续的内存碎片整理,使得内存空间连续。

Orinoco优化

orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它提出了增量标记、三色标记法、惰性清理、并发、并行等优化方法。

全停顿(Stop-The-World)

  • 概念:由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World)

  • 由于新生代空间小,并且存活对象少,再配合Scavenge算法,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象

并行垃圾回收

  • 并行回收机制:垃圾回收器在主线程中执行垃圾回收的任务的同时,再引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度。

  • 通过引入多个辅助线程来负担主线程中部分垃圾回收任务,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量。 6.png

增量垃圾回收

增量标记

  • 在上面提到的标记-清除算法会暂停应用程序的执行,全盘扫描并标记所有的可达对象,然后清除未标记的对象。这种暂停时间随着堆的大小增加而变得更长。

  • 增量标记则将标记过程分为多个阶段,每个阶段之间插入应用程序的执行。在每个阶段,垃圾回收器会扫描一部分对象进行标记,然后让应用程序继续执行一段时间。这样,垃圾回收和应用程序的执行交替进行,减少了单次标记的时间,从而降低了对应用程序的阻塞时间。 8.png

但是要怎样才能做到垃圾回收器随时暂停和重启,并且在重启的时候恢复到上一步执行的地方呢?还有另一个问题,标记好的数据若是在暂停垃圾回收的过程中被修改了如何处理?

  • 针对上面的两个问题,V8的解决方案分别是三色标记法写屏障

三色标记法

  • 在没有采用增量算法之前,老生代的垃圾回收是采用标记清理算法标记整理算法,单纯使用黑色和白色来标记数据,在一次执行完整的标记前,垃圾回收会将所有数据设置为白色,然后从根开始深度遍历,将所有能访问到的数据标记为黑色,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。

  • 如果这种标记策略,那么垃圾回收器执行了一段增量回收并暂停后启用主线程去执行了 JavaScript 应用程序;随后垃圾回收器再次被启动,这时候内存黑白色都有,我们便不能得知下一步走到哪里了。

  • 为此,V8使用了三色标记法来解决这个问题,其中的"三色"指的是三种不同的标记状态:白色、灰色和黑色

    1. 白色:表示对象尚未被垃圾回收器访问过,也意味着该对象可以被回收。
    2. 灰色:表示对象已被垃圾回收器访问过,但其引用的其他对象还未被访问。也表明目前正在处理这个对象。
    3. 黑色:表示对象已被垃圾回收器访问过,并且其引用的其他对象也已被访问和标记。黑色对象是安全的,不会被回收。
  • 从根节点开始标记

9.png

  • 回收器通过处理其指针将灰色对象变成黑色

10.png

  • 标记完成后的最终状态

11.png

引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。

写屏障

  • 在一次暂停垃圾回收后,接下来执行的 JavaScript 程序把标记好的对象引用关系修改了。当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了。 12.png

为了解决这个问题,V8 增量回收使用写屏障 (Write-barrier) 机制:当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了下一次增量标记阶段可以正确标记,这个方法也被称为强三色不变性

惰性清理

在标记阶段完成后,就要进入清除阶段了。V8采用的是惰性清理(Lazy Sweeping),采用这种延迟清理的原因是因为在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。

并发垃圾回收

  • 上面讲到的并行垃圾回收增量垃圾回收依然会阻塞主线程,接下来讲的并发垃圾回收就可以解决这个问题。
  • 并发回收机制:指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。

但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,主线程和辅助线程极有可能在同一时间去更改同一个对象,所以也要进行写屏障操作。

7.png

V8的优化总结

副垃圾回收器

  • 副垃圾回收器(新生代) 所采用的就是并行回收策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

主垃圾回收器

  • 2011 年,V8 将主垃圾回收器从 stop-the-world 标记切换到增量标记。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。
  • 以上三种方式各有优缺点,而主垃圾回收器(老生代) 就融合了这三种机制,来实现垃圾回收。
    • 首先主垃圾回收器主要使用并发标记,在主线程执行 JavaScript时,辅助线程就开始执行标记操作了,所以标记是在辅助线程中完成的。
    • 标记完成之后,再执行并行整理和清理操作。主线程在执行整理和清理操作时,多个辅助线程也在执行整理整理和清理操作。
    • 另外,主垃圾回收器还采用了增量回收的方式,整理和清理的任务会穿插在各种 JavaScript 任务之间执行。

13.png

小结

以上是我整理的关于js垃圾回收机制的内容,本人水平有限,如有错误欢迎在评论区指正,一起讨论!(๑•̀ㅂ•́)و✧