js的垃圾回收机制

74 阅读5分钟

垃圾回收的概念

垃圾回收的本质就是:自动地周期性地找出那些“不再被需要”的内存(即从根对象出发,无论如何也访问不到的对象),并将其释放,以便后续的代码可以重新使用这部分内存。

垃圾回收的算法

1. 引用计数 (Reference Counting) - “古老但已基本被淘汰”

这是早期的一种简单算法。

  • 原理

    • 为每个对象维护一个**“引用计数器”**。
    • 当有一个新的引用指向该对象时,计数器 +1。
    • 当一个指向该对象的引用被移除或覆盖时,计数器 -1。
    • 当计数器变为 0 时,说明没有任何地方引用这个对象了,它就立即被回收。
  • 优点

    • 实现简单。
    • 垃圾可以被立即回收,不会累积。
  • 致命缺陷:无法处理“循环引用” (Circular References)

    • 场景:对象 A 的一个属性引用了对象 B,同时对象 B 的一个属性也引用了对象 A。

      let objA = {};
      let objB = {};
      objA.b = objB;
      objB.a = objA;
      
    • 问题:此时,objA 和 objB 的引用计数都至少为 1。即使我们后续将 objA 和 objB 的外部引用都断开(objA = null; objB = null;),它们俩的引用计数也永远不会变为 0,因为它们在互相“续命”。

    • 后果:这两个对象形成了一个无法被回收的“内存孤岛”,导致内存泄漏

    • 由于这个致命缺陷,现代 JavaScript 引擎不再使用单纯的引用计数算法。

2. 标记-清除 (Mark-and-Sweep) - “现代 GC 的基石”

这是目前 JavaScript 中最主流的垃圾回收算法。我们上面的“公寓管理员”比喻,描述的就是这个过程。

  • 原理

    1. 标记阶段 (Mark Phase)

      • 垃圾回收器从一组根 (Roots)  对象开始(在浏览器中,根通常是 window 对象)。
      • 递归地遍历所有从根对象出发可以访问到 (reachable)  的对象,并给它们打上一个“存活”的标记。
    2. 清除阶段 (Sweep Phase)

      • 垃圾回收器遍历整个堆内存,将所有没有被打上“存活”标记的对象,都视为垃圾,并回收它们占用的内存。
  • 优点

    • 完美解决了循环引用的问题。在上面的例子中,objA 和 objB 形成的“孤岛”与根对象 window 之间没有任何引用链,因此它们不会被标记,最终会被一起回收。
  • 缺点

    • 内存碎片化 (Memory Fragmentation) :清除后,内存空间会变得不连续,就像一块奶酪上有很多孔洞。这可能会导致后续需要分配大块连续内存时找不到合适的空间。
    • “Stop-the-World” :在执行标记和清除的过程中,通常需要暂停 JavaScript 应用的执行,这可能会导致页面在垃圾回收期间出现短暂的卡顿
3. 标记-整理 (Mark-and-Compact)

这是对“标记-清除”算法的一个改进,用于解决内存碎片化问题。

  • 原理

    • 标记阶段与“标记-清除”完全相同。
    • 在清除阶段,它不是直接清理垃圾,而是将所有存活的对象,向内存的一端进行移动、整理,使它们紧凑地排列在一起。
    • 然后,直接清理掉边界之外的所有内存。
  • 优点没有内存碎片

  • 缺点:移动对象的成本较高,比“标记-清除”更耗时。


三、V8 引擎的分代回收 (Generational Collection) - “因材施教”

现代 JS 引擎(如 Chrome 的 V8)为了极致的性能,并不会用同一种算法处理所有对象。它采用了一种分代回收的策略,这基于一个重要的观察: “大部分对象都是‘朝生夕死’的。”

V8 将堆内存分为了两个主要区域:

  1. 新生代 (New Generation / Young Generation)

    • 存放对象:新创建的、生命周期很短的对象。

    • 特点:空间小(通常只有几 MB),但垃圾回收非常频繁

    • 回收算法:Scavenge 算法(一种复制算法)

      • 将新生代空间一分为二:From 空间To 空间
      • 新对象首先被分配在 From 空间。
      • 当 From 空间快满时,触发一次“小回收 (Minor GC)”。
      • GC 遍历 From 空间,将其中仍然存活的对象复制空的 To 空间
      • 复制完成后,整个 From 空间的所有内容都被视为垃圾,被一次性清空。
      • 最后,From 空间和 To 空间的角色互换
    • 优点:速度极快,没有内存碎片。非常适合处理大量“短命”对象。

  2. 老生代 (Old Generation)

    • 存放对象:经过多次“小回收”后仍然存活的对象(比如全局变量、闭包引用的变量),或者一些一开始就很大的对象。

    • 特点:空间大,垃圾回收频率较低

    • 回收算法:标记-清除 (Mark-and-Sweep) + 标记-整理 (Mark-and-Compact)

      • 当老生代空间不足时,触发一次“主回收 (Major GC)”。
      • 主要使用“标记-清除”算法。
      • 当发现内存碎片化问题严重时,会触发一次“标记-整理”算法来整理空间。
    • 优化:为了避免长时间的“Stop-the-World”,V8 引入了增量标记 (Incremental Marking)  和并发标记 (Concurrent Marking)  等技术,让垃圾回收的过程可以和 JavaScript 应用的执行穿插进行,从而减少卡顿。


四、作为开发者,我需要做什么?

垃圾回收是自动的,但我们的代码风格会直接影响其效率。

  1. 避免意外的全局变量

    • 所有不再需要的变量,都应该确保它们的作用域是局部的。
    • let 和 const 是你的好朋友。
  2. 警惕闭包导致的内存泄漏

    • 这是最常见的内存泄漏来源。确保在不再需要时,解除对闭包的引用(比如移除事件监听器、清除定时器)。
  3. 手动解除引用

    • 对于一些占用内存巨大的对象(比如一个处理完的大数组),当你可以确定它不再被需要时,可以手动将其设置为 null,这可以帮助(但不能保证)垃圾回收器更早地识别并回收它。
    let largeArray = [ ... ];
    // ... process largeArray
    largeArray = null; // 解除引用