JS(v8)垃圾回收机制
Posted 冰雪奇缘lb
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS(v8)垃圾回收机制相关的知识,希望对你有一定的参考价值。
GC是什么
GC
即 Garbage Collection
,程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的,它工作在引擎内部,对于我们前端来说,GC 过程是相对比较无感的。
不是所有语言都有 GC
,一般的高级语言里面会自带 GC
,比如 Java、Python、javascript
等,也有无 GC
的语言,比如 C、C++
等,那这种就需要我们程序员手动管理内存了,相对比较麻烦。
JavaScript
自动垃圾收集机制
-
编写
JavaScript
程序时,开发者不需要手工跟踪内存的使用情况,只要按照标准写JavaScript
代码,JavaScript
程序运行所需的内存分配以及无用内存的回收完全是自动管理。 -
JavaScript
中自动垃圾回收机制的原理为:- 找出那些不再使用的变量,然后释放其占用的内存。
- 垃圾收集器会按照固定的时间间隔(或预定的收集时间) 周期性 地执行此操作。
局部变量的正常生命周期
在函数执行过程中,会为局部变量在栈内存(或 堆内存)上分配相应的空间来存储它们的值。在函数中使用这些变量,直至函数执行结束,此时可以释放局部变量的内存供将来需要时使用。
以上情况下,较容易判断变量是否有存在的必要,更复杂的情况需要更精细的变量追踪策略。
JavaScript
中的垃圾收集器必须跟踪每个变量是否有用,需要为不再有用的变量打上 标记,用于将来回收其占用的内存。
标识无用变量的策略通常有两个:标记清除
和 引用计数
。
标记清除
标记清除(Mark-Sweep)
,目前在 JavaScript引擎
里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎
都在采用 标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎
在运行垃圾回收的频率上有所差异。
标记清除采用的收集策略为:
● JavaScript
中的垃圾收集器运行时会给存储在内存中的所有变量都加上标记;
● 然后去掉环境中的变量以及被环境中的变量引用的变量的标记;
● 此后,再被加上标记的变量被视为准备删除的变量;
● 最后,垃圾收集器完成内存清除,销毁那些带标记的值并回收其占用的内存空间。
2008年之前,IE、Firefox、Opera、Chrome 和 Safari
的 JavaScript实现使用的均为 标记清除式
的垃圾回收策略,区别可能在垃圾收集的时间间隔。
优点
标记清除算法
的优点是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单。
缺点
标记清除算法
有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,会导致空闲内存空间是不连续
的,出现了 内存碎片
(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)。
标记清除算法
有两个很明显的缺点:
● 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块。
● 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n)
的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢。
PS:标记清除算法的缺点补充
归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了。
标记整理(Mark-Compact)算法
就可以有效地解决以上问题,标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)。
引用计数
引用计数算法 是另一种垃圾收集策略。引用计数的本质是 跟踪记录每个值被引用的次数。其执行机制如下:
● 当声明一个变量并将一个引用类型值赋值给该变量时,这个值的引用次数为1;
● 如果同一个值(变量)又被赋值给另一个变量,则该值的引用次数加1;
● 如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减1;
● 当这个值的引用次数为0时,则无法再访问这个值,就可回收其占用的内存空间。
垃圾收集器下次运行时,会释放那些引用次数为 0 的值所占用的内存。
引用计数存在一个致命的问题: 循环引用。循环引用是指,对象 A 中包含一个指向对象 B 的指针,而对象 B 中也包含一个指向对象 A 的引用。下面的代码就是标准的循环引用的例子:
function test()
let A = new Object()
let B = new Object()
A.b = B
B.a = A
如上所示,对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,按照引用计数的策略,两个对象的引用次数均为 2。函数执行完,两个对象的引用次数永不为0,会一直存在内存中,若多次调用,导致大量内存得不到回收。
我们再用标记清除的角度看一下,当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。
若采用标记清除策略,函数执行完毕,对象离开作用域就不存在相互引用。
IE8浏览器 之前中有一部分对象并不是原生的 JavaScript 对象,可能是使用 C++ 以 COM 对象的形式实现的(BOM, DOM)。而 COM 对象的垃圾收集机制采用的是 引用计数策略。即使 IE 的 JavaScript 引擎是使用标记清除策略实现的,但 JavaScript 访问 COM 对象仍然是基于 引用计数策略的。在这种情况下,只要在 IE 中涉及 COM 对象,就可能存在循环引用的问题。
IE中的循环引用与手动断开的操作如下所示:
// COM对象
let ele = document.getElementById("xxx")
let obj = new Object()
// 造成循环引用
obj.ele = ele
ele.obj = obj
// 切断引用关系
obj.ele = null
ele.obj = null
将变量设置成 null 即可切断变量与它之前引用的值之间的连接。下次垃圾收集器运行时,会删除这些值并回收它们占用的内存。
为解决上述问题,IE9及以上版本把 BOM 和 DOM 对象都转换成了真正的 JavaScript 对象,避免了两种垃圾回收算法并存引起的问题。
优点
引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。
标记清除算法
需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停,然后去执行一段时间的 GC
,另外,标记清除算法需要遍历对象来清除,而引用计数则只需要在引用时计数就可以了。
缺点
它需要一个计数器,计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。
垃圾回收的性能问题
垃圾收集器是周期运行的,确定 垃圾收集的时间间隔 是个重要的问题。
IE7之前的垃圾收集器是根据内存分配量运行的,即 256 个变量、4096 个对象(数组)字面量或 64 KB 的字符串。达到这些临界值的任何一个,垃圾收集器就会运行。所以就导致如果一个脚本含有很多变量,在整个生命周期中一直保有前面临界值大小的变量,就会频繁触发垃圾回收,会存在严重的性能问题。
IE7 重写了垃圾收集例程。新的工作方式为:触发垃圾收集的变量分配、字面量和数组元素的临界值被调整为 动态修正。初始值与之前版本相同,但如果垃圾收集例程回收的内存低于 15%,则临界值加倍。若回收内存分配量超过 85%,则临界值重置回默认值。
JavaScript V8 引擎的垃圾回收机制
现在大多数浏览器都是基于标记清除算法
,V8
亦是,当然 V8
肯定也对其进行了一些垃圾回收机制的优化。
在JavaScript脚本中,绝大多数对象的生存期很短,只有部分对象的生存期较长。所以,V8 中的垃圾回收主要使用的是 分代回收 (Generational collection)机制
。
分代回收机制
V8 中将堆内存分为 新生代
和 老生代
两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。
新生代 的内存一般都不大,所以使用 Scavenge 算法
进行垃圾回收效果比较好。老生代 一般占用内存较大,因此采用的是 标记清除算法
。
新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。
V8 引擎
将保存对象的 堆 (heap)
进行了分代
,V8 整个堆内存的大小就等于新生代
加上老生代
的内存(如下图)
新生代垃圾回收
新生代
对象是通过 Scavenge算法
进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法
。
Cheney算法
中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区,如下图所示。
新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。
当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。
当一个对象经过 2次 复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。
另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。
老生代垃圾回收
大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再用新生代一般分区复制方法就会非常耗时,从而导致回收执行效率不高。老生代垃圾回收器采用的就是上文所说的标记清除算法了。
前面我们也提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的标记整理算法来解决这一问题来优化空间。
并行回收(Parallel)
在介绍并行之前,我们先要了解一个概念 全停顿(Stop-The-World), JavaScript 是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿。
比如一次 GC 需要 60ms ,那我们的应用逻辑就得暂停 60ms ,假如一次 GC 的时间过长,对用户来说就可能造成页面卡顿等问题。
因此 V8 团队引入了并行回收机制。所谓并行,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。
简单来说,使用并行回收,假如本来是主线程一个人干活,它一个人需要 3 秒,现在叫上了 2 个辅助线程和主线程一块干活,那三个人一块干 1 秒就完事了,但是由于多人协同办公,所以需要加上一部分多人协同(同步开销)的时间我们算 0.5 秒好了,也就是说,采用并行策略后,本来要 3 秒的活现在 1.5 秒就可以干完了。
不过虽然 1.5 秒就可以干完了,时间也大大缩小了,但是这 1.5 秒内,主线程还是需要让出来的,这个过程内存是静态的,不需要考虑内存中对象的引用关系改变,只需要考虑协同。
新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针。
增量标记与懒性清理
对于一个堆很大,活跃对象有很多的脚本时,标记清除 效率可能会很慢,为减少垃圾回收引起的停顿,引入了 增量标记 和 惰性清理。
增量标记允许堆的标记(前面的标记阶段)发生在几次5-10毫秒的小停顿中。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。就像普通的标记一样,增量标记也是一个深度优先搜索,并同样采用白灰黑机制来分类对象。
我们上面所说的并行策略虽然可以增加垃圾回收的效率,对于新生代垃圾回收器能够有很好的优化,但是其实它还是一种全停顿式的垃圾回收方式,对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间。
所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记。
什么是增量
增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记(如下图)。
试想一下,将一次完整的 GC 标记分次执行,那在每一小次 GC 标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢?那假如我们在一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?
可以看出增量的实现要比并行复杂一点,V8 对这两个问题对应的解决方案分别是三色标记法与写屏障。
三色标记法(暂停与恢复)
因为新生区的内存一般都不大,所以使用 Scavenge 算法进行垃圾回收效果比较好。老生区一般占用内存较大,因此采用的是 标记-清除算法 与 标记紧缩算法。
老生代是采用标记清除算法,在没有采用增量算法之前,单纯使用黑色和白色来标记数据就可以了,其标记流程即在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所有能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。
如果采用非黑即白的标记策略,在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段 JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了。
为了解决这个问题,V8 团队采用了一种特殊方式: 三色标记法
三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑。
● 白色指的是未被标记的对象。
● 灰色指自身被标记,成员变量(该对象的引用对象)未被标记。
● 黑色指自身和成员变量皆被标记。
三色标记法的核心是 深度优先搜索,具体过程为:
● 在标记的初期,位图是空的,所有对象也都是白的。
● 从根可达的对象会被染色为灰色,并被放入标记用的一个单独分配的双端队列。
● 标记阶段的每次循环,GC会将一个对象从双端队列中取出,染色为黑,然后将它的邻接对象染色为灰,并把邻接对象放入双端队列。
● 这一过程在双端队列为空且所有对象都变黑时结束。
● 特别大的对象,如长数组,可能会在处理时分片,以防溢出双端队列。如果双端队列溢出了,则对象仍然会被染为灰色,但不会再被放入队列(这样他们的邻接对象就没有机会再染色了)。
● 因此当双端队列为空时,GC仍然需要扫描一次,确保所有的灰对象都成为了黑对象。对于未被染黑的灰对象,GC会将其再次放入队列,再度处理。
如上图所示,最初所有的对象都是白色,意味着回收器没有标记它们,从一组根对象开始,先将这组根对象标记为灰色并推入到标记工作表(双端队列)中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变成黑色,并将自身的下一个引用对象转为灰色。
就这样一直往下走,直到没有可标记灰色的对象时,也就是无可达(无引用到)的对象了,那么剩下的所有白色对象都是无法到达的,即等待回收(如上图中的 C、E 将要等待回收)。
采用三色标记法后我们在恢复执行时就好办多了,可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以
三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少 全停顿 的时间。
写屏障(增量中修改引用)
一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用。举个例子(如图)
假如我们有 A、B、C 三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行应用程序也就是 JavaScript 脚本,在脚本中我们将对象 B 的指向由对象 C 改为了对象 D ,接着恢复执行下一次增量分段。
这时其实对象 C 已经无引用关系了,但是目前它是黑色(代表活动对象)此一整轮 GC 是不会清理 C 的,不过我们可以不考虑这个,因为就算此轮不清理等下一轮 GC 也会清理,这对我们程序运行并没有太大影响。
我们再看新的对象 D 是初始的白色,按照我们上面所说,已经没有灰色对象了,也就是全部标记完毕接下来要进行清理了,新修改的白色对象 D 将在次轮 GC 的清理阶段被回收,还有引用关系就被回收,后面我们程序里可能还会用到对象 D 呢,这肯定是不对的。
为了解决这个问题,V8 增量回收使用 写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性。
那在我们上图的例子中,将对象 B 的指向由对象 C 改为对象 D 后,白色对象 D 会被强制改为灰色。
惰性清理
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)。
惰性清理是指在标记完成后,并不急着释放空间,无需一次清理所有的页,垃圾回收器会视情况逐一清理,直到所有页都清理完成。
增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。
增量标记与惰性清理的优缺?
增量标记与惰性清理的出现,使得主线程的停顿时间大大减少了,让用户与浏览器交互的过程变得更加流畅。但是由于每个小的增量标记之间执行了 JavaScript 代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记缺点也很明显:
首先是并没有减少主线程的总暂停的时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量。
并发回收(Concurrent)
前面我们说并行回收依然会阻塞主线程,增量标记同样有增加了总暂停时间、降低应用程序吞吐量两个缺点,那么怎么才能在不阻塞主线程的情况下执行垃圾回收并且与增量相比更高效呢?
这就要说到并发回收了,它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起(如下图)。
辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点,这里我们不再细说。
再说V8中GC优化
V8 的垃圾回收策略主要基于分代式垃圾回收机制,这我们说过,关于新生代垃圾回收器,我们说使用并行回收可以很好的增加垃圾回收的效率,那老生代垃圾回收器用的哪个策略呢?我上面说了并行回收、增量标记与惰性清理、并发回收这几种回收方式来提高效率、优化体验,看着一个比一个好,那老生代垃圾回收器到底用的哪个策略?难道是并发?
其实,这三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的。
老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行。
以上是 V8 引擎为我们的垃圾回收所做的一些主要优化了,虽然引擎有优化,但并不是说我们就可以完全不用关心垃圾回收这块了,我们的代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,那当不再用到的内存,没有及时回收时,我们叫它 内存泄漏。
Scavenge 算法
由于垃圾清理发生的比较频繁,清理的过程必须很快。V8 中的清理过程使用的是 Scavenge 算法,按照 经典的 Cheney 算法 实现的。Scavenge 算法的主要过程是:
● 新生区被分为两个等大小的子区(semi-spaces):to-space 和 from-space;
● 大多数的内存分配都是在 to-space 发生 (某些特定对象是在老生区);
● 当 to-space 耗尽时,交换 to-space 和 from-space, 此时所有的对象都在 from-space;
● 然后将 from-space 中活跃的对象复制到 to-space 或者老生区中;
● 这些对象被直接压到 to-space,提升了 Cache 的内存局部性,可使内存分配简洁快速。
算法的伪代码描述如下:
def scavenge():
swap(fromSpace, toSpace)
allocationPtr = toSpace.bottom
scanPtr = toSpace.bottom
for i = 0..len(roots):
root = roots[i]
if inFromSpace(root):
rootCopy = copyObject(&allocationPtr, root)
setForwardingAddress(root, rootCopy)
roots[i] = rootCopy
while scanPtr < allocationPtr:
obj = object at scanPtr
scanPtr += size(obj)
n = sizeInWords(obj)
for i = 0..n:
if isPointer(obj[i]) and not inOldSpace(obj[i]):
fromNeighbor = obj[i]
if hasForwardingAddress(fromNeighbor):
toNeighbor = getForwardingAddress(fromNeighbor)
else:
toNeighbor = copyObject(&allocationPtr, fromNeighbor)
setForwardingAddress(fromNeighbor, toNeighbor)
obj[i] = toNeighbor
def copyObject(*allocationPtr, object):
copy = *allocationPtr
*allocationPtr += size(object)
memcpy(copy, object, size(object))
return copy
参考:
○ https://juejin.cn/post/6844903858972409869
○ https://juejin.cn/post/6844903858972409869#heading-15
○ http://www.ruanyifeng.com/blog/2017/04/memory-leak.html
以上是关于JS(v8)垃圾回收机制的主要内容,如果未能解决你的问题,请参考以下文章
Chrome V8系列--浅析Chrome V8引擎中的垃圾回收机制和内存泄露优化策略[转]