JavaScript 垃圾回收机制

一、垃圾回收机制的核心概念

1.1 内存的生命周期

  • 内存分配:当声明变量、函数、对象时,系统会自动分配内存给它们
  • 内存使用:即读写内存,也就是使用变量、函数
  • 内存回收:使用完毕,由垃圾回收器自动回收不再使用的内存

1.2 什么是垃圾回收(Garbage Collection,GC)

  • 定义:JS中的函数,变量,对象等都需要占用一定的内存,当这些东西不再被使用的时候,就变成了垃圾,如果不进行回收,内存就会被一直占用,随着程序的运行,垃圾也会越来越多,总有一刻,内存会被占满,程序也就无法运行了
  • 为什么需要 GC:JavaScript 作为高级语言,不允许开发者直接操作内存(如 C/C++ 的 手动内存管理原语malloc/free
  • 垃圾回收机制可以自动处理内存的分配和释放,避免了手动内存管理导致的内存泄露、野指针等问题,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源,降低开发成本

1.3 垃圾的判定标准

  • 核心原则:该内存空间是否还能被程序 “访问到”
  • 可访问场景(非垃圾):
    1. 全局作用域中声明的变量(如 window/global 上的属性)
    2. 函数执行栈中正在执行的函数内的局部变量、参数
    3. 通过「可达链」能关联到上述两类变量的对象(如 A 是全局变量,A 的属性 B 引用对象 C,则 C 是可达的)
  • 不可访问场景(垃圾):
    • 函数执行结束后,局部变量未被外部引用,且无法通过任何可达链关联到全局 / 执行栈
    • 手动将变量赋值为 null/undefined 后,若没有其他引用,原指向的对象会成为垃圾

二、JavaScript 中常见的垃圾回收算法

2.1 引用计数法(Reference Counting)

2.1.1 核心原理

引用计数法的核心逻辑是:为每个内存对象维护一个 “引用计数器”,记录当前有多少个活跃引用指向该对象

  • 当对象被引用时(如被变量赋值 let obj = new Object()、作为参数传递等),计数器 +1
  • 当引用失效时(如变量被重新赋值 obj = null、离开作用域等),计数器 -1
  • 当计数器变为 0 时,引擎判定该对象垃圾,会被立即回收并释放内存。

2.1.2 优点

  1. 实时性,即时回收

    对象引用计数为 0 时会被立即回收,避免内存堆积,适合对内存敏感的场景。

  2. 实现简单

    逻辑直观,只需维护计数器和增减操作,对引擎复杂度要求低。

  3. 减少卡顿

    回收过程分散在程序运行中(随引用变化触发),不会像 “标记 - 清除” 算法那样偶尔出现长时间的 GC 停顿,对程序运行的中断影响小。

2.1.3 缺点(核心局限性)

  1. 无法解决循环引用(核心原因): 当两个或多个对象相互引用时,即使它们已无外部引用,计数器也始终大于 0,导致内存泄漏。例如:

    function createCycle() {
      // objA 引用 objB,objB 引用 objA,形成循环
      let objA = {};
      let objB = {};
      objA.b = objB;
      objB.a = objA;
    }
    // 函数执行结束后,objA、objB 局部变量销毁,但两者的属性互相引用,计数器均为 1
    // 引用计数法无法识别,这部分内存永远无法释放,造成泄露
    createCycle();
    
  2. 额外性能开销: 每次引用变化(赋值、删除等)都需要更新计数器,频繁操作会消耗额外资源,尤其在复杂对象引用场景中。

  3. 空间占用: 每个对象都需额外存储计数器,增加了内存 overhead(尤其对大量小对象场景)。

2.1.4总结

​ 引用计数法是一种简单直观的垃圾回收算法,通过跟踪对象引用数量实现内存管理,优点是即时回收、实现简单,但核心缺陷是无法处理循环引用,且存在性能和空间开销。随着 JS 引擎发展,它已不再是主流算法,但理解其原理有助于深入掌握垃圾回收机制的设计思路。

2.2 标记清除法

2.2.1 算法原理

  • 核心逻辑:基于「可达性」判定垃圾,分「标记」和「清除」两个阶段,定期执行(非实时)
  • 执行流程:
    1. 标记阶段:从「根对象」(全局对象 window/global、执行栈中的变量)出发,遍历所有可达的对象,标记为「活跃对象」
    2. 清除阶段:遍历整个内存空间,将未被标记的对象(不可达)判定为垃圾,释放其内存
    3. 可选:整理阶段:将活跃对象内存地址连续排列,减少内存碎片(部分引擎优化,如「标记 - 整理法」

2.2.2 优点

1. 解决循环引用问题
2. 性能开销低,无需去维护一个引用计数器

2.2.3 缺点

1. 非实时性:GC 执行时会中断程序(“GC 暂停”),若内存量大,可能影响用户体验
1. 内存碎片:释放的内存地址不连续,后续大对象可能无法分配

三、更多

3.1 现代引擎优化

  • 分代收集(Generational Collection):这是最值得补充的一点。V8将堆内存分为新生代(Young Generation)老生代(Old Generation)。绝大多数新创建的对象都被分配在新生代。因为观察表明大多数对象的生命周期都非常短。新生代区域小但GC非常频繁(使用Scavenge算法,一种复制算法);存活过一定次数的对象会被晋升到老生代,老生代区域大但GC频率低(使用标记-清除/标记-整理/标记-增量整理算法)。
  • 并行、增量、并发回收:为了减少GC带来的程序暂停(Stop-The-World),现代GC器使用了多种技术:
    • 并行(Parallel):主线程和辅助线程同时执行同样的GC工作。
    • 增量(Incremental):将完整的GC工作拆分成多个小任务,穿插在JavaScript任务之间执行,避免长时间停顿。
    • 并发(Concurrent):辅助线程在后台执行GC工作,完全不需要主线程停顿。

3.2 作为开发者,如何辅助GC(避免内存泄漏)

这是面试中非常喜欢问的“如何做”部分。

常见内存泄漏场景及避免方法:

  1. 意外的全局变量:未声明的变量或this指向改变导致的全局变量。
    • 避免:总是使用 'use strict'
  2. 被遗忘的定时器或回调函数setIntervalsetTimeout 以及事件监听器不再需要时未被清除。
    • 避免:使用 clearInterval/clearTimeout 清除;对于事件监听器,在不需要时使用 removeEventListener
  3. 脱离DOM的引用:在JavaScript中缓存了DOM元素的引用,即使该元素已从DOM树中移除,因为JS还引用着,它也不会被GC。
    • 避免:在移除DOM元素后,手动将其引用置为 null
  4. 闭包:闭包可以维持函数内部变量的引用,如果使用不当(例如在闭包中持有大量数据的引用),可能导致这些数据无法被释放。
← 返回列表