【前端知识】内存泄漏与垃圾回收机制 (下)

五一假期第2天,承接上篇的知识笔记。
本文为原创,未经同意请勿转载

由于篇幅有点长,所以笔者将我关于这部分的笔记分为上下两个篇章(文章开头有附录上篇链接),避免读者的阅读疲倦感😵,同时也方便大家的阅读啦🤗。下面的笔记笔者也结合了自己所学的东西和平常使用的情况进行了相关的拓展。如果下面笔记中存在错误,欢迎大家及时指正,学习与讨论。

上章:介绍内存的生命周期,管理及内存泄漏的相关概念,分析内存泄漏可能的原因。
上篇博客的链接:【前端知识】内存泄漏与垃圾回收机制 (上)
下章:内存泄漏的解决方法,垃圾回收机制的相关知识,内存管理的优化等。

6. 内存泄漏的解决方法

对于内存泄漏,我们在上一章中已经介绍,内存泄漏是指不再使用的内存,没有被及时释放,导致内存被占用而可能导致程序崩溃的情况。那内存泄漏解决的方法就应该是怎么去及时的释放掉那些不再使用的内存(变量和对象)。在JS中,存在自动垃圾回收的机制可以实现对某些内存空间的自动释放,但是由于程序本身的逻辑问题,外部的因素,或者在处理大型数据集和复杂的应用程序时,内存泄漏是难以避免的。所以,JS还可以通过手动清除来实现内存的及时释放。因此,总的来说,在JS中,内存泄漏的解决方法可以分为自动和手动两种。

6.1 解决方法概述

  1. 自动——垃圾回收机制
    JS引擎中提供有自动的垃圾回收机制,无需像C++那样的底层语言对某些数据进行自动的释放。
  2. 手动
    当然面对一些难以避免的情况,我们可以通过下面的方法来进行手动的释放:
    • 置空来解除引用(最简单的方法)
      在JS中,只要一个变量不再被引用,它所占用的内存就会被自动回收。所以,如果需要手动释放内存,可以将不再需要的变量置空,也就是设置为 null,比如:

      let object = { name: "xiaobai_Ry" };
      object = null; // 置空,释放内存
      
    • 手动删除DOM节点
      通过JS动态生成的 DOM 节点,如果不在使用,也需要手动删除。可以通过 removeChild 方法【eg. element.parentNode.removeChild(element)】或者 innerHTML 属性【eg. element.innerHTML = ""】来删除 DOM 节点。此外,根据笔者我前面的笔记,我们也可以知道DOM节点再删除的时候,还需要注意游离DOM引用的问题。也就是我们在移除DOM节点的同时需要同步释放它对应的缓存引用,也就是说将相应的元素全部删除或者置空。不知道是否记得我们前面提到的例子,这里笔者也重新放到这里,加深一下印象呗~

      <div id="root">
        <ul id="ul">
          <li></li>
          <li id="liry"></li>
          <li ></li>
        </ul>
      </div>
      <script>
        let root = document.querySelector('#root')
        let ul = document.querySelector('#ul')
        let liry = document.querySelector('#liry')
        // 这里虽然在DOM树上使用 removechild 方法从根元素中删除了 ul 元素
        // 但是由于ul变量存在,所以整个ul及其子元素都不能垃圾回收
        root.removeChild(ul)
        // 虽然这里我们进一步置空了ul变量,但是因为liry变量仍然引用了ul的子节点
        // 说明对应元素还没有完全置空,所以ul元素依旧不能被垃圾回收
        ul = null
        // 到这里,所有对应的变量都置空,无引用
        // 此时可以立即垃圾回收掉整个 ul 元素及其子元素。
        liry = null
      </script>
      
    • 取消事件监听器
      当一个元素绑定了事件监听器后,需要手动取消绑定,以避免事件监听器对内存的持续占用。可以通过 removeEventListener 方法来取消事件监听器。这个我们在上章节的笔记中也有介绍,这里就不再赘述了。

    • 其他
      其实上面基本上的手动方法包括其他的一些手动清除方法,我们其实已经在上篇中的3.3内存泄漏可能的原因中有提及到,这里只是再做一个总结,其他方法就不在赘述了。

6.2 什么是垃圾

  • 对象不再被引用
  • 对象不能从根上访问到,也就是不可达

6.3 垃圾回收机制的定义及规则

JavaScript会在创建变量(对象、字符串)时自动分配内存,并在这些变量不被使用时自动释放内存,这个过程被称为垃圾回收(Garbage Collection),简称GC,也有的书叫做内存回收,因为回收的是不在使用的内存。

