Skip to main content

GC(V8)

GCGarbage Collection(垃圾回收)。在 JS 代码执行过程中,引擎会自动分配内存并在数据不再使用时释放内存。如果释放不及时或者无法释放,就会造成内存泄漏

“V8 的垃圾回收核心是分代回收。它将内存分为新生代老生代:新生代存放存活时间短的对象,用Scavenge (复制)算法,牺牲空间换时间;老生代存放存活时间长或体积大的对象,用标记-清除 (Mark-Sweep)标记-整理 (Mark-Compact)算法。此外,为了避免 GC 阻塞主线程导致页面卡顿,V8 还引入了增量标记并发回收等优化机制。”

V8 内存限制

V8 对内存的使用有严格的限制(64 位系统约 1.4G, 32 位约 0.7G)。 为什么要限制内存? 主要是因为 V8 最初是为浏览器设计的,单页面的内存需求不大;更重要的是垃圾回收机制的时间开销。如果内存过大(比如 1.5G),执行一次完整的垃圾回收可能会导致 JavaScript 线程暂停(Stop-The-World)超过 1 秒,这在浏览器端会造成极其严重的卡顿。

分代回收策略

V8 将堆内存分为两个区域:新生代(Young Generation)老生代(Old Generation)

1. 新生代 (Scavenge 算法)

新生代内存极小(通常 1~8MB),专门用来存放存活时间极短的对象。 它采用 Scavenge(清除)算法,核心思想是用空间换时间

  • 内存划分:将新生代内存均分为两半,一半叫 From 空间,一半叫 To 空间。

  • 工作流程

    1. 新分配的对象总是存放在 From 空间。
    2. From 空间快满时,触发 GC:遍历 From 空间,找出存活的活动对象
    3. 将这些活动对象复制To 空间,并保证它们在 To 空间中内存是连续的(没有碎片)。
    4. 释放 From 空间中所有非活动对象(直接清空)。
    5. FromTo 空间角色互换,完成一次回收。
  • 对象晋升机制: 如果一个对象经过多次 Scavenge 依然存活,或者 To 空间的内存占用已经超过 25%,该对象会被直接转移到老生代中。

2. 老生代 (Mark-Sweep & Mark-Compact)

老生代存放体积较大的对象或从新生代晋升过来的对象。这里由于对象存活率高且体积大,不能再用复制算法(空间浪费大且复制成本高)。

  • 标记-清除 (Mark-Sweep)

    • 标记:从根对象(Root)开始遍历,能访问到的标记为“活动对象”。
    • 清除:遍历堆内存,将未被标记的“非活动对象”直接清除。
    • 缺点:会产生大量不连续的内存碎片
  • 标记-整理 (Mark-Compact): 为了解决内存碎片问题,V8 会在内存不足以分配大对象时触发此算法。

    • 整理:将所有存活的活动对象向内存空间的一端移动。
    • 清理:直接清理掉边界外的所有内存。

V8 垃圾回收的高级优化 (Orinoco)

传统的 GC 执行时会暂停所有 JavaScript 主线程逻辑(称为 Stop-The-World 全停顿),导致动画掉帧。V8 为此做了极致的优化:

  1. 增量标记(Incremental Marking):将一次长达几十毫秒的标记过程,拆分成许多毫秒级的小步。主线程执行一点 JS 代码,接着执行一点标记逻辑,交替进行,极大降低了最大停顿时间。
  2. 并发回收(Concurrent Sweeping/Marking):利用后台辅助线程,在不阻塞主线程的情况下,并行地去执行标记和清理工作。
  3. 懒清理(Lazy Sweeping):如果当前内存足够,V8 不会急着立即清理所有的死对象,而是按需延迟清理,直到内存确实不够用时再清理。

常见内存泄漏场景与排查

面试题:什么是内存泄漏?在前端开发中,通常有哪些情况会导致内存泄漏?

内存泄露(Memory Leak)是指:程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费。

常见泄漏场景

  1. 意外的全局变量 在非严格模式下,未声明直接赋值的变量会被挂载到 window 上,或者在全局作用域中使用 this。全局变量的生命周期与页面相同,只有在刷新或关闭时才会被释放。 解决:使用严格模式 'use strict'

  2. 被遗忘的定时器或事件监听 如果组件被销毁,但它内部注册的 setInterval 或全局的 window.addEventListener 没有被清理,那么回调函数内部引用的变量将永远无法被回收。 解决:在组件卸载(如 Vue 的 onUnmounted,React 的 useEffect cleanup)时,手动 clearIntervalremoveEventListener

  3. 闭包的不当使用 闭包会将其外部函数的变量保存在内存中。如果在长生命周期的对象中滥用了闭包,会导致外部作用域的内存一直无法释放。

  4. 游离的 DOM 引用 如果你用一个 JS 变量保存了 DOM 节点的引用,后来这个节点被从 DOM 树上移除了,但由于 JS 变量依然持有它的引用,这个 DOM 节点(及其子节点)就不会被垃圾回收,成为“游离的 DOM”。

    let element = document.getElementById("button");
    document.body.removeChild(element);
    // 节点虽然在页面上消失了,但 element 变量还保留着引用,导致内存泄漏
    element = null; // 必须手动置空解除引用

如何排查内存泄漏?

在面试中可以说出以下实际操作步骤:

  1. 打开 Chrome DevTools,切换到 Performance(性能) 面板。
  2. 勾选 Memory(内存)选项,点击录制。
  3. 在页面上执行可能导致泄漏的交互操作(比如多次进出同一个组件)。
  4. 停止录制,观察内存走势图(Heap 曲线)。
    • 正常的 GC 曲线应该是波浪形的(上升后下降)。
    • 如果曲线的最低点(Min)在不断攀升,说明每次 GC 后都有垃圾无法回收,这就是典型的内存泄漏。
  5. 进一步定位:切换到 Memory 面板,抓取 Heap Snapshot(堆快照),比对两次操作前后的快照,查找未被释放的 Detached DOM 或异常增长的大对象。

早期回收策略:引用计数 (了解即可)

IE9 之前的旧版本 IE 采用的是引用计数算法。

  • 核心:跟踪记录每个值被引用的次数。次数为 0 时释放。
  • 致命缺点:循环引用。如果对象 A 和对象 B 互相引用对方,哪怕它们已经和根作用域断开了联系,它们的引用次数也永远是 1,导致内存永远无法回收。现代浏览器早已弃用该策略,全面拥抱“标记-清除”。