阅读 325

js中的垃圾回收机制(v8引擎)

垃圾回收GC

垃圾回收(GC,Garbage Collection),这里的垃圾就是指程序中不需要再用的数据,js引擎中的垃圾回收机制会自动回收垃圾。

垃圾回收分为手动回收和自动回收,不同语言的垃圾回收机制不一样。

常见的GC算法

引用计数法

顾名思义,让所有对象实现记录下有多少“程序”在引用自己,让各对象都知道自己的“人气指数”。举一个简单的例子:

var a = new Object(); // 此时'这个对象'的引用计数为1(a在引用) var b = a; // ‘这个对象’的引用计数是2(a,b) a = null; // reference_count = 1 b = null; // reference_count = 0  // 下一步 GC来回收‘这个对象’了 复制代码

优点:

即时回收,当引用为0时就会把对象连向空闲链表等待回收;

不用遍历堆区活动对象和非活动对象

缺点:

无法解决循环引用;(两个对象或多个形成引用环,引用计数不为0故无法回收)

每个对象都要有引用计数器,内存开销大;

tip:该算法已经逐渐被 ‘标记-清除’ 算法替代,在V8引擎里面,使用最多的就是 标记-清除算法

标记清除算法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异

标记清除算法主要过程字如其名:标记——清除

标记是把所有的活动对象做标记,清除是将未标记的对象销毁(非活动对象)。

大致如下:

  1. 将所有的变量加上标记(通过加一个二进制位,维护进出环境的列表等等方式),计为垃圾;

  2. 通过一个根节点开始深度遍历(出发点有很多,我们称之为一组 对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象文档DOM树 等),把遍历到的对象记为活动对象

  3. 清除所有垃圾标记的对象,销毁并释放其内存空间。

  4. 将所有对象重新记为垃圾,等待下一轮回收

优点

做标记很简单,0和1。

缺点

回收垃圾后导致内存碎片的问题

10.jpg

垃圾是回收完了,内存空间也释放了,那么问题来了:创建新对象分配的空间在哪一块呢?

11.jpg

这里寻找合适的空间有三种算法

First-fit,一旦找到大于等于size空间块就返回

Best-fit,遍历整个空闲列表,返回大于等于size空间的最小分块

Worst-fit,遍历,找到最大的分块,分成刚好size空间的块和剩下的空间块,返回刚好大小的空间。

这三种策略里面 Worst-fit的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit和 Best-fit来说,考虑到分配的速度和效率 First-fit是更为明智的选择

产生的空间碎片和分配效率的问题有了优化的策略:标记整理

12.jpg

这个策略在上面的过程中多加了一步:在变量标记后将活动对象向一端移动,最后清除掉边界的内存。

复制算法

将一块内存分成两部分,From空间和To空间,每次回收时候将From空间里的活动对象移动至To空间,移动完后将From空间释放。**From空间和To空间互换。**等待进行下一轮回收。

13.jpg

js内存模型

JS内存空间分为栈(stack)堆(heap)池(一般也会归类为栈中)。 其中存放变量,存放复杂对象,存放常量。

对于基础数据类型,栈中的变量的值刚好存在这里;对于引用数据类型,变量的值是引用(即内存地址),这个地址指向堆区的一片空间。

栈空间的垃圾回收

一般来说,执行上下文每次出栈意味着这个执行上下文已经执行完毕,内存需要回收,它的回收很简单,原理就是覆盖。

function foo(){ var a = 1 var b = {name:"极客邦"} function showName(){ var c = "极客时间" var d = {name:"极客时间"} } showName() } foo() 复制代码

当执行到showName函数末尾(即第六行) 调用栈和堆空间情况如下

13-1.png

有⼀个记录当前执⾏状态的指针(称为 ESP),指向调⽤栈中showName函数的执⾏上下⽂,表⽰当前正在执⾏showName函数。

重点来了,当showName函数执行完后,这个执行上下文是怎么销毁内存的呢?

