注册
web

js的垃圾回收机制

概论


对于js的垃圾回收,很多人的理解还停留在引用计数和标记清除的阶段。


有人会说,学习这个,对业务代码开发没啥作用。但是我想说,了解了这些基础的东西之后,才能更好地组织代码,在写代码的时候,才能做到心中能有个框架,知道浏览器到底发生了什么。


我也不是科班出身,很多东西不清不楚的。但我感觉计算机行业有个很好的地方,就是学了的知识很快就能得到验证,就能在生产上应用。这种成就感是我当年干机械的时候所无法体验到的。


前几天就有个报障说有个项目越用越卡,但是排查不出问题,我最近正好在学习垃圾回收内存泄漏,就立马能分析出来是不是内存不足产生的影响,就很开心。


本文我会采用图解的方式,尽量照着js垃圾回收的演变历史讲解。


一,什么是垃圾回收


GCGarbage Collection,也就是我们常说的垃圾回收。


我们知道,js是v8引擎编译执行的,而代码的执行就需要内存的参与,内存往往是有限的,为了更好地利用内存资源,就需要把没用的内存回收,以便重新使用。


比如V8引擎在执行代码的过程中遇到了一个函数,那么我们会创建一个函数执行上下文环境并添加到调用栈顶部,函数的作用域里面包含了函数中所有的变量信息,在执行过程中我们分配内存创建这些变量,当函数执行完毕后函数作用域会被销毁,那么这个作用域包含的变量也就失去了作用,而销毁它们回收内存的过程,就叫做垃圾回收。


如下代码:


var testObj1={
a:1
}
testObj1={
b:2
}

对应的内存情况如下:


1,垃圾的产生.drawio.png
其中堆中的{a:1}就变成了垃圾,需要被GC回收掉。


在C / C++中,需要开发者跟踪内存的使用和管理内存。而js等高级语言,代码在执行期间,是V8引擎在为我们执行垃圾回收。


那么既然已经有v8引擎自动给我们回收垃圾了,为啥我们还需要了解V8引擎的垃圾回收机制呢?这是因为依据这个机制,还有些内存无法回收,会造成内存泄漏。具体的表现就是随着项目运行时间的变成,系统越来越卡滞,需要手动刷新浏览器才能恢复。


了解V8的垃圾回收机制,才能让我们更好地书写代码,规避不必要的内存泄漏。


二,内存的生命周期


如上所说,内存应该存在这样三个生命周期:




  1. 分配所需要的内存:在js代码执行的时候,基本数据类型存储在栈空间,而引用数据类型存储在堆空间。




  2. 使用分配的空间:可能对对应的值做一些修改。




  3. 不需要时将其释放回收。


    如下代码:


    function fn(){
    //创建对象,分配空间
    var testObj={
    a:1
    }
    //修改内容
    testObj.a=2
    }
    fn()//调用栈执行完毕,垃圾回收

    对应的内存示意图:




2,内存的生命周期.drawio.png


三,垃圾回收的策略


当函数执行完毕,js引擎是通过移动ESP(ESP:记录当前执行状态的指针)来销毁函数保存在栈当中的执行上下文的,栈顶的空间会被自动回收,不需要V8引擎的垃圾回收机制出面。


然而,堆内存的大小是不固定的,那堆内存中的数据是如何回收的呢?


这就引出了垃圾回收的策略。


通常用采用的垃圾回收有两种方法:引用计数(reference counting)标记清除(mark and sweep)


3.1,引用计数(reference counting)


如上文第二节中所说,testObj对象存放在堆空间,我们想要使用的时候,都是通过指针来访问,那么是不是只要没有额外的指针指向它,就可以判定为它不再被使用呢?


基于这个想法,人们想出了引用计数的算法。


它工作原理是跟踪每个对象被引用的次数,当对象的引用次数变为 0 时,则判定该对象为无用对象, 可以被垃圾回收机制进行回收。


    function fn(){
//创建对象,分配空间
var testObj1={
a:1
}//引用数:1
var testObj2=testObj1//引用数:2
var testObj3=testObj1//引用数:3
var testObj4={
b:testObj1
}//引用数:4
testObj1=null//引用数:3
testObj2=null//引用数:2
testObj3=null//引用数:1
testObj4=null//引用数:1
}
fn()//调用栈执行完毕,垃圾回收

