likes
comments
collection
share

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

作者站长头像
站长
· 阅读数 59

一、概述

我们知道JavaScript的自动垃圾回收,不用程序员手动回收内存,为了更加了解内存的回收机制,本文浅浅探讨 JavaScript 和 Google V8引擎的垃圾回收机制以及那些操作会造成内存泄漏

  • 了解JavaScript和V8引擎垃圾回收机制
  • 了解常见的内存泄漏操作以及排查方法

该文章会分为以下三个方面来讲解:

  1. 垃圾回收机制
  2. V8引擎对垃圾回收机制的优化
  3. 常见的内存泄漏

这是文章的 第二部分 《V8引擎对垃圾回收机制的优化》

二、V8引擎 对GC的优化

2.1 分代式垃圾回收

上文所说的垃圾回收策略在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些大、老、存活时间长的对象来说,同新、小、存活时间短的对象一个频率的检查很不好,因为前者需要时间长并且不需要频繁进行清理,后者恰好相反,这也是分代式的原则。

2.1.1 新生代回收 & 老生代回收

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持1~8M的容量,而老生代的对象为存活时间较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大

分为两种垃圾回收器

  • 新生代垃圾回收器
  • 老生代垃圾回收器

新生代垃圾回收器

新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge 算法 的具体实现中,主要采用了一种复制式的方法即 Cheney 算法

Cheney 算法 中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。

怎么清理的呢?

当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。

当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。

另外还有一种情况

如果复制一个对象到空闲区时,空闲区空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中,设置为25%的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。

老生代回收器

相比于新生代,老生代的垃圾回收就比较容易理解了,上面我们说过,对于大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再如新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是标记清除了。

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。

清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。

前面我们也提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了上文中说的 标记整理算法(在第一部分中2.3.1) 来解决这一问题来优化空间。

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。

2.1.2 并行回收(Parallel)

我们都知道 JavaScript 是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿

举个例子: JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

比如一次 GC 需要 60ms ,那我们的应用逻辑就得暂停 60ms ,假如一次 GC 的时间过长,对用户来说就可能造成页面卡顿等问题。这里,引入多个辅助线程来同时处理,以此加速垃圾回收的执行速度,因此 V8 团队引入了并行回收机制。

所谓并行,也就是同时的意思,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

简单来说,使用并行回收,假如本来是主线程一个人干活,它一个人需要 3 秒,现在叫上了 2 个辅助线程和主线程一块干活,那三个人一块干一个人干 1 秒就完事了,但是由于多人协同办公,所以需要加上一部分多人协同(同步开销)的时间我们算 0.5 秒好了,也就是说,采用并行策略后,本来要 3 秒的活现在 1.5 秒就可以干完了。也就是说多个人干活肯定快一些。

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

2.1.3 增量标记

上文说到使用 并行回收 可以提高回收效率,对于新生代垃圾回收器能够有很好的优化,但是它还是一种全停顿式的垃圾回收方式,对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间。

所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从 全停顿标记切换到增量标记

什么是增量标记

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记,举个例子:

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

完整的 GC 标记分次执行,那在每一小次 GC 标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢?那假如我们在一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?

三色标记法(暂停与恢复)

我们知道老生代是采用标记清理算法,上面的增量标记有一个缺陷就是 在切换回GC的时候不知道执行到哪里了, 用黑色和白色来比喻,在执行完整的GC之前,垃圾回收器将所有数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所有能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。

如果采用非黑即白的标记策略,那在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了

为了解决这个问题,V8 团队采用了一种特殊方式: 三色标记法

三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑

  1. 白色指的是未被标记的对象;
  2. 灰色指自身被标记,成员变量(该对象的引用对象)未被标记;
  3. 黑色指自身和成员变量皆被标记;

举个例子

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

如上图所示

最初所有对象都是白色,回收器没有对他们进行标记,从一组根对象开始,先将这组根对象标记为灰色并推到标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变为黑色,并将自身的下一个引用对象转为灰色;

就这样一直往下走,直到没有可标记灰色的对象时,也就是无可达(无引用)的对象了,那么剩下的所有白色对象都是无法到达的,即等待回收(如上图中的 C、E 将要等待回收)。

采用三色标记法后我们在恢复执行时就好办多了,可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以。

三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少 全停顿 的时间。

写屏障(增量中修改引用)

一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用,简单来说就是 在GC中已经标记过之后,到JS线程的时候有将其引用修改

举个例子:

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

假如我们有 A、B、C 三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行应用程序也就是 JavaScript 脚本,在脚本中我们将对象 B 的指向由对象 C 改为了对象 D ,接着恢复执行下一次增量分段。

这时其实对象 C 已经无引用关系了,但是目前它是黑色(代表活动对象)此一整轮 GC 是不会清理 C 的,不过我们可以不考虑这个,因为就算此轮不清理等下一轮 GC 也会清理,这对我们程序运行并没有太大影响。

我们再看新的对象 D 是初始的白色,按照我们上面所说,已经没有灰色对象了,也就是全部标记完毕接下来要进行清理了,新修改的白色对象 D 将在次轮 GC 的清理阶段被回收,还有引用关系就被回收,后面我们程序里可能还会用到对象D 呢,这肯定是不对的。

为了解决这个问题,V8 增量回收使用 写屏障 ( Write-barrier ) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性

2.1.4 惰性清理

增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理( Lazy Sweeping)增量标记完成后,惰性清理就开始了。

当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。

增量标记与惰性清理的优缺?

增量标记与惰性清理的出现,使得主线程的停顿时间大大减少了,让用户与浏览器交互的过程变得更加流畅。但是由于每个小的增量标记之间执行了 JavaScript 代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记缺点也很明显:

  1. 并没有减少主线程的总暂停的时间,甚至会略微增加;
  2. 由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量;

2.2 并发回收(Concurrent)

和上面讲到的 并行回收 有区别

前面我们说并行回收依然会阻塞主线程,增量标记同样有增加了总暂停时间、降低应用程序吞吐量两个缺点,那么怎么才能在不阻塞主线程的情况下执行垃圾回收并且与增量相比更高效呢?

这就要说到并发回收了,它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起:

JavaScript 垃圾回收机制和内存泄漏 - 第二部分(2/3)

辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点。

三、总结

V8 的垃圾回收策略主要基于分代式垃圾回收机制,这我们说过,关于新生代垃圾回收器,我们说使用并行回收可以很好的增加垃圾回收的效率,上述的这三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的:

  1. 新生代主要使用分为了使用区和空闲区,使用相关的算法进行垃圾回收机制;
  2. 老生代使用标记清除算法,还是使用了并行回收增量标记惰性清理来辅提高回收效率,增量标记中使用三色标记法来达到暂停和恢复的作用;
  3. 还是使用并发回收使回收更高效;

其他部分

转载自:https://juejin.cn/post/7179171207525171255
评论
请登录