JS垃圾回收机制

对于内存的基本概念

JS垃圾回收机制负责自动管理内存,回收不再使用的对象所占用的内存空间

  • JS内存的周期
    • 内存分配:当我们声明变量/函数/对象时系统会自动为他们分配内存
    • 内存使用:读写内存,使用函数/变量等
    • 内存释放:通过垃圾回收机制自动将不再使用的内存释放
  • JS的内存回收
    • JS有自动垃圾回收机制— 即找出不再使用值,然后将其占用的内存释放
    • 大多数内存管理的问题都在这个阶段.在这里最艰难的任务是找不到不再使用的变量

    [!NOTE]
    变量分为局部变量和全局变量

    1. 不再使用的变量也就是生命周期结束的变量局部变量,局部变量只在函数执行的过程中存在,当函数运行结束变量的存在就无意义了,没有其他引用(闭包),
      那么该变量会被回收
  • 内存泄漏
    官方定义:内存泄漏(memory leak)是指程序中已经动态分配的内存由于某种原因,程序未释放或无法释放,造成内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果
    用不到的内存,没有即时释放造成内存浪费
  • 内存溢出
    程序运行需要的内存超出了剩余内存时,就抛出内存溢出的错误.

深入学习垃圾回收机制

什么是垃圾回收机制?

垃圾是怎样产生的?

为什么要进行垃圾回收?

垃圾回收是怎样进行的?

V8 引擎对垃圾回收进行了哪些优化?

1. GC是什么

GC即Garbage Collection 程序工作中会产生很多垃圾,这些垃圾是程序不用的内存或者之前用过,以后不会再用的内存,而GC就是负责回收这些垃圾的,GC工作在引擎内部对于前端来说GC过程是相对无感的,这一套在引擎内执行而我们又相对无感的操作就是常说的 垃圾回收机制

当然也不是所有的语言都有GC,一般高级语言会自带GC,比如java,Python,JS等,也有无GC语言比如C,C++,这种就需要管理员手动管理内存了,相对比较麻烦

2. 垃圾产生&为何回收

写代码时声明变量,对象,函数等都是需要占内存的,但是我们并不关注这些,因为这是引擎为我们分配的

但是当我们不在需要这些变量,对象时会发生什么? JS引擎又是如何发现并清理它的呢?

简单举个例子

let test = {
    name:'测试'
}
test = [1,2,3,4,5]

如上所示,我们假设他是一个完整的程序代码

我们知道JS 引用数据类型是保存在堆内存中的,然后再占内存中保留一个指向该数据的引用,所以JS中对引用数据类型的操作都是通过操作对象的引用而不是直接操作实际对象.

那上面的代码我们先声明了一个test,它指向的是堆内存里的一个对象,接着我们吧test重新赋值成一个数组,也就是test引用指向了堆内存里的一个数组,
那么{name:'测试'}与test的引用关系就没有了,变成了无用的内存.这种无用内存就需要被释放,清理(回收)

请添加图片描述
用官方描述就是 程序运行需要内存,只要程序提出要求操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程必须要及时释放内存,否则内存占用越来越高轻则影响性能,重则导致程序崩溃

3. 垃圾回收策略

在JS内存管理中有一个概念叫做 可达性 ,就是那些以某种方式可访问或者说可用的值,他们被保证存储在内存中,反之不可访问则需要被回收

至于如何回收那就是怎样发现这些不可达对象(也就是垃圾)并给予清理的问题,JS的垃圾回收机制简单来说就是 定期找出那些不再使用的内存(变量)并释放其内存

为什么是定期而不是实时的去查找释放呢? -----> 因为开销太大

如何找到所谓的垃圾?

这个查找垃圾的过程就涉及到一些算法策略,方式有很多种,最常见的两种就是

  • 标记清除法
  • 引用计数法
4. 标记清除法

策略

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

标记清除法 分为 标记清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有做标记的(也就是非活动对象)销毁

**你可能会疑惑怎么给变量加标记?**其实有很多种办法,比如当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表,当前还有很多其他办法。其实,怎样标记对我们来说并不重要,重要的是其策略