如上代码,引用次数变成0后,堆内存中的对应内存就会被GC。


如下图,当testObj1-4都变成null后,原来的testObj4引用数变成0,而{a:1}这时候的引用数还为1(有一个箭头指向它),而{b:1002}被回收后,它的引用数就变成0,故而最后也被垃圾回收。


3,引用计数的计数数量.drawio.png


引用计数的优点:


引用计数看起来很简单,v8引擎只需要关注计数器即可,一旦对象的引用数变成0,就立即回收。

但是很明显的,引用计数存在两个缺点:


1,每个对象都需要维护一个计数器去记录它的引用数量。
2,如果存在相互循环引用的对象,因为各自的引用数量无法变成0(除非手动改变),因而无法被垃圾回收。

对于第二点,如下代码:


function fn(){
//创建对象,分配空间
var testObj1={
a:testObj2
}
var testObj2={
b:testObj1
}
}
fn()

当fn执行完毕后的内存情况如下,因为两个对象相互引用,导致引用数到不了0,就无法被GC:


4.循环引用.drawio.png


因为引用计数的弊端,后续的浏览器开始寻找新的垃圾回收机制,从2012年起,所有现代浏览器都使用了标记清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记清除算法的改进。


3.2,标记清除(mark and sweep)


标记清除是另一种常见的垃圾回收机制。


它工作原理是找出所有活动对象并标记它们,然后清除所有未被标记的对象


其实现步骤如下:



  1. 根节点:垃圾回收机制的起点是一组称为根的对象(有很多根对象),根通常是引擎内部全局变量的引用,或者是一组预定义的变量名,例如浏览器环境中的 Window 对象和 Document 对象。
  2. 遍历标记:从根开始遍历引用的对象,将其标记为活动对象。每个活动对象的所有引用也必须被遍历并标记为活动对象。
  3. 清除:垃圾回收器会清除所有未标记的对象,并使空间可用于后续使用。

因为能从根节点开始被遍历到的(有被使用到的),就是有用的活动对象,而剩余不能被链接到的则是无用的垃圾,需要被清除。


对于前文引用计数中循环引用的例子,就因为从根对象触发,无法遍历到堆空间中的那两个循环引用的对象,就会把它判定为垃圾对象,从而回收。


如下代码:


var obj1={
a:{
b:{
c:3
}
}
}
var obj2={
d:1
}
obj2=null

如下图,从根节点无法遍历到obj2了,就会把d垃圾回收。


5,标记清除.png


按照这个思路,标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,会导致空闲内存空间是不连续的,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题:当新对象需要空间存储时,需要遍历空间以找到能够容纳对象大小size的区域:


6,标记清除新增对象.png


这样效率比较低,因而又有了标记整理(Mark-Compact)算法 ,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会先将活动对象向内存的一端移动,然后再回收未标记的垃圾内存:


7,标记整理算法.png


四,V8引擎的分代回收


如上文所说,在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些占用空间大、存活时间长的对象,要是和占用空间小、存活时间短的对象一起检查,那不是平白浪费很多不必要的检查资源嘛。


因为前者需要时间长并且不需要频繁进行清理,后者恰好相反,那怎么优化呢?


类似于信誉分,信誉分高的,检查力度就应该小一些嘛。把信誉分抽象一下,其实说的就是分层级管理,于是就有了弱分代假设。


4.1,弱分代假设(The Weak Generational Hypothesis)



  1. 多数对象的生命周期短
  2. 生命周期长的对象,一般是常驻对象

V8的GC也是基于假设将对象分为两代: 新生代和老生代。


对不同的分代执行不同的算法可以更有效的执行垃圾回收。


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


他们的内存大小如下:


64位操作系统32位操作系统
V8内存大小1.3G(1432MB)0.7g(716MB)
新生代空间32MB16MB
老生代空间1400MB700MB

