你真的了解JS垃圾回收机制吗?

本文最后更新于:1 个月前

目录

前言

堆栈内存管理

JS垃圾回收机制

标记清除(Mark and Sweep)

标记阶段

清除阶段

标记清除的特点

优点

缺点

引用计数(Reference Counting)

引用计数器的维护

引用计数的跟踪

垃圾回收的触发

回收对象

引用计数的特点

优点

缺点

分代回收(Generational Collection)

老生代回收

新生代回收

分代回收的特点

优点

缺点

内存泄漏

内存泄漏的场景

无用的对象引用

循环引用

全局变量的滥用

未释放的资源

总结

相关代码


前言

垃圾回收是JavaScript中内存管理的重要组成部分。开发人员不需要手动分配和释放内存。垃圾回收机制可以自动处理内存的分配和释放,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源。

它通过标记不再需要的对象,并回收它们所占用的内存空间,以便其他对象可以使用。

本篇文章将与大家分享,介绍一下JavaScript垃圾回收的重要性和定义,并深入探讨内存管理的概念、JS垃圾回收机制的分类,以及如何避免内存泄漏以及性能优化。

堆栈内存管理

之前的文章中,我针对堆与栈的概念做了初步的介绍,引用文章中的一句话:

栈内存用于存储程序的函数调用,变量声明以及一些占用小的变量值,如布尔,部分整数等,它们的生命周期受到函数的调用和退出以及变量的作用域的控制。当函数被调用或者变量创建时,相关的变量和函数调用会被压入栈内存,如果函数退出或者变量作用域销毁,相关的变量和函数就会从栈内存中弹出。

堆内存的作用是存储变量值,如字符串,对象,数组及函数,它们的生命周期受到JavaScript垃圾回收机制的控制,当不再需要这些变量时,垃圾回收机制会将它们销毁。

简言之,堆用于存储动态分配的对象,而栈用于存储基本类型的值和对堆中对象的引用。

也就是说,在堆内存中才存在垃圾回收器这个概念,内存的分配和释放是由JavaScript引擎自动处理的,开发人员无需显式地分配或释放内存。JavaScript引擎使用垃圾回收机制来管理内存,确保不再使用的对象被自动回收,以便为新的对象腾出空间。

JS垃圾回收机制

进入今天的正题,垃圾回收机制有三类,其中标记清除和引用计数是比较常见的机制,分代回收则是前二者的结合

标记清除(Mark and Sweep)

标记清除法是JS最常见的垃圾回收机制之一。它的工作流程包括标记阶段和清除阶段。

标记阶段

  1. 从根对象开始,例如全局对象(window)或函数的作用域链
  2. 遍历对象的属性和引用,将可访问的对象标记为被引用的对象
  3. 递归遍历活动对象的属性和引用,标记其他可访问的对象

清除阶段

  1. 遍历堆中的所有对象。
  2. 对于未被标记为活动的对象,将其标记为垃圾对象。
  3. 释放垃圾对象所占用的内存空间。
  4. 将已经被清除的对象从内存中删除。

我们写个类来模拟一下标记清除的操作

// 标记清除, 垃圾回收机制
class MarkGC {
  marked = new Set(); // 模拟标记操作
  run(obj) {
    this.marked.clear(); // 这一步应该是放在最后的,但是看不出效果,所以改成运行前重置
    this.mark(obj);
    this.sweep(obj); // 这一步实际上没有效果,为了方便理解
    return this;
  }
  //   判断对象或属性是否已经标记
  checkMark = (obj) => typeof obj === "object" && !this.marked.has(obj);
  mark(obj) {
    const { marked } = this;
    if (this.checkMark(obj)) {
      marked.add(obj);
      Reflect.ownKeys(obj).forEach((key) => this.mark(obj[key]));
    }
  }
  sweep(obj) {
    Reflect.ownKeys(obj).forEach((key) => {
      const it = obj[key];
      if (this.checkMark(it)) {
        delete obj[key];
        this.sweep(it);
      }
    });
  }
}
// 全局对象
const globalVar = {
    obj1: { name: "Object 1" },
    obj2: { name: "Object 2" },
    obj3: { name: "Object 3" }
}
const gc = new MarkGC()
gc.run(globalVar)// 执行垃圾回收
console.log(globalVar, gc.marked);
// 删除操作
delete globalVar.obj3
delete globalVar.obj2
// 对象删除后运行垃圾回收
gc.run(globalVar)
console.log(globalVar, gc.marked);