垃圾回收的规则,其实在上一篇章中,我们也算有讲过(就是在内存分析那一块),这里我直接放思维导图吧:
在这里插入图片描述

6.4 垃圾回收算法的基本流程

垃圾回收机制的通用流程:标记——》释放——》整理
(1)标记内存空间中的活动对象(正在使用的对象)和非活动对象(可以回收对象)
(2)删除非活动对象,释放内存空间
(3)整理内存空间,避免频繁回收后产生的大量内存碎片(不连续内存空间)

7. 垃圾回收的常见算法

对垃圾回收算法来说,核心思想就是如何判断内存已经不再使用,常用的基本垃圾回收算法有四种:引用计数(现代浏览器不再使用),标记清除(常用,V8老生代中用到),复制算法(V8新生代里用到)、标记整理(也就做标记压缩,V8老生代中用到)等

7.1 引用计数

在这里插入图片描述

  • 策略:跟踪每个变量被使用的次数
  • 描述:语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。每个对象都有一个引用计数器,当有对象引用它时,计数器+1;当引用失效时,计数器-1;任何时刻计数器为0时就是不可能再被使用的。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。
  • 优点: 引用计数为零时,发现垃圾立即回收;可以最大限度减少程序暂停
  • 缺点:需要计数器(引用表),空间开销较大,无法知道引用数量的上限;解决不了循环引用导致的无法回收问题

7.2 标记清除

在这里插入图片描述

  • 策略:分成标记和清除两个阶段,将不再使用对象定义为无法到达的对象,在标记完成后统一回收所有未被标记的对象。

  • 描述

    1. 遍历所有对象,找标记活动对象;
    2. 遍历所有对象,清除没有标记对象;
    3. 回收相应的空间。
  • 优点: 解决引用计数无法回收循环引用对象的问题,实现简单,一个二进制位数就可以为其标记,不需要额外的空间。

  • 缺点:垃圾回收之后出现内存碎片化,空闲内存块不连续,两次扫描,耗时严重,分配速度慢。

7.3 复制算法

在这里插入图片描述

  • 描述
    1. 将整个空间平均分成 from 和 to 两部分。
    2. 先在 from 空间进行内存分配,当空间被占满时,标记活动对象,并将其复制到 to空间。
    3. 复制完成后,将 from 和 to 空间互换。
  • 优点: 避免了内存碎片化的问题,吞吐量大,可以实现高速的分配
  • 缺点:典型的空间换取时间的算法,内存利用率低,浪费空间,不适合处理大型对象

7.4 标记整理(标记压缩)

在这里插入图片描述

  • 策略:分成标记,回收和整理三个阶段,其标记过程与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
  • 描述
    1. 标记阶段与标记清除算法一样
    2. 清除阶段则会先执行整理,移动对象位置,将存活的对象移动到一边,然后再清理端边界外的内存。
  • 流程
    1. 从⼀个 GC root 集合出发,标记所有活动对象。
    2. 将所有活动对象移到内存的⼀端,集中到⼀起。
    3. 直接清理掉边界以外的内存,释放连续空间。
  • 优点: 解决标记清除中内存碎片化的问题,提高了内存的利用效率(解决复制算法的问题),适用于数据平滑、存活对象多的情况。
  • 缺点:移动对象位置,不会立即回收对象,需要对整个堆做多次搜索,回收的效率比较慢。

7.5 四种基本GC算法的总结

在这里插入图片描述

  • 内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
  • 内存整齐度:复制算法=标记整理算法>标记清除算法。
  • 内存利用率:标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/消除多了一个整理内存的过程。

8. 垃圾回收机制的优化

上面我们讲了垃圾回收机制的四种基本算法,在后面其实针对上面的基本算法也有很多研究者提出来优化的策略,这里先简单的概述一下,主要包括:分代回收(这也是V8的垃圾回收策略),增量标记,惰性清理(空闲时间收集),并行回收和并发回收等

8.1 分代回收

注:这里后面V8详细介绍
将对象分为新生代对象和老生代对象两种,针对两种对象生命周期的特点采用不同的垃圾回收算法,以此有针对性地提高GC的回收效率。

8.2 增量回收