4.2,新生代的垃圾回收策略


新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收。


在JavaScript中,任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。Scavenge算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。


Scavenge 算法中将新生代内存一分为二,Semi space FromSemi space To,新生区通常只支持 1~8M 的容量。这块区域使用副垃圾回收器来回收垃圾。


工作方式也很简单:


1,等From空间满了以后,垃圾回收器就会把活跃对象打上标记。
2,把From空间已经被标记的活动对象复制到To空间。
3,将From空间的所有对象垃圾回收。
4,两个空间交换,To空间变成From空间,From变成To空间。以此往复。

而判断是否是活跃对象的方法,还是利用的上文说的从根节点遍历,满足可达性则是活跃对象。


具体流程如下图所示,假设有蓝色指针指向的是From空间,没有蓝色指针指向的是To空间:


8,新生代的垃圾回收策略.drawio.png


从上图可以明显地看到,这种方式解决了上文垃圾回收后内存碎片不连续的问题,相当于是利用空间换时间。


现在新生代空间的垃圾回收策略已经了解,那新生代空间中的对象又如何进入老生代空间呢?


4.3,新生代空间对象晋升老生代空间的条件


1,复制某个对象进入to区域时,如果发现内存占用超过to区域的25%,则将其晋升老生代空间。(因为互换空间后要留足够大的区域给新创建对象)
2,经过两次fromto互换后,还存活的对象,下次复制进to区域前,直接晋升老生代空间。

4.4,老生代空间的垃圾回收策略


老生代空间最初的回收策略很简单,这在我们上文也讲过,就是标记整理算法。


1,先根据可达性,给所有的老生代空间中的活动对象打上标记。
2,将活动对象向内存的一端移动,然后再回收未标记的垃圾内存。

这样看起来已经很完美了,但是我们知道js是个单线程的语言,就目前而言,我们的垃圾回收还是全停顿标记:js是运行在主线程上的,一旦垃圾回收生效,js脚本就会暂停执行,等到垃圾回收完成,再继续执行


这样很容易造成页面无响应的情况,尤其是在多对象、大对象、引用层级过深的情况下。


于是在这基础上,又有了增量标记的优化。


五,V8优化


5.1,增量标记


前文所说,我们给老生代空间中的所有对象打上活动对象的标记,是从一组根节点出发,根据可达性遍历而得。这就是全量地遍历,一次性完成,


但因为js是单线程,为了避免标记导致主线程卡滞。于是人们想出来和分片一样的思路:主线程每次遍历一部分,就去干其他活,然后再接着遍历。如下图:


9,增量标记.png


增量标记就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记,这样就算页面卡滞,因为时间很短,使用者也感受不到,体验就好了很多。


但是这又引发了一个新的问题:每次遍历一部分节点就停下来,下次继续怎么识别到停顿点,然后继续遍历呢?


V8又引入了三色标记法。


5.2,三色标记法


首先要明确初心:三色标记法要解决的问题是遍历节点的暂停与恢复。


使用两个标志位编码三种颜色:白色(00),灰色(10)和黑色(11)。


白色:指的是未被标记的对象,可以回收


灰色:指自身被标记,成员变量(该对象的引用对象)未被标记,即遍历到它了,但是它的下线还没遍历。不可回收


黑色:自身和成员变量都被标记了,是活动对象。不可回收


1,从已知对象开始,即roots(全局对象和激活函数), 将所有非root对象标记置为白色
2,将root对象变黑,同时将root的直接引用对象abc标记为灰色
3,将abc标记为黑色,同时将它们的直接引用对象标记为灰色
4,直到没有可标记灰色的对象时,开始回收所有白色的对象

10,三色标记法.drawio.png


如上图所示,如果第一次增量标记只标记到(2),下次开始时,只要找到灰色节点,继续遍历标记即可。


而遍历标记完成的标志就是内存中不再有灰色的。于是这时候就可以把白色的垃圾回收掉。


那这样就解决了遍历节点的暂停与恢复问题,同时支持增量标记。