引擎在执行GC(标记清除法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组根对象 ,而所谓的根对象包括又不止于全局window对象,文档DOM对象

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有的对象都是垃圾,全标记为0
  • 然后从各个根对象开始遍历,把不是垃圾的节点标记成1
  • 清理所有标记为0的垃圾,销毁并回收他们所占用的内存空间
  • 最后再把内存中对象标记修改为0,等待下一轮垃圾回收

优点

标记清除法的优点只有一个,实现相对简单,标记也无非打与不打两种情况,这使得一位二进制位(0/1)就可以为其标记.

缺点

标记清除算法有一个很大的缺点,就是清除之后剩余对象内存的位置是不变的,也就是说空闲的内存空间是不连续的,会出现内存碎片.并且由于剩余的内存空间不是一整块,而是由大小不同的内存组成的内存列表,这就牵扯出了内存分配的问题

请添加图片描述

假设我们新建对象分配内存时需要大小为size,由于内存空间是间断不连续的则需要对内存进行单项遍历找出大于等于size的内存才能为期分配(如下图)
请添加图片描述

那如何找到合适的块呢? 我们可以采取下面三种分配策略

  • First-fit:找到大于等于size的块立刻返回
  • Best-fit:遍历整个空闲列表,返回大于等于size的最小分块
  • Worst-fit:遍历整个空闲列表,找到最大的块,然后分成两部分,一部分size大小,并将该部分返回

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

综上所述,标记清除算法或者说策略就有两个很明显的缺点

  • 内存碎片化 空闲的内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象找不到合适的块儿
  • 分配速度慢 因为即便是First-fit任是一个O(n) 操作,最坏的情况是每次都要遍历到最后,同时由于碎片化,大对象的分配速率会更慢

[!NOTE]
归根结底,标记清除法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要能够解决这一问题,两个缺点就都能完美解决了
标记整理算法就可以有效的解决,他的标记阶段和标记清除算法没有什么不同,知识标记结束后,标记整理算法会将有用的对象(不需要清理的内存)向内存一端移动,最后清理掉边界内存(如下图)

请添加图片描述

5. 引用计数法

策略

引用计数(Reference Counting),这是最早先的一种垃圾回收算法,他把对象是否不再需要简化定义为对象有没有其他对象引用到他,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,简单了解即可

引用计数法的策略是跟踪并记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量时,这个值的引用次数就为1
  • 如果同一个值又被赋给了另一个变量,那么引用次数加1
  • 如果该变量的值被其他值覆盖了,则引用次数减1
  • 当这个值引用次数为0时,说明这个值在栈内存已经没有对应的引用了,也就是这个值无法被访问了,内存释放

如下例

let a = new Object()    //此时对象的引用为 1   (栈内存 : a )
let b = a               //此时对象的引用为2 (栈内存 : a,b)
a = null                // 此对象的引用计数为 1(b引用)
b = null                // 此对象的引用计数为 0(无引用)
                        // GC 回收此对象

这种方式是不是很简单?确实很简单,不过在引用计数这种算法出现没多久,就遇到了一个很严重的问题循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A ,如下面这个例子

function test(){
    let A = new Object()
    let B = new Object()

    A.a = B
    B.b = A
}

如上所示对象A和B通过各自的属性相互引用,按照引用计数的策略,他们的引用次数都为2.但是在函数test执行完后 对象A和B应该要清理的 ,但使用引用计数法则不会被清理,因为他们的引用数量不会变成0.假如在此函数的过程中被多次调用,那么就会造成大量的内存不会被释放

用标记清除法视角看的话 函数执行完后 A B 都不在当前作用域内,会被当做非活动对象进行清除,相比之下引用计数法则不会释放也就会造成大量内存占用,这也是后来放弃引用计数法使用标记清除法的原因之一

优点
引用计数法的优点当堆内存中的对象引用为0时会被立即回收,立即进行垃圾回收

缺点
引用计数法需要计数器来统计引用次数,计数器需要占用很大的空间;最重要的一点还是循环引用无法回收问题

6.V8对GC的优化

什么是V8? V8引擎是驱动 Google Chrome 的 JavaScript 引擎的名称。是 Chrome浏览器和edge浏览器获取我们的 JavaScript 代码并执行代码的东西

大多数浏览器都是基于标记清除算法进行垃圾回收的,V8也是,当然V8对其进行了优化加工,那么我们继续学习V8的垃圾回收机机制优化

分代式垃圾回收

试想一下,我们上面所说的垃圾清理算法在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些,大,老,存活时间长的对象来说 同新,小,存活时间短的对象一个频率检查很不好,因为前者需要时间长,并且不需要频繁清理,后者恰恰相反,分代式就解决了这一问题

所谓分代式,就是将需要经常清理的内存和不需要经常清理的内存划分开来

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

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

V8整个堆内存的大小就等于新生代加上老生代的内存如下图

请添加图片描述

对于新老两块内存区域的垃圾回收,V8采用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫新生代垃圾回收器,同样将管理老生代的垃圾回收器叫老生代垃圾回收器

新生代垃圾回收

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

Cheney算法中将堆内存一分为二一个是处于使用状态的空间我们也就是使用区,一个是处于闲置的空间也就是空闲区(如下图所示)

请添加图片描述

新创建的对象都会放到使用区中,当使用区块被写满时,就需要执行一次垃圾清理操作

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

首先新生代分使用区和空闲区,当使用区快满了,就要开始GC了
然后开始对使用区做标记,标记后复制一份活动对象到空闲区(这里做了整理的操作,也就是排序,避免内存碎片)
再然后清除使用区所有数据对象,把原来的使用区改称空闲区,把原来的空闲区改成使用区,
这样的话新使用区就是空的,继续存数据,当快存满了开始下一轮GC

再看第二轮GC,还是重复上面的步骤,先标记,再把活动对象从使用区复制到空闲区,
这个时候假如发现了上次就存在的对象这次还是活动对象,那这个对象就会被晋级,扔到老生代里去。
接着说复制之后,使用区又被清空了,并且再次和空闲区转换,那每一轮GC过后,使用区就会变成空的

新生代升级策略

当一个对象经过多次复制依然存活的话,会被认为是长期存活对象,随后会被移动到老生代,采用老生代垃圾回收策略
还有另外一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过25%,那么这个对象会直接晋升到老生代,设置比例为25的原因是当完成Scavenge回收后,空闲区将反转成使用区,继续进行新对象内存分配,若占比过大将会影响后续内存分配

老生代垃圾回收
对比新生代,老生代垃圾回收就容易理解了.老生代的对象大多数占用空间大,存活时间长如果像新生代一样复制来复制去会非常耗时,从而导致效率变低.所以老生代垃圾回收器的策略就是标记清除法

  • 首先进行标记,从一组根元素开始进行递归遍历,遍历过程中能达到的元素就是活动对象,没有达到的元素就判断为非活动对象
  • 然后是清除阶段,老生代垃圾回收器会直接将非活动对象,也就是无用数据清除

我们知道标记清除法在清除之后会导致内存空间不连续,产生内存碎片,过多的内存碎片会导致大对象无法分配到足够的连续内存,而V8就采用了标记整理法来优化这一问题

为什么需要分代式

分代式的作用主要是优化垃圾回收的效率,区分新生代与老生代,避免了过多的重复遍历

并行回收

在介绍并行回收之前我们要先了解一个概念 全停顿(Stop-The-World)
JS是一门单线程语言,他运行在主线程上.当进行垃圾回收时主线程就会阻塞JS脚本的执行,等待垃圾回收的这种行为就叫–全停顿
也就是说 全停顿代表的是由于垃圾回收导致的JS线程阻塞

既然存在GC比较耗时的问题,考虑到一个人盖房子难那两个人更多人一起盖房子呢?切换到程序,我们能不能引用多个辅助线程来同时处理,这样是不是就会加速垃圾回收的执行速度呢?因此V8引用了并行回收机制

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

请添加图片描述

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

增量标记与懒性清理

我们上面所说的并行策略虽然可以增加垃圾回收的效率,对于新生代垃圾回收器能够有很好的优化,但是其实它还是一种全停顿式的垃圾回收方式,对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间
所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记

什么是增量
增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记(如下图)
请添加图片描述

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

可以看出增量的实现比并行的实现要复杂一些.于是V8提出了三色标记法与写屏障

三色标记法(暂停与恢复)
我们知道老生代是采用标记清理算法,而上文的标记清理中我们说过,也就是没有采用增量算法之前,单纯的使用黑色和白色来标记数据就可以了,其标记流程在执行完一次完整的GC标记前,垃圾回收器会将所有的数据设置为白色,然后垃圾回收器从一组根对象出发,将所有能访问的数据标记为黑色,遍历结束之后标记为褐色的数据对象就是活动对象,剩余的就是待清理的垃圾对象

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

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

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

请添加图片描述

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

就这样一直往下走,直到没有可标记灰色的对象时,也就是无可达(无引用到)的对象了,那么剩下的所有白色对象都是无法到达的,即等待回收(如上图中的 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 代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记缺点也很明显:
首先是并没有减少主线程的总暂停的时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量(吞吐量是啥总不用说了吧)

并发回收

面我们说并行回收依然会阻塞主线程,增量标记同样有增加了总暂停时间、降低应用程序吞吐量两个缺点,那么怎么才能在不阻塞主线程的情况下执行垃圾回收并且与增量相比更高效呢?
这就要说到并发回收了,它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起(如下图)

请添加图片描述

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

再说V8中GC优化

V8的垃圾回收策略主要是基于分布式垃圾回收机制,关于 新生代垃圾回收器使用并行回收可以很好的增加垃圾回收的效率,那老生代呢? 并行,增量与惰性清理,并发都能使用在老生代.

因为三种方式各有优缺点,所以老生代垃圾回收器都是融合使用的

老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行

机制来控制这一点,这里我们不再细说

再说V8中GC优化

V8的垃圾回收策略主要是基于分布式垃圾回收机制,关于 新生代垃圾回收器使用并行回收可以很好的增加垃圾回收的效率,那老生代呢? 并行,增量与惰性清理,并发都能使用在老生代.

因为三种方式各有优缺点,所以老生代垃圾回收器都是融合使用的

老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值