JavaScript GC 垃圾回收机制

283 阅读9分钟

垃圾回收(Garbage Collection, GC)

浏览器使用的垃圾回收方法:

1. 引用计数(Reference Counting)

  • 在这种方法中,每个对象都有一个与之关联的计数器,记录有多少引用指向该对象。当引用计数降至零时,说明没有任何引用指向该对象,因此可以安全地回收。此算法简单但容易造成循环引用问题,导致内存泄漏。

2. 标记-清除(Mark-Sweep)

  • 这是最基本也是最早的垃圾回收算法之一。算法分为两个阶段:标记阶段和清除阶段。在标记阶段,GC遍历所有从根开始可达的对象,并标记它们;在清除阶段,未被标记的对象被视为垃圾并回收。这种方法的缺点是它会导致内存碎片化,可能影响性能。
    function func(n){
        // 标记 n 进入,函数作用域
        {
            var a = 1; // 标记 a 进入,变量提升,函数作用域
            let b = 2; // 标记 b 进入,块级作用域
            const c = 3; // 标记 c 进入,块级作用域
            n = n * a * b;
        } 
        // 标记 b、c 退出,b、c 等待垃圾回收,
        // 垃圾回收程序可提前介入,不一定要等函数作用域返回;
        // 垃圾回收程序可以在任何时刻运行,它的触发时机取决于JavaScript引擎的实现;
        console.log(n);
        ...
        return n;// 标记 n、a 退出, n、a 等待垃圾回收
    }
    
    • letconst对 gc 的帮助:限制作用域,避免变量提升,帮助块级垃圾回收;const还可降低 因重复或无用赋值引起的内存泄露 风险;

3. 停止-复制(Stop-and-Copy)

  • 此方法将内存划分成两块,一块处于使用中,另一块处于空闲状态。垃圾回收时,活动对象从当前使用的内存块复制到空闲块,然后两块的角色互换。这种方法解决了内存碎片问题,但要求有一半的内存空间处于闲置状态,降低了内存使用效率。

  • 停止-复制算法的步骤:

        flowchart LR 
        B[停止程序执行] --> C[标记存活对象] 
        C --> D[复制存活对象到新内存] 
        D --> E[更新引用到新对象地址] 
        E --> F[清理原内存区域] 
        F --> G[恢复程序执行] 
    
    1. 停止(Stop):

    • 算法"暂停"程序的执行,以便GC可以执行其任务而不受干扰。

    1. 标记(Mark):

    • GC遍历所有从根集(root set,例如全局变量、活跃的栈帧中的局部变量等)可达 的对象。标记所有存活的对象,此处的存活指的是程序还可能访问到的对象。

    1. 复制(Copy):

    • 标记为存活的对象被复制到一个新的内存区块。当复制过程完成后,存活对象都将位于这个新的区块中,通常是连续的,这有助于减少内存碎片。

    • 在复制过程中,每个被复制的对象的地址都会被更新。确保程序中的所有引用都指向新的对象位置。

    1. 清理(Cleanup):

    • 原来的内存区域(包括所有未被标记为存活的对象)将被视为"垃圾",并且被整个释放,换句话说,这部分内存变得可以重新分配。

    • 事实上,经过复制后,原来的内存区域中的大部分或全部内容都将成为无用数据。

    1. 恢复(Resume):

    • 一旦GC完成其任务,程序的执行会从之前停止的地方继续恢复。

    停止-复制算法的优缺点:

    优点:

    • 垃圾内存的清理是彻底的,因为整个旧内存区域都会被释放。
    • 减少内存碎片,因为存活的对象在新内存中是连续放置的。
    • 实现相对简单。因为清理过程仅仅涉及释放整个旧的存储区。

    缺点:

    • 需要额外的内存。因为在复制存活对象时,必须有足够的额外空间来存放所有存活的对象。
    • 停止整个程序可能会导致性能问题,特别是在实时系统中,长时间的停顿是不可容忍的。
    • 复制操作可能引起一定的性能开销,尤其是当存活对象较多时。

4. 增量回收(Incremental GC)

  • 增量GC通过将垃圾收集的工作分割成小块来执行,从而减少每次垃圾回收引起的停顿时间。这种方法虽然提高了应用的响应性,但由于GC的片断可能导致管理复杂度增加和整体延长垃圾回收时间。
  • 增量垃圾收集的步骤:

        flowchart LR
        B[触发增量GC] --> C[执行部分GC任务] 
        C --> D{完成?} 
        D -- 是 --> E[GC结束] 
        D -- 否 --> F[应用继续执行] 
        F --> C 
        E --> G[应用恢复]
    
    1. 触发增量GC: 系统根据内存使用情况或设定的阈值触发增量垃圾回收。
    2. 执行部分GC任务: 执行一小部分的垃圾回收任务,如标记一部分可达对象。
    3. 检查是否完成: 检查是否所有的垃圾回收任务已完成。
    4. 结束GC: 如果所有任务完成,则结束垃圾回收。
    5. 继续应用执行: 如果GC未完成,允许应用程序执行一段时间,可能是执行正常的业务逻辑。