来理解一下上述代码,标记清除法主要分为mark操作和sweep操作,运行mark函数会将全局对象中的属性存入标记列表中,然后运行sweep函数对,没标记的对象清除

标记清除的特点

优点
  • 内存回收全面:标记清除算法能够回收不再被引用的所有对象,包括循环引用的对象。通过标记阶段和清除阶段的组合,能够有效地释放内存空间
  • 灵活性:标记清除算法与编程语言的具体实现无关,适用于多种编程语言和环境。它可以在运行时动态地进行垃圾回收,根据对象的实际引用情况进行操作
  • 可预测性:标记清除算法的执行时间是可控的。垃圾回收操作可以在合适的时机进行,避免了出现大量的内存分配和释放操作,从而提高了程序的响应性能
缺点
  • 暂停时间:标记清除算法需要在垃圾回收时停止程序的执行,进行标记和清除操作。这可能导致程序的暂停时间较长,影响了程序的实时性和响应性能
  • 空间效率:标记清除算法在执行清除操作时,需要对整个堆进行遍历,查找并清除未标记的对象。这可能导致在垃圾回收期间出现较大的内存占用,从而降低了内存的利用效率
  • 碎片化问题:标记清除算法在清除对象后会产生内存碎片,即一些小而不连续的内存空间。这可能会导致后续的内存分配操作出现困难,增加内存分配的时间和复杂度

引用计数(Reference Counting)

引用计数基于每个对象维护一个引用计数器,用于跟踪对象被引用的次数。当对象的引用计数变为零时,即没有任何引用指向它时,该对象被认为是不再被使用,可以进行回收。下面是该方法的基本原理

引用计数器的维护

  1. 每个对象都有一个引用计数器,初始值为 0。
  2. 当对象被引用时,引用计数器增加。
  3. 当对象的引用被取消或销毁时,引用计数器减少。

引用计数的跟踪

  1. 当一个对象被其他对象引用时,引用计数增加。
  2. 当一个对象引用的其他对象被取消或销毁时,引用计数减少。

垃圾回收的触发

  1. 在程序执行过程中,当垃圾回收器被触发时,它会遍历堆中的所有对象。
  2. 对于每个对象,检查其引用计数器的值。
  3. 如果引用计数器为零,说明该对象不再被引用,可以被回收。

回收对象

  1. 当一个对象被回收时,其占用的内存空间会被释放。
  2. 同时,该对象引用的其他对象的引用计数也会相应减少。
  3. 如果其他对象的引用计数也变为零,这些对象也会被回收,整个过程递归进行。

我们同样使用一段代码来简单模拟一下引用计数的操作

// 引用计数器
class RefCount {
  constructor() {
    this.count = 0;
  }

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }
}

// 对象类
class MyObject {
  constructor() {
    this.refCount = new RefCount();
    this.refCount.increment(); // 对象被创建时,引用计数加1
  }

  addReference() {
    this.refCount.increment(); // 引用增加时,引用计数加1
  }

  releaseReference() {
    this.refCount.decrement(); // 引用减少时,引用计数减1
    if (this.refCount.count === 0) {
      this.cleanup(); // 引用计数为0时,进行清理操作
    }
  }

  cleanup() {
    // 执行清理操作,释放资源
    console.log("清理完成");
  }
}
// 创建对象并建立引用关系
const obj1 = new MyObject();
// 建立引用关系
obj1.addReference();
console.log(obj1.refCount);
// 解除引用关系
obj1.releaseReference();
obj1.releaseReference();
console.log(obj1.refCount);

RefCount类是一个简单的计数器,使用MyObject类创建新的类,使用计数器的addReference函数增加引用数量,使用releaseReference解除引用关系,此时数量会减一,当引用数量减到0时会执行cleanup函数对资源进行释放,达到垃圾回收效果

