美文网首页
JS 的垃圾回收机制

JS 的垃圾回收机制

作者: elle0903 | 来源:发表于2023-02-13 15:35 被阅读0次

    前言

    在 C 和 C++ 里,跟踪内存是开发者的职责,这给开发者带来很大负担,也是很多问题的根源。JS 为开发者卸下了这一负担,它通过自动内存管理,实现了内存的分配和闲置资源的回收。

    在这里,闲置资源的回收,我们称之为垃圾回收(GC,Garbage Collect)。本文将从两个方面,来探讨 JS 这门语言的 GC 机制。

    1. 常见的 GC 方案
      介绍几种比较常见的 GC 方案,同时也尝试对它们进行一些优劣性的对比和分析。
    2. V8 引擎的 GC 方案
      介绍 Google Chrome 浏览器内部的 V8 引擎目前正在使用的 GC 方案,同时也尝试了解一下,它所进行的一系列针对性优化。

    好了,废话不多说,我们正式开始吧。

    常见 GC 方案

    深入 GC 方案之前,我们需要先了解一下垃圾回收的过程。简单来说,一次由程序发起的自动 GC ,会包含如下两个过程:

    1. 识别垃圾
    2. 清理垃圾

    那么,什么样的数据会被定义为垃圾数据呢?

    什么样的数据会被定义为垃圾数据?

    我们知道,JS 运行时存在一个储存作用域链的栈,这个栈最底层的元素是全局作用域,往上叠加的,是一个个按顺序被创建的函数 / 块作用域。
    这些作用域被创建时,一些变量也会跟着被声明和赋值,原始类型的数据被保存在栈内,引用类型的数据则会被保存在栈外的堆内存之中。

    作用域链和作用域链里的变量

    函数/块执行结束,作用域随即被推出,被推出的作用域变得不再可以访问,那么自然而然得,其内部所保存的一些变量,也跟着变得不再可以访问。
    这些再也无法被访问的数据,我们称之为垃圾。

    很形象,是不是?(∩_∩)~

    有了这些认知之后,我们再来看看,如何评判一个 GC 方案的优劣。

    如何评判一个 GC 方案的优劣?

    评价 GC 算法的优劣时,我们经常用到这四个标准。

    1. 吞吐量
    2. 最大暂停时间
    3. 堆使用效率
    4. 访问的局部性

    下面我们来逐个介绍。

    1. 吞吐量

    吞吐量(throughput)反映了程序在单位时间内处理数据的能力。如上图所示。

    我们知道 JS 是单线程的,一段程序的执行,势必影响另一段。一般来说, GC 会在线程闲置时执行,但页面是展示在用户面前的,点击、输入、阅览等操作,也完全由用户来操控。我们没有隔着屏幕操控用户意念的能力,线程的闲置时间理所当然变得不可估算。
    那么,为了最大程度保证用户体验,我们势必需要减少 GC 的执行次数,和每一次的 GC 执行耗时。

    以上图为例,整个程序执行过程中, GC 一共启动了3次,我们把花费的时间分别设为 A、B、C,堆大小设为 HEAP_SIZE,那么此次 GC 的吞吐量便为 HEAP_SIZE/(A+B+C)。

    HEAP_SIZE 恒定的情况下,执行时间越低,算法的吞吐量越高。

    1. 最大暂停时间
      我们知道 GC 算法,会导致应用逻辑的暂停。最大暂停时间指的是,因执行 GC 而暂停执行应用逻辑的最长时间。

    2. 堆使用效率
      一般来说,堆中存放的信息越多, GC 算法的效率越高,吞吐量也越大。除此之外,堆用法的不同,也会很大程度上影响堆的使用效率。

    3. 访问的局部性
      访问的局部性指的是,具有引用关系的对象之间很可能存在连续访问的情况
      基于这一现象,当CPU尝试从内存中读取数据时,我们不仅让它读取使用的数据,还让它读取该数据附近的所有数据,继而压缩数据读取的时间。
      为了让这一策略能够生效,我们在存储数据时,就需要把具有引用关系的对象,安排在堆中较近的位置。

    有了衡量标准,我们就可以详细来看看每一种 GC 算法了,本文将涉及六种 GC 算法,它们分别是:

    1. 引用计数算法
    2. 标记 - 清除算法
    3. 复制算法
    4. 标记 - 整理算法
    5. 分代式垃圾回收
    6. 增量式垃圾回收

    下面我们逐个来分析。

    1. 引用计数算法

    引用计数的思路是,记录每个值的被引用的次数,当它被初始化,并被赋值给一个变量,引用为 1;如果另外一个变量引用了它,引用加 1;如果一个变量不再引用它,引用减 1。当一个值的引用数变为 0,那就说明它再没法被访问了,因此它成为垃圾,可以放心回收。

    优点
    这个算法的优点是吞吐量较大,一个对象的被引用次数变为0,它就可以立刻被释放,所占用的内存也即刻被回收。这样一来,内存管理的开销就被分摊到了程序的运行过程中,最大暂停时间也随即被大大缩短。

    缺点
    引用计数最早由Netscape Navigator 3.0采用,但很快就遇到了严重的问题:无法回收循环引用的对象。

    function problem() {
        let objectA = new Object();
        let objectB = new Object();
        objectA.someOtherObject = objectB;
        objectB.anotherObject = objectA;
    }
    

    在上面这个例子中,objectA 和 objectB 通过各自的属性相互引用,于是它们的引用数都是 2。函数运行结束,objectA 和 objectB 的引用次数各自减 1,但因为它们还引用着彼此,所以它们的引用次数永远不会置为 0,也就无法被回收。

    2. 标记 - 清除算法

    标记 - 清除算法(Mark-Sweep)由标记和清除两个阶段构成:

    1. 标记阶段:给所有的活动对象打上标记。
    2. 清除阶段:回收没有标记的对象。

    优点
    堆的使用效率较高。因为可以在整个堆中存放对象,所以相对来说堆的使用效率较高。

    缺点
    碎片化。经过多轮空间分配和垃圾回收后,堆从一整片完整的空间被划分为了很多不连续的碎片,这就导致另外一个问题:分配速度受限。因为每次重新分配空间时,都要遍历所有空闲链表,去寻找足够大的分块,最坏的情况下,每次进行分配都要遍历整个空闲链表。

    3. 复制算法

    简单来说,就是把整个存储空间一分为二,一个空间(From 区)用来存放数据,另一个空间(To 区)暂时闲置。

    算法执行步骤如下:

    1. 将 From 区的活动对象,复制到 To 区,——注意是活动对象,非活动对象不复制。
    2. 清空 From 区。
    3. 将 From 区和 To 区对调。
    A为不活跃对象 将B、C复制到To 区 清空 From 区 对调 From 区和 To 区

    优点

    1. 优秀的吞吐量。因为复制算法只搜索并复制活动对象,所以跟标记 - 清除算法相比,它能在较短时间内完成。
    2. 不会发生碎片化。复制算法每次运行 GC 时都会执行整理,因此不会发生碎片化。也因此,当出现新的数据需要分配内存时,它的工作效率也更高。

    缺点
    堆使用效率低下。只有一半堆能够被使用,相较其他算法,它在堆的利用方面,效率可以说相当低下。

    4. 标记 - 整理算法

    标记 - 整理算法(Mark Compact)是标记 - 清除算法复制算法相结合的产物。该算法由标记阶段和整理两个阶段构成:

    1. 标记阶段:这个阶段跟标记 - 清除算法的标记阶段一致。
    2. 整理阶段:移动活动对象的位置,使活动对象聚集到堆的一端。
    标记 - 整理算法

    优点

    1. 堆空间利用率较高。可以在整个堆中存放对象,堆使用效率较高。
    2. 没有碎片化的问题。经过整理后的堆内存空间是连续的,接下来的内存分配也不会有问题。

    缺点
    吞吐量较差。因为整理过程需要多次整堆遍历,速度较慢,所以吞吐量较差。

    5. 分代垃圾回收

    这个算法基于一个普遍的共识:大部分对象生成后都会马上变成垃圾,只有少部分对象能够存活很久。

    基于这个共识,分代垃圾回收算法(Generational Collection)在对象中引入了年龄的概念。通过优先回收容易成为垃圾的对象,提高垃圾回收的效率。

    该算法把对象分类成几代,针对不同的使用不同的 GC 算法。首先来了解一些概念。

    1. 新生代对象(new generation),指刚生成的对象。
    2. 老年代对象(old generation),指到达一定年龄的对象。
    3. 新生代 GC(minor GC),指对新生代对象所执行的 GC。
    4. 老年代 GC(major GC),指对老生代对象所执行的 GC。该算法的执行频率往往更低。
    5. 晋升(promotion),指将存活了一定次数的新生代对象转化为老年代对象
    image

    优点
    吞吐量得到改善,“很多对象年纪轻轻就会死”虽然是经验之谈,却能够适用于大多数情况。新生代 GC 只把刚生成的对象当成对象,这就有效降低了时间上的消耗。老生代 GC 的执行时间或许会更长,但它的执行频率降低不少。

    缺点
    当“很多对象年纪轻轻就会死”这一法则不成立时,分代垃圾回收的表现就会大打折扣,这种情况下,基本算法的表现可能更加优秀。

    6. 增量式垃圾回收

    增量式垃圾回收(Incremental)是一种通过逐渐推进垃圾回收的策略,来控制应用逻辑最大暂停时间的算法。

    当 GC 的任务变得繁重时,它所消耗的时间也会跟着提升。JS 单线程运行,GC 占用的时间越多,应用逻辑所受到的影响也越大。最严重的情况下,它甚至可能导致应用逻辑的暂停。这种暂停应用逻辑运行的 GC,被我们称为停止型 GC 。

    停止型 GC

    为避免出现停止型 GC,增量式垃圾回收应运而生。正如增量(Incremental)这个词所描述的,——递增的,逐渐增加的,增量式垃圾回收所做的事情便是,让 GC 和应用逻辑一点点交替运行。

    增量式垃圾回收

    我们可以使用Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking),来完成增量式垃圾回收。这个算法将内存中的对象分成三种:

    1. 白色:尚未进入检索过的对象。
    2. 灰色:正在被检索的对象。
    3. 黑色:已经完成检索的对象。

    增量式的 GC 标记 - 清除算法可分为以下三个阶段:

    • 根查找阶段;
    • 标记阶段;
    • 清除阶段;

    下面是详细描述:

    1. 将内存中的所有对象置成白色。
    2. 从根开始,依次检索每个对象,并在检索到它们时,将它们置为灰色。
    3. 当一个对象的子对象全被检索完,它会置为黑色。
      4.检索结束,清除白色对象。

    优点
    缩短最大暂停时间。采用三色标记算法的增量式垃圾回收,跟应用逻辑交替运行,因此它能够最大限度地保证应用逻辑的正常运行。

    缺点
    降低了吞吐量。交替进行的标记算法虽能够有效降低算法的最大暂停时间,频繁的访问和检索却会影响到算法总体的吞吐量。

    V8的垃圾回收策略

    V8采用分代式垃圾回收算法。通过回忆该算法的原理,我们可以知道,针对不同年龄的对象,它会采用不同的垃圾回收策略。

    1. 针对新生代对象:复制算法 + 标记 - 整理算法。
    2. 针对老生代对象:标记 - 清除算法 + 标记 - 整理算法。

    默认情况下,它将内存空间一分为二:

    • 针对 32 位系统,新生代内存大小为 16MB,老生代内存大小为 700MB 。
    • 针对 64 位系统:新生代内存大小为 32MB,老生代内存大小为 1.4GB 。

    回收新生代对象

    针对新生代对象,V8采用复制算法 + 标记 - 整理算法的模式,具体执行步骤如下:

    1. 将新生代存储区,分为大小相等的两个空间,使用空间为 From,空闲空间为 To 。
    2. 算法执行时,将 From 空间的活动对象拷贝至 To 空间。
    3. 清空 From 空间的所有数据。
    4. 对象 From 空间和 To 空间。

    将活动对象从 From 空间拷贝至 To 空间时,如果满足以下条件之一,需要将活动对象的存储位置晋升到老生代存储区:

    1. 该对象已经经过一轮回收。
    2. To 空间使用率超过 25% 。之所以设置阈值,是因为该轮回收结束后,现在的 To 空间会变为 From 空间,接下来的内存分配也将在这个空间中进行,空间使用率若过高,后续的内存分配有一定概率会受到影响。

    回收老生代对象

    老生代对象中,存活对象占较大比重,此时,若继续采用复制算法标记 - 整理算法,就会出现两个问题。

    1. 效率低下。因为存活对象较多,此时再进行逐个复制,效率大打折扣。
    2. 堆利用率低下。老生代内存使用远大于新生代,复制算法却将那么大的存储区一分为二,浪费很严重。

    综合以上两点,V8 采用标记 - 清除标记 - 整理增量标记算法,对老生代对象进行回收。具体过程如下:

    1. 首先,使用标记 - 清除算法完成垃圾空间的回收。此时可能出现内存空间碎片化的问题。
    2. 当对象从新生代存储区晋升到老生代时,使用标记 - 整理算法对内存空间进行优化。
    3. 考虑到 GC 的工作很可能阻塞应用逻辑的运行,所以使用增量标记算法,将 GC 拆成多段,与应用逻辑交替执行。

    编写有利于垃圾回收的代码

    了解 JS 语言的垃圾回收机制,看起来对我们的日常工作没有太大影响,毕竟,谁也不是一天到晚忙优化,是不是?甚至我们经手的大多数页面都相当简单,有的甚至只能存活十天半个月,此时,比起锲而不舍地对它进行最大程度的优化,保证它能正常运行,才是我们日常最重要的工作。
    但俗话也说,千里之堤溃于蚁穴,不积跬步无以至千里,不积小流难以成大江,长年累月形成的好习惯,可以帮助我们走得更远,而旷日持久的坏习惯,即便它微乎其微,对一个人造成的影响也可能难以估量。
    了解 JS 语言的垃圾回收机制,看起来对我们的日常工作没有太大影响,但通过它形成的编码好习惯,可以让我们受益良多。

    那么,该如何编写一份有利于垃圾回收的优质代码呢?

    可以从如下几点入手。

    1. 使用 const 和 let 替代 var
      es6 新增的两个关键字,不仅有助于改善代码风格,更有助于改进垃圾回收。相较函数和全局作用域的 var,块作用域的 const 和 let,可以更早地被 GC 回收。

    2. 解除引用
      降低内存占用量可以一定程度上提升页面性能,基于这个共识,我们需要想办法在执行的代码中,只保存必要的数据。
      如果一个对象不再需要,那么请将它设置为null。
      如果一个对象很大,你只需要其中的一小部分,那么请用一个单独的变量保存它,而不是每次都去访问大对象的一个小属性。

    // 示例 1,程序执行结束后,解除对 inner 的引用。
    function outer() {
      let name = 'Jake';
    
      return function () {
        return name;
      };
    };
    
    let inner = outer();
    inner();
    inner = null;
    
    /* 
     * 示例 2,使用 WeakMap 和 WeakSet。 
     * WeakMap 和 WeakSet 不会阻止垃圾程序的运行。
     * 当 Countdown 的实例被回收,_counter 和 _action 也会随即被置空,不会造成内存泄漏。
     */
    const _counter = new WeakMap();
    const _action = new WeakMap();
    
    class Countdown {
      constructor(counter, action) {
        _counter.set(this, counter);
        _action.set(this, action);
      }
    
      dec() {
        let counter = _counter.get(this);
        if (counter < 1) return;
        counter--;
        _counter.set(this, counter);
        if (counter === 0) {
          _action.get(this)();
        }
      }
    }
    
    const c = new Countdown(2, () => console.log('DONE'));
    c.dec()
    c.dec()
    

    好啦!
    以上就是本文的全部内容。

    感谢整理相关内容并分享出来的大神们,也感谢看到这里的大小可爱们。

    比一个心:(′▽`ʃ♡ƪ)

    咱们有缘江湖再见!

    参考文章

    相关文章

      网友评论

          本文标题:JS 的垃圾回收机制

          本文链接:https://www.haomeiwen.com/subject/bchfkdtx.html