(ps:其实这里我有个疑惑,暂停后重新开始的时候,不也要遍历寻找灰色节点嘛,每次恢复都要遍历找灰色节点,不是也耗时嘛?)


5.3,写屏障


按照上文对标记的描述,其实有一个前提条件:在标记期间,代码运行不会变更对象的引用情况。


比如说我采用的是增量标记,前脚刚做好的标记,后脚就被js脚本修改了引用关系,那不是会导致标记结果不可信嘛?如下图:


11,写屏障.drawio.png


就像上图一样,D已经被判定成垃圾了,但是下一个分片的js又引用了它,这时候如果删除,必然不对,所以V8 增量使用 写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性


那在我们上图的例子中,将对象 B 的指向由对象 C 改为对象 D 后,白色对象 D 会被强制改为灰色。


这样一来,就不会将D判定为垃圾 ,并且图中新增的垃圾C在本轮垃圾回收中也不会回收,而是在下一轮回收了。


5.4,惰性清理


上文的增量标记和三色标记法以及写屏障只是对标记方式的优化。目的是采用分片的思想将标记的流程碎片化。


而清理阶段同样可以利用这个思想。


V8的懒性清理,也称为惰性清理(Lazy Sweeping),是一种垃圾回收机制,用于延迟清理未标记对象所占用的内存空间,以减少垃圾回收期间的停顿时间。


当增量标记结束后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,于是可以将清理的过程延迟一下,让JavaScript逻辑代码先执行;也无需一次性清理完所有非活动对象内存,垃圾回收器可以按需逐一进行清理,直到所有的页都清理完毕。


六,垃圾回收总结


6.1,初始的垃圾回收策略:从引用计数到标记清除


对于js的垃圾回收,最开始的时候,是采用引用计数的算法,但是因为引用计数存在循环引用导致垃圾无法清除,于是又引入了标记清除算法,而标记清除算法存在碎片空间问题,于是又优化成标记整理算法。


随着技术的发展,v8引擎的垃圾回收机制也在不断完善。


6.2,弱分代假设,划分新老生代空间采用不同策略


第一次完善是采用弱分代假设,为了让内存占用大、存活时间长的对象减少遍历,采用分代模型,分成了新分代和老分代空间,垃圾回收采取不同的策略。


新生代空间以空间换时间,拆分成from和to空间互换位置,解决垃圾回收后内存不连续的问题。


将满足条件的对象晋升到老生代空间。而老生代空间采用标记整理算法。


6.3,从全停顿到引入分片思想


因为js是单线程,如果垃圾回收耗时过长,就会阻塞页面响应。


为了解决标记阶段的全停顿问题,引入了增量标记算法。但是非黑即白的标记算法在下一次重新开始标记时无法找到上次的中断点,所以使用三色标记法。此外,为了避免增量标记过程中js脚本变更引用关系,v8又增加了写屏障。


同样的,为了解决清理阶段的全停顿问题,引入了惰性清理。


七,本系列其他文章


最近在整理js基础,下面是已经完成的文章:


js从编译到执行过程 - 掘金 (juejin.cn)


从异步到promise - 掘金 (juejin.cn)


从promise到await - 掘金 (juejin.cn)


浅谈异步编程中错误的捕获 - 掘金 (juejin.cn)


作用域和作用域链 - 掘金 (juejin.cn)


原型链和原型对象 - 掘金 (juejin.cn)


this的指向原理浅谈 - 掘金 (juejin.cn)


js的函数传参之值传递 - 掘金 (juejin.cn)


js的事件循环机制 - 掘金 (juejin.cn)


从作用域链和内存角度重新理解闭包 - 掘金 (juejin.cn)


八,本文参考文章:


「硬核JS」你真的了解垃圾回收机制吗 - 掘金 (juejin.cn)


一文带你快速掌握V8的垃圾回收机制 - 掘金 (juejin.cn)


[深入浅出]JavaScript GC 垃圾回收机制 - 掘金 (juej

in.cn)

0 个评论

要回复文章请先登录注册