引用计数的特点

优点
  • 实时性:引用计数算法能够实时地检测到对象的不再被引用状态,并立即回收这些对象。一旦对象的引用计数变为零,即可立即进行回收,释放对象所占用的内存空间
  • 简单高效:引用计数算法的实现相对简单,每个对象都维护一个引用计数器,通过增加和减少计数器的值来追踪对象的引用关系,这使得引用计数算法在实现上比较高效
  • 处理循环引用:引用计数算法通常能够处理循环引用的情况,即当两个或多个对象互相引用时,只要它们的引用计数都变为零,垃圾回收器就能够回收这些对象
缺点
  • 循环引用问题:引用计数算法无法处理循环引用的情况。当存在循环引用时,即使这些对象不再被程序使用,它们的引用计数也不会变为零,从而导致内存泄漏
  • 额外开销:引用计数算法需要维护每个对象的引用计数器,这会带来额外的内存开销。每次对象的引用发生变化时,都需要更新计数器的值,这会增加运行时的开销
  • 更新的性能开销:当对象的引用发生频繁变化时,如大量的增加和减少引用,引用计数的频繁更新可能会影响程序的性能

分代回收(Generational Collection)

分代回收是一种结合了标记清除和引用计数的垃圾回收机制,它会根据对象的生命周期将内存分为不同的代。

分代回收存在一个假设:大多数对象的生命周期都比较短暂,而只有少数对象具有较长的生命周期。基于这个假设,分代回收将对象的生命周期划分为两类:新生代(Young Generation)堆和老生代(Old Generation)堆。新生代堆用于存储大量的短期存活对象,而老生代堆则用于存储长期存活对象

关于两种分代回收的原理如下

老生代回收

老生代实际上就是上面说到的标记清除算法,这套算法适用于存活时间较长的对象

新生代回收

新生代堆被分为两个相等大小的区域:From空间和To空间

  1. 新对象分配到From空间
  2. 当From空间满时,触发垃圾回收
  3. 从根对象开始,标记所有存活的对象
  4. 将存活的对象复制到To空间中
  5. 清除已经死亡的对象
  6. 将To空间作为新的From空间,并将From空间作为新的To空间,完成垃圾回收

下面我使用JS实现一下新生代回收的过程

// 新生代回收机制
class GenerationalCollection {
  // 定义堆的From空间和To空间
  fromSpace = new Set();
  toSpace = new Set();
  garbageCollect(obj) {
    this.mark(obj); // 标记阶段
    this.sweep(); // 清除阶段
    // 切换From和To的空间
    const { to, from } = this.exchangeSet(this.fromSpace, this.toSpace);
    this.fromSpace = from;
    this.toSpace = to;
    return this;
  }
  isObj = (obj) => typeof obj === "object";
  exchangeSet(from, to) {
    from.forEach((it) => {
      to.add(it);
      from.delete(it);
    });
    return { from, to };
  }
  allocate(obj) {
    this.fromSpace.add(obj);
  }
  mark(obj) {
    if (!this.isObj(obj) || obj?.marked) return;
    obj.marked = true;
    this.isObj(obj) &&
      Reflect.ownKeys(obj).forEach((key) => this.mark(obj[key]));
  }
  sweep() {
    const { fromSpace, toSpace } = this;
    fromSpace.forEach((it) => {
      if (it.marked) {
        // 将标记对象放到To空间
        toSpace.add(it);
      }
      // 从From空间中移除该对象
      fromSpace.delete(it);
    });
  }
}
// 全局对象
const globalVar = {
    obj1: { name: "Object 1" },
    obj2: { name: "Object 2" },
    obj3: { name: "Object 3" }
}
const GC = new GenerationalCollection()
// 创建对象并分配到From空间
GC.allocate(globalVar.obj1)
GC.allocate(globalVar.obj2)
console.log(GC.fromSpace, GC.toSpace);
// 执行垃圾回收
GC.garbageCollect(globalVar)
console.log(GC.fromSpace, GC.toSpace);