如果有很多对象,并且我们试图一次遍历并标记整个对象集,那么可能会花费一些时间,并在执行中会有一定的延迟。因此,引擎试图将垃圾回收分解为多个部分。然后,各个部分分别执行。这需要额外的标记来跟踪变化,这样有很多微小的延迟,而不是很大的延迟。
具体地,增量就是指将一次GC标记的过程分成很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记。
在这里插入图片描述
在V8中,增量标记具体采用是三色标记算法(暂停和恢复)和写屏障(增量中修改引用)来实现,也就是在进行垃圾回收的时候,并不会一次性把所有可达对象都标记出来,而是使用一个标记阶段(marking phase)和一个更新阶段(sweeping phase),将它们交替执行,逐步地完成标记工作。其中,三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白【未被标记的对象】、灰【自身被标记,成员变量(该对象的引用对象)未被标记】、黑【自身和成员变量皆被标记】。三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少全停顿的时间。写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性

8.3 惰性清理(空闲时间收集)

增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)。那惰性清理是什么?惰性清理是指系统在进行垃圾回收时,先将需要回收的对象标记出来,并不会立即进行回收,而是将其加入待清理队列中,等到系统空闲或者分配内存失败时才开始对待清理队列中的对象执行回收操作。这种方式可以减少GC操作对程序的影响,降低系统开销,提高程序的运行效率。因此,惰性清理算法可以通过监听空闲事件,利用浏览器空闲时间,在此期间进行回收处理。
相较于及时回收,惰性清理的优点是可以将回收操作分散到多个时间点上执行,避免了因为回收集中发生导致停顿的可能性;同时也改善了回收带来的性能瓶颈问题,使得程序在执行中更加平滑。而缺点是需要占用一定的内存空间,等待足够数量的对象达到一定阈值方可进行回收。

8.4 并行回收

并行回收指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作,以此i加快回收速度。
在这里插入图片描述

  • 优点:
    • 高吞吐量:由于使用了多个cpu同时进行回收操作,所以并行回收可以提供更高的吞吐量。
    • 低延迟:并行回收通常会与应用程序并发运行,因此对延迟的影响相对较小。
    • 硬件利用率高:并行回收可以最大限度地利用多核处理器的性能。
  • 缺点:
    • 需要更多的内存资源:由于垃圾回收器需要同时处理许多对象,因此并行回收需要更多的内存资源。
    • 停顿时间较长:虽然并行回收不会完全停止应用程序,但在回收期间仍然需要暂停一段时间,因此会对应用程序的延迟产生影响。

8.5 并发回收

并发回收指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起。
在这里插入图片描述

  • 优点:
    • 低延迟:与并行回收类似,并发回收也可以最小化应用程序运行期间的停顿时间。
    • 更少的内存资源:由于垃圾回收器只需处理较少数量的对象,因此需要的内存资源也较少。
  • 缺点:
    • 低吞吐量
    • 线程切换繁琐:并发回收需要协调线程之间的信息交换,所以实现较为复杂,而且容易出现并发问题。

9. V8的垃圾回收策略

在这里插入图片描述

v8引擎垃圾回收策略采用分代回收的思想,也就是将内存(堆)分为新生代和老生代两个区域,分别使用副、主垃圾回收器。其中,新生代中的对象为存活时间较短的对象,采用复制算法(Scavenge 算法)加标记整理算法,而Scavenge 算法的具体实现,主要采用了Cheney算法。老生代中的对象为存活时间较长或常驻内存的对象,采用标记清除和标记压缩的算法结合。对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升。通过主垃圾回收其来实现老生代的垃圾回收,同时为了不造成卡顿,标记的过程被切分为一个个子标记,交替进行(也就是增量标记)。
在这里插入图片描述

9.1 V8新生代的垃圾回收

新生代存的对象都是生存周期短的对象,回收新生代对象主要采用的是复制算法(Scavenge 算法)加标记整理算法。而Scavenge 算法的具体实现,主要采用了Cheney算法。Cheney算法将新生代内存空间分成From和To两个等大的空间,其中,To空间(空闲区)处于闲置状态而From空间(使用区)处于使用状态。新创建的对象会被存到From空间,当From空间满的时候就执行复制算法(Scavenge 算法)进行垃圾回收。其中垃圾回收算法具体流程为:先对From空间中的对象进行标记,完成后将标记对象复制到To空间的⼀端,然后将两个区域角色反转,就完成了回收操作。
在这里插入图片描述
这个标记主要是指:检查From空间中的存活对象,如果对象存活则判断对象是否满足晋升到老生代的条件,如果满足条件则晋升到老生代。如果不满足条件则移动到 To空间。

在新生代GC中,每次执行清理都需要复制对象,所以需要时间成本所以新生代空间一般会设置得比较小(1~8M)。

