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

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

前言

垃圾回收是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应用程序。
以上就是文章的全部内容了,感谢你看到了这里,希望你从中获益,如果觉得文章不错的话,还希望三连支持一下博主,非常感谢!

相关文章
|
15天前
|
JavaScript 前端开发 算法
【JavaScript】JavaScript 垃圾回收机制深度解析:内存管理的艺术
JavaScript的内存管理和垃圾回收机制涉及栈内存与堆内存、引用计数与标记-清除算法。栈内存存储基本类型和函数调用时的局部变量,而堆内存用于复杂数据类型,如对象和数组。垃圾回收主要通过标记-清除策略,处理不再被引用的对象。现代引擎如V8使用分代收集和增量标记等优化方法,减少停顿并提升性能。开发者应注意避免内存泄漏,如及时解除引用、管理DOM引用和定时器,使用WeakMap和WeakSet等。理解这些原理和最佳实践对于编写高效代码至关重要。
31 5
|
2天前
|
自然语言处理 前端开发 JavaScript
前端 JS 经典:闭包与内存泄漏、垃圾回收
前端 JS 经典:闭包与内存泄漏、垃圾回收
6 0
|
10天前
|
JavaScript 前端开发 算法
JavaScript 使用自动垃圾回收机制来管理内存
JavaScript 使用自动垃圾回收机制来管理内存
9 0
|
15天前
|
JavaScript 前端开发 算法
JavaScript的垃圾回收机制
JavaScript的垃圾回收机制
8 0
|
1月前
|
JavaScript 前端开发 算法
JS垃圾回收机制
JS垃圾回收机制
|
2月前
|
JavaScript 前端开发 算法
垃圾回收:JavaScript内存管理的利器
垃圾回收:JavaScript内存管理的利器
|
2月前
|
JavaScript 前端开发 算法
JavaScript的垃圾回收机制通过标记-清除算法自动管理内存
【5月更文挑战第11天】JavaScript的垃圾回收机制通过标记-清除算法自动管理内存,免除开发者处理内存泄漏问题。它从根对象开始遍历,标记活动对象,未标记的对象被视为垃圾并释放内存。优化技术包括分代收集和增量收集,以提升性能。然而,开发者仍需谨慎处理全局变量、闭包、定时器和DOM引用,防止内存泄漏,保证程序稳定性和性能。
31 0
|
2月前
|
JavaScript 前端开发 算法
JavaScript 的垃圾回收机制有一些潜在的缺点
【5月更文挑战第11天】JavaScript 的垃圾回收机制虽自动化管理内存,降低泄漏风险,但也存在性能开销、无法精确控制内存释放、全局变量和闭包可能导致内存泄漏、弱引用及循环引用问题。开发者需注意优化代码,避免这些问题,以充分利用垃圾回收机制并提升应用性能。
21 0
|
2月前
|
JavaScript 前端开发 算法
了解JavaScript的垃圾回收机制
JavaScript的垃圾回收机制自动回收不再使用的内存,主要通过标记清除和引用计数算法。它从根对象开始遍历引用链,标记活跃对象并清理未标记的。引用计数算法在对象引用为零时回收,但循环引用会导致问题。现代垃圾回收器能处理循环引用,防止内存泄漏。示例代码展示了当大数组不再被引用时,垃圾回收机制自动清理内存。
|
8天前
|
XML 缓存 JavaScript
一篇文章讲明白JS模板引擎之JST模板
一篇文章讲明白JS模板引擎之JST模板
11 2