简单描述一下上面的代码,allocate函数将对象放到From堆空间中,mark函数对对象及属性添加标记,在sweep清除函数中如果对象既被标记又在From空间中那么就将其复制到To空间中,最后在垃圾回收机制函数garbageCollect中对调两个堆空间最终完成整个周期

分代回收的特点

优点
  • 提高回收效率:分代回收能够针对对象的生命周期进行不同的优化。通过区分对象所在的代,可以针对不同代采用更适合的回收策略。由于新生代对象的生命周期较短,采用复制算法进行回收可以快速地清理掉大部分垃圾对象。而老生代对象的生命周期较长,使用标记清除法进行回收可以更全面地清理垃圾对象。
  • 减少停顿时间:分代回收可以将垃圾回收任务分散到不同的时间段进行,避免一次性处理所有对象。这样可以减少单次垃圾回收的时间,从而减少系统的停顿时间,提高系统的响应能力和用户体验。
缺点
  • 需要维护多个代:分代回收需要维护不同代的对象,增加了内存管理的复杂性。
  • 内存分配和复制开销:新生代回收中使用的复制算法需要将存活的对象复制到新的空间中,这会引入一定的内存分配和复制开销。同时,分代回收中的对象移动和内存重整等操作也会带来一定的开销

内存泄漏

内存泄漏是指在程序中分配的内存无法被正常释放和回收的情况,导致内存的持续占用和增长。

它与垃圾回收机制有密切关系。垃圾回收机制的目的是自动识别和回收不再使用的内存,以避免内存泄漏和资源浪费。然而,如果存在内存泄漏,即使对象已经不再使用,垃圾回收机制也无法正确识别这些对象为垃圾并释放它们的内存。这样,内存泄漏导致的内存占用会随着时间的推移逐渐增加,直到达到系统的内存限制。

内存泄漏的场景

常见的内存泄漏场景有下面几类

无用的对象引用

当对象仍然存在引用,即使不再需要时,垃圾回收机制也无法回收这些对象。例如,未正确解除事件监听器或定时器,导致被监听的对象一直被引用,无法释放内存。

场景:使用element.addEventListener却没有使用取消函数:removeEventListener;setInterval或setTimeout没有关闭

解决:使用removeEventListener,clearTimeout等函数重置

循环引用

当两个或多个对象相互引用,并且这些对象之间没有与其他对象的引用关系时,即使这些对象不再被使用,垃圾回收机制也无法回收它们。这种情况下,对象之间形成了一个封闭的循环,导致内存泄漏。

场景:

const obj = {}
const obj1 = {}
obj.child = obj1
obj1.child = obj

解决:合理设计对象之间的引用关系,避免对象类型变量循环使用,使用弱引用或断开循环引用的方法来解决

全局变量的滥用

全局变量在整个应用程序生命周期中都存在,如果没有正确管理和释放全局变量,会导致这些变量一直存在于内存中,无法被垃圾回收机制回收。

场景:全局创建变量,在程序或页面的生命周期并未对该变量重置或者清空,则会一直处于激活状态,不会被垃圾回收机制处理

解决:限制变量的作用域,避免过多的全局变量,TS中可以使用命名空间和模块的形式,也就是JS的函数或对象

未释放的资源

例如打开的文件句柄、网络连接或数据库连接等资源,如果在使用完毕后没有正确释放,会导致内存泄漏。

场景:在网络请求时超时时间过长,请求一直等待可能会造成内存泄漏

解决:使用完操作后尽量手动断开或者设置超时,比如请求的abort函数和timeout属性,这一类现象类似于线程的死锁,无法得知何时取消,造成性能问题。

总结

JavaScript垃圾回收机制是内存管理的关键,它能够自动检测和释放不再使用的内存,提高程序的性能和可靠性。了解垃圾回收的分类、内存泄漏的原因和避免方法,以及性能优化的最佳实践,有助于开发高效的JavaScript应用程序。
以上就是文章的全部内容了,感谢你看到了这里,希望你从中获益,如果觉得文章不错的话,还希望三连支持一下博主,非常感谢!

相关代码

myCode: 基于js的一些小案例或者项目 - Gitee.com