新生代对象晋升到老生代有两个条件:

  1. 第一个是判断对象是否已经经过一次 Scavenge 回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。当⼀个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理
    在这里插入图片描述

  2. 第二个是 To 空间的内存使用占比是否超过限制。当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25% ,则对象直接晋升到老生代中。设置 25% 的原因主要是因为算法结束后,两个空间结束后会交换位置,如果 To 空间的内存太小,会影响后续的内存分配。
    在这里插入图片描述

9.2 V8老生代的垃圾回收

老生中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。由于Mark-Conpact需要移动对象,所以它的执行速度不可能很快,在取舍上,V8主要使用标记清除,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用标记整理。

老生代总结: 回收老生代对象主要采用标记清除标记整理增量标记算法,主要使用标记清除算法,只有在内存分配不足时,采用标记整理算法。

  1. 首先使用标记清除完成垃圾空间的回收
  2. 采用标记整理进行空间优化
  3. 采用增量标记进行效率优化
    在这里插入图片描述

背景

在老生代中,存活对象占较大比重,如果继续采用Scavenge算法进行管理,就会存在两个问题:

  1. 由于存活对象较多,复制存活对象的效率会很低。
  2. 采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。

💡 Scavenge只复制活着的对象,而标记清除(Mark-Sweep)只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。

描述: 标记清除法首先会对内存中存活的对象进行标记,标记结束后清除掉那些没有标记的对象。由于标记清除后会造成很多的内存碎片,不便于后面的内存分配。所以为了解决内存碎片的问题引入了标记压缩法。

优化背景: 由于JS是单线程运行的,意味着垃圾回收算法和脚本任务在同⼀线程内运行,在执行垃圾回收时,后续脚本任务需要等垃圾回收完成后才能继续执行。若堆中的数据量非常大,⼀次完整垃圾回收的时间会非常长,导致应⽤的性能和响应能力直线下降。为了避免垃圾回收影响应用的性能,V8 将标记的过程拆分成多个⼦标记,让垃圾回收标记和应用逻辑交替执行,避免脚本任务等待较长时间。

优化: 由于在进行垃圾回收的时候会暂停应用的逻辑,对于新生代方法由于内存小,每次停顿的时间不会太长,但对于老生代来说每次垃圾回收的时间长,停顿会造成很大的影响。为了解决这个问题 V8 引入了增量标记的方法,将一次停顿进行的过程分为了多步,每次执行完一小步就让运行逻辑执行一会,就这样交替运行。

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代中

9.3 总结

新生代由于占用空间比较少,采用空间换时间机制。而老生代区域空间比较大,不太适合大量的复制算法和标记整理,所以最常用的是标记清除算法,为了就是让全停顿的时间尽量减少。

10. 内存管理的性能优化

这里其实也可以参考上篇章中可能原因里面的解决方案。

  1. 避免使用全局变量和函数
    全局变量和函数在使用前不需要声明,但是会影响代码的可读性和维护性,同时也容易被误用和滥用。为了减少内存开销和保证代码的可靠性,尽可能避免使用全局变量和函数,并采用模块化编程方式。
    • 全局变量会挂载在window下
    • 全局变量至少有一个引用计数
    • 全局变量存活更久,持续占用内存
    • 在明确数据作用域的情况下,尽量使用局部变量
  2. 减少内存泄漏
    javascript 的回收机制会自动回收无法被引用的变量和对象。但是如果有一部分变量或对象被持续引用却不再使用了(例如全局变量等),就会造成内存泄漏的问题。因此在编写代码时,需要注意及时清理不再需要的变量和对象,并避免产生闭包、循环引用等问题。
    • 事件绑定优化
    • 避开闭包陷阱
    • 减少循环体中的活动
    • …(见上章中的3.3)
  3. 减少对象创建和销毁
    javascript 中创建对象相比其他语言比较容易,但是同时也很容易产生大量的**对象。为了减少对象的创建和销毁,可以使用对象池技术,预先创建多个对象并保存在池中,需要使用时从池中取出,并在使用完成后重新放回池中。
  4. 使用静态类型
    javascript 是一种弱类型语言,由于类型转换的不确定性会造成较大的内存开销。从 ecmascript 6 开始,引入了静态类型系统,可以通过声明静态类型来减少类型转换和检测的开销,从而提高代码运行效率。
  5. 减少判断层级
  6. 减少数据读取次数
    对于频繁使用的数据,我们要对数据进行缓存。
  7. 使用内存快照工具进行调试
    在开发过程中,可以使用内存快照工具对代码进行内存占用

相关博客推荐

华为云——Vue进阶(幺陆玖):JS垃圾回收机制
知乎——V8 垃圾回收原来这么简单?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xiaobai_Ry

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值