垃圾回收

❓为什么要垃圾回收?

想象一下你的电脑内存就像是一个巨大的酒店,而你的 JavaScript 代码就是酒店的前台和客人。

  1. 入住(分配内存)​​:当你的代码声明一个变量、创建一个对象或函数时(例如 let user = {name: 'John'};),就像是一位客人来入住。前台(JavaScript 引擎)会为这位客人分配一个房间(一块内存空间)。
  2. ​离店(释放内存)​​:当客人离开(变量不再被需要)后,他们的房间应该被腾出来,打扫干净,以便给新的客人使用。

酒店房间是有限的,如果只分配不释放,酒店很快就会住满,新的客人就无法入住(程序耗尽内存,导致速度变慢甚至崩溃)。​垃圾回收机制就是酒店里自动化的保洁团队,它的任务就是不断地巡视酒店,找出那些空置的房间(不再使用的内存),并把它清理干净。

🤔 如何判断“垃圾”?

保洁团队(GC)的核心工作是判断哪个房间是“空置”的。它们主要使用两种策略:

1、引用计数

📖 思路:每个值都有一个计数器,记录有多少个变量引用​(指向)它。如果一个值的引用计数变为 0,则表示没有任何方式可以访问到它了,它就是垃圾。 🤩 过程​

  1. 一个值被赋给变量时,它的引用计数为1。
  2. 如果它又被赋给另一个变量,计数 +1。
  3. 如果一个变量被覆盖或超出了作用域(比如函数执行完毕),它的引用计数 -1。
  4. 当计数变为 0 时,内存立即被回收。

致命缺陷:循环引用

function problem() {
  let objA = {}; // 引用计数:1 (objA)
  let objB = {}; // 引用计数:1 (objB)

  // objB 引用计数:2 (objB, objA.paramA)
  objA.paramA = objB;
  // objA 引用计数:2 (objA, objB.paramB)
  objB.paramB = objA;
}
// 函数执行完毕,objA 和 objB 这两个局部变量
// 已经超出作用域,被销毁了。

// 但 objA 和 objB 对象的引用计数都从 2 减为 1
// (它们互相引用着对方),永远不会变为 0!

// 内存泄漏(Memory Leak)发生了!

由于这个无法解决的缺陷,现代浏览器引擎不再单独使用引用计数算法。

2、标记-清除

这是当前所有现代 JavaScript 引擎(V8, JavaScriptCore 等)采用的核心算法。它的概念更聪明:​判断“是否可达”(Reachability)​,而不是数引用次数。

🤔 思路​:有一组被称为的固有可达值。例如:

  • 当前执行的函数中的局部变量和参数。
  • 全局变量。
  • 等等...

从这些出发,凡是能通过引用链(对象的属性引用等)访问到的值,都被认为是可达的(Reachable)​,是“正在使用的”,必须保留。反之,任何无法从根访问到的值,都被标记为不可达的(Unreachable)​,即是“垃圾”。

🤖 ​过程(两个阶段)​​:

  1. ​标记(Mark)​​:垃圾回收器从根对象开始,遍历整个对象图,将所有可达的值都打上一个“标记”。
  2. ​清除(Sweep)​​:垃圾回收器将所有没有被标记的内存块(即不可达的垃圾)回收并返还给操作系统。

✌🏻 ​解决循环引用​:在上面的 objAobjB 例子中,函数执行完毕后,objAobjB 这两个局部变量(根)已经消失。这两个对象无法从任何根访问到,因此它们都会被标记为不可达,并在清除阶段被回收。​问题完美解决!​​

3、优化策略 - 让GC更高效

标记-清除算法很好,但如果每次都要遍历整个内存,会造成明显的程序暂停。为了优化用户体验,现代JS引擎在标记-清除的基础上增加了复杂的优化策略。

1️⃣ 分代回收

观察发现:绝大多数对象都是“朝生夕死(比如函数内的临时变量)。存活时间长的对象,大概率还会继续存活很久。V8 引擎将堆内存分为两个代​​:

  • 新生代:存放新创建的对象。这里的垃圾回收非常频繁、快速,使用一个叫 ​Scavenge​ 的算法(一种复制算法)。
  • 老生代:新生代中经历过多次回收仍然存活的对象会被晋升到这里。这里的对象存活时间长,回收频率较低,但因为数据量大,回收时间更长,使用的是标记-清除及其变体(标记-整理)​。

​这样做的好处​:将大部分频繁但快速的回收集中在小的新生代区域,而将对性能影响大的完整回收集中在大的老生代,减少了整体的停顿时间。

2️⃣ 增量回收

  • ​问题​:遍历一个非常大的对象图可能需要很长时间,导致单次 GC 停顿过长,页面卡顿。
  • ​策略​:GC 不再尝试一次做完所有工作,而是将完整的标记工作分解成多个小部分,穿插在 JavaScript 的执行过程中。这样虽然总时间可能更长,但每次停顿的时间很短,用户几乎无感。

3️⃣ 闲时回收

  • 策略​:GC 会尝试在 CPU 空闲时(比如在动画的每帧之间的间隔期)运行,进一步减少对关键任务的影响。

如何避免内存泄漏

1️⃣ DOM 引用管理​

移除 DOM 元素时同步清理事件监听器及变量引用

let el = document.getElementById("el");
el.removeEventListener("click", handler);
el.parentNode.removeChild(el);
el = null; // 解除引用

2️⃣ ​定时器与闭包​

  • 使用clearInterval/clearTimeout清理定时器。
  • 避免闭包捕获大型对象(如数组),必要时手动释放(closure = null)

3️⃣ ​弱引用数据结构​

  • 使用 WeakMap 存储对象关联数据,键对象回收时值自动释放

💎 总结

  1. ​目的​:自动管理内存,释放不再使用的内存,防止内存泄漏。
  2. ​核心算法​:​标记-清除​。从“根”出发,标记所有可达值,然后清除不可达值。
  3. ​关键优化​:分代回收、增量回收、闲时回收