JavaScript 垃圾回收机制
一、垃圾回收机制的核心概念
1.1 内存的生命周期
- 内存分配:当声明变量、函数、对象时,系统会自动分配内存给它们
- 内存使用:即读写内存,也就是使用变量、函数
- 内存回收:使用完毕,由垃圾回收器自动回收不再使用的内存
1.2 什么是垃圾回收(Garbage Collection,GC)
- 定义:JS中的函数,变量,对象等都需要占用一定的内存,当这些东西不再被使用的时候,就变成了垃圾,如果不进行回收,内存就会被一直占用,随着程序的运行,垃圾也会越来越多,总有一刻,内存会被占满,程序也就无法运行了
- 为什么需要 GC:JavaScript 作为高级语言,不允许开发者直接操作内存(如 C/C++ 的 手动内存管理原语
malloc/free) - 垃圾回收机制可以自动处理内存的分配和释放,避免了手动内存管理导致的内存泄露、野指针等问题,减轻了开发人员的负担,并且降低了内存泄漏的风险,它的主要目的是自动地检测和释放不再使用的内存,以便程序能够更高效地利用系统资源,降低开发成本
1.3 垃圾的判定标准
- 核心原则:该内存空间是否还能被程序 “访问到”
- 可访问场景(非垃圾):
- 全局作用域中声明的变量(如
window/global上的属性) - 函数执行栈中正在执行的函数内的局部变量、参数
- 通过「可达链」能关联到上述两类变量的对象(如 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 优点
实时性,即时回收:
对象引用计数为 0 时会被立即回收,避免内存堆积,适合对内存敏感的场景。
实现简单:
逻辑直观,只需维护计数器和增减操作,对引擎复杂度要求低。
减少卡顿:
回收过程分散在程序运行中(随引用变化触发),不会像 “标记 - 清除” 算法那样偶尔出现长时间的 GC 停顿,对程序运行的中断影响小。
2.1.3 缺点(核心局限性)
无法解决循环引用(核心原因): 当两个或多个对象相互引用时,即使它们已无外部引用,计数器也始终大于 0,导致内存泄漏。例如:
function createCycle() { // objA 引用 objB,objB 引用 objA,形成循环 let objA = {}; let objB = {}; objA.b = objB; objB.a = objA; } // 函数执行结束后,objA、objB 局部变量销毁,但两者的属性互相引用,计数器均为 1 // 引用计数法无法识别,这部分内存永远无法释放,造成泄露 createCycle();额外性能开销: 每次引用变化(赋值、删除等)都需要更新计数器,频繁操作会消耗额外资源,尤其在复杂对象引用场景中。
空间占用: 每个对象都需额外存储计数器,增加了内存 overhead(尤其对大量小对象场景)。
2.1.4总结
引用计数法是一种简单直观的垃圾回收算法,通过跟踪对象引用数量实现内存管理,优点是即时回收、实现简单,但核心缺陷是无法处理循环引用,且存在性能和空间开销。随着 JS 引擎发展,它已不再是主流算法,但理解其原理有助于深入掌握垃圾回收机制的设计思路。
2.2 标记清除法
2.2.1 算法原理
- 核心逻辑:基于「可达性」判定垃圾,分「标记」和「清除」两个阶段,定期执行(非实时)
- 执行流程:
- 标记阶段:从「根对象」(全局对象
window/global、执行栈中的变量)出发,遍历所有可达的对象,标记为「活跃对象」 - 清除阶段:遍历整个内存空间,将未被标记的对象(不可达)判定为垃圾,释放其内存
- 可选:整理阶段:将活跃对象内存地址连续排列,减少内存碎片(部分引擎优化,如「标记 - 整理法」
- 标记阶段:从「根对象」(全局对象
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(避免内存泄漏)
这是面试中非常喜欢问的“如何做”部分。
常见内存泄漏场景及避免方法:
- 意外的全局变量:未声明的变量或
this指向改变导致的全局变量。- 避免:总是使用
'use strict'。
- 避免:总是使用
- 被遗忘的定时器或回调函数:
setInterval、setTimeout以及事件监听器不再需要时未被清除。- 避免:使用
clearInterval/clearTimeout清除;对于事件监听器,在不需要时使用removeEventListener。
- 避免:使用
- 脱离DOM的引用:在JavaScript中缓存了DOM元素的引用,即使该元素已从DOM树中移除,因为JS还引用着,它也不会被GC。
- 避免:在移除DOM元素后,手动将其引用置为
null。
- 避免:在移除DOM元素后,手动将其引用置为
- 闭包:闭包可以维持函数内部变量的引用,如果使用不当(例如在闭包中持有大量数据的引用),可能导致这些数据无法被释放。