5. 分代回收(Generational GC)

  • 基于对象的生命周期不同,将内存分为新生代和老生代。新生代存放生命周期较短的对象,使用高效的算法如Scavenge。老生代存放生命周期长的对象,使用如标记-清除或标记-整理的算法。这种策略的优点是可以针对不同的对象使用最适合的回收技术,优化内存使用和回收效率。
  • 分代垃圾回收步骤:

        flowchart LR 
        B[对象创建] --> C{对象存活?} 
        C -- 是 --> D[对象转移到老生代] 
        C -- 否 --> E[对象回收] 
        D --> F{GC触发?} 
        F -- Minor GC --> G[新生代GC] 
        F -- Major/Full GC --> H[全面GC-包括老生代] 
        G --> I[继续应用执行] 
        H --> I[继续应用执行] 
    
  • 分代垃圾回收通常将对象分为二或三代:

    1. 新生代(Young Generation)
    • 新创建的对象首先放入此区域。
    • 因为大量对象很快变得无用,所以经常进行垃圾回收,即“Minor GC”。
    • 回收速度快,使用Copying或者Scavenge算法。
    1. 老生代(Old Generation/Tenured Generation)
    • 经过多次回收仍然存活的对象会被晋升到老生代。
    • 老生代的垃圾回收频率较低但更耗时,即“Major GC”或“Full GC”,通常采用标记-清除或标记-压缩算法。
    1. 永久代(Permanent Generation,特定于某些JVM,如Sun/Oracle的HotSpot)
    • 存储类元数据和方法信息等。
    • 在一些现代的JVM中(如Oracle JDK 8+),永久代已由元空间(Metaspace)取代。

6. 并发和并行垃圾回收

  • 并发GC允许垃圾收集器在应用程序的执行过程中运行
  • 并行GC利用多核处理器同时执行垃圾回收任务。

主要的 JavaScript 引擎中的 GC 策略:

1. V8 引擎

  • 分代收集:V8使用分代垃圾收集策略,将对象分为新生代和老生代两个部分。

  • 新生代收集

    • 新生代:存放生命周期短的小对象。这部分空间较小,但经常进行垃圾回收,即 Minor GC。由于新生代中的对象生命周期短,大多数对象很快就成为垃圾,因此使用高效的算法来快速清理这些对象是合理的
    • 新生代内存大小: 64位系统-32MB,32位系统-16M。
    • 新生代内存分为两个区域,From space(活跃)、To space(闲置),各占一半。
    • 使用 Scavenge 算法,进行半空间复制:
      1. 对象生成时首先被分配在 From space;
      2. 当 From space 区域填满时进行垃圾回收,将 From space 中仍然存活的对象复制到 To space,然后清空 From Space; image.png
      3. 反转两个区域,完成垃圾回收;
  • 老生代收集

    • 老生代:存放生命周期长或大对象。老生代的垃圾回收频率较低,使用的是 Major GC,也称为 Full GC。
    • 老生代内存大小: 64位系统-1.4GB,32位系统-0.7GB
    • 老生代由新生代晋升,晋升条件:对象在新生代存活足够长的时间(经过几次GC还存活);还包括对象的大小和当前新生代空间的使用情况。
    • 老生代内存分区:
      1. 老生代存储区(Old Space)
        • 这是存储大多数老对象的主要空间。
        • 主要存放那些从新生代中晋升过来的对象。
      2. 大对象空间(Large Object Space)
        • 用于存储大型对象,即那些超过新生代大小限制的对象。
        • 这些对象直接在大对象空间分配,避免了在新生代中因为对象大小过大而导致的频繁复制。
        • 大对象空间的垃圾回收相对简单,因为每个大对象都是单独管理,容易确定是否还被引用。
      3. 代码空间(Code Space)
        • 专门用来存放可执行代码。
        • 这个空间的对象通常都是编译生成的代码块。
      4. 映射空间(Map Space)
        • 存储结构类型信息(Maps),这些信息定义了对象的结构。
        • Maps 是 V8 中所有对象类型定义的基础。
      5. Cell space, PropertyCell space 和其他
        • 这些较小的空间用于存放特定类型的数据,如闭包的变量绑定等。
    • 通过标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法清理老生代
      • 分类标记:采用深度优先遍历,遍历每个对象。
      • 标记-清除:标记所有从根节点可达的对象,清除未标记的对象。
      • 标记-整理:在清除未标记对象后,将存活的对象整理移到内存的一端,从而减少内存碎片,提高内存使用效率。
  • 增量垃圾回收和延迟清理:为了防止垃圾收集在执行时造成大的延迟,V8 引入了增量标记和延迟清理等技术。

2. SpiderMonkey 引擎

  • 精确垃圾收集:SpiderMonkey 使用确切的根跟踪技术,其中编译器在运行时生成GC的根信息。

  • 分代收集:类似于 V8,SpiderMonkey 也使用分代收集,包括对新生代的基于副本的回收以及对老生代的标记和清除或标记-整理。

  • 增量和并行GC:它能够跨多个GC增量步骤分割垃圾收集的工作,减少每次垃圾回收对程序运行的中断。

3. JavaScriptCore (safari)

  • 分代收集:JavaScriptCore 也实现了类似的分代收集框架。

  • 并发GC:从某些版本开始,JavaScriptCore 开始采用并发GC,允许垃圾回收与JavaScript代码同时运行,减少程序的暂停时间。