答案很简单,直接将ESP指针下移就行,如果在开辟新的执行上下文会将showName函数执行上下文直接覆盖掉

13-2.png

同样的当foo函数也执行完,ESP继续下移指向全局执行上下文

所以,对于栈空间的垃圾回收,是通过ESP指针移动来完成的。

堆空间的垃圾回收(V8引擎)

代际假说和分代收集

代际假说:

第⼀个是⼤部分对象在内存中存在的时间很短,简单来说,就是很多对象⼀经分配内存,很快就变得不可

访问;

第⼆个是不死的对象,会活得更久。

通常,垃圾回收算法有很多种,但是并没有哪⼀种能胜任所有的场景,你需要权衡各种场景,根据对象的⽣ 存周期的不同⽽使⽤不同的算法,以便达到最好的效果,根据代际假说,v8引擎将堆分为新生代和老生代两个区间。新生代存放存活时间短的对象,老生代存放存活时间长的对象。

利用标记清除

v8引擎采用标记清除策略来回收机制。

主要过程如下:

  1. 标记活动对象和非活动对象

  2. 回收非活动对象内存

  3. 内存整理(解决内存碎片的问题)

新生代的回收过程

采用的是Scavenge算法,其实和上文中的复制算法很相似。

新生代分为对象区域和空闲区域,空间很小,新加入的对象放入对象区域,当对象区域块满了后,进行算法的执行:

把对象区域中所有的活动对象有序移动至空闲区域(这样解决了内存碎片的问题),然后释放对象区域的内存,接着对象区域和空闲区域互换。

08.png

这个过程有一个问题,对象区域的剩余空间会随着回收次数的增加越来越少,这个时候会有新生代的对象晋升至老生代

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

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

老生代的回收过程

老生代采用的就是之前所说的标记清除算法

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

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

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

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

09.png

相关的一些问题和优化

全停顿

众所周知,JS是一门单线程的语言,运行在主线程上,垃圾回收进行时候会阻塞JS代码的执行,等待垃圾回收完毕后才会继续执行JS,这种行为就叫做全停顿

在v8新生代的垃圾回收中,因为空间较小,全停顿的影响不大,但对于存放大对象的大空间老生代中,标记,清除,整理碎片等这一系列过程就会有很久。如果页面中有JS动画执行,这个垃圾回收执行了200ms就会造成页面的卡顿,影响体验。

于是为了解决这个问题,有了增量标记

增量标记

为了降低老生代的垃圾回收而造成的卡顿,v8把一次GC的标记分成了很多很多的子标记过程,同时让JS执行和子标记交替执行,直到这次的GC的标记完成。

10.jpg

把GC的标记过程分为很多子过程就有效缓解了因GC过程过长导致的页面卡顿问题,但是这又带来了新的两个问题:

  1. 每次子过程标记了活动对象,而下一次标记过程从哪开始呢?

  2. 在增量标记的间隙(JS代码执行)中,对已经标记过的对象中修改了某些引用又怎么处理?

下面依次给出v8引擎的解决方案: 三色标记,写屏障。

三色标记

之前一直采用的是标记清除算法,用一位二进制0和1(白和黑),标记过的是黑色,表示是活动对象。其标记流程即在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所有能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。但是在增量标记中就不能判断已经标记到了哪,于是多加了一个颜色表示状态

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

  • 白色指的是未被标记的对象

  • 灰色指自身被标记,成员变量(该对象的引用对象)未被标记

  • 黑色指自身和成员变量皆被标记

14.jpg

如上图所示,我们用最简单的表达方式来解释这一过程,最初所有的对象都是白色,意味着回收器没有标记它们,从一组根对象开始,先将这组根对象标记为灰色并推入到标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变成黑色,并将自身的下一个引用对象转为灰色

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

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

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

写屏障

15.jpg

假如我们有 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 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记


作者:C大调永远滴神
链接:https://juejin.cn/post/7019626125814923278


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