Skip to content

V8 的垃圾回收机制

V8 的垃圾回收策略主要基于分代式垃圾回收机制。现代的垃圾回收算法中按对象的存活时间将内存进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

V8 的内存分代

V8 中将内存分为新生代和老生代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。V8 堆的整体大小就是新生代所用的内存空间加上老生代所用的内存空间。

  • 新生代:主要存放生命周期较短的对象,空间较小(通常为几 MB)。

  • 老生代:主要存放生命周期较长的对象,空间较大(几十 MB 到几百 MB)。

新生代垃圾回收:Scavenge 算法

新生代的对象主要通过 Scavenge 算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用了 Cheney 算法。Cheney 算法是一种采用复制的方式实现垃圾回收的算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,称为 From 空间,另一个处于闲置状态,称为 To 空间。

  • 分配对象时,先在 From 空间中进行分配。
  • 垃圾回收时,将 From 空间中的存活对象复制到 To 空间,非存活对象占用的空间被释放。
  • 复制完成后,From 和 To 空间角色对换(翻转)。

优缺点:

  • 优点:只复制存活对象,速度快,适合新生代。
  • 缺点:只能使用堆内存的一半,空间利用率低。

对象晋升

当一个对象经过多次 Scavenge 回收依然存活,或 To 空间占用超过 25%,该对象会被晋升到老生代

老生代垃圾回收:Mark-Sweep & Mark-Compact

老生代对象采用 Mark-Sweep(标记-清除)和 Mark-Compact(标记-整理)算法。

  • Mark-Sweep:分为标记和清除两个阶段。标记阶段遍历堆中所有对象,标记存活对象;清除阶段清理未被标记的对象。不会移动对象,但会产生内存碎片。

  • Mark-Compact:在 Mark-Sweep 基础上,整理阶段将存活对象向一端移动,消除碎片,提高空间利用率,但速度较慢。

V8 通常先用 Mark-Sweep,空间不足时再用 Mark-Compact。

回收算法速度空间开销是否移动对象
Scavenge最快双倍空间(无碎片)
Mark-Sweep中等少(有碎片)
Mark-Compact最慢少(无碎片)

增量标记

为减少垃圾回收带来的停顿,V8 将标记阶段拆分为多个小步进(Incremental Marking),每完成一步就让 JS 逻辑执行一会,实现垃圾回收与应用逻辑交替执行,极大降低了最大停顿时间。

此外,V8 还引入了 Lazy Sweeping(延迟清理)、Incremental Compaction(增量整理)、并行标记与并行清理等机制,进一步提升性能。

引用计数(已废弃)

如果 a 对象引用了 b 对象,计数器 + 1,引用失效时计数器 - 1,当计数器为 0 时立即回收。引用计数算法因循环引用导致内存泄漏,现代浏览器已不再采用。

如何触发垃圾回收

  • 作用域销毁:函数执行结束,局部变量失效,对象引用断开,等待回收。

    js
    const foo = () => {
      const local = {}
    }
    
    foo() // 函数执行结束结束后,local 对象等待回收
  • 变量主动释放:通过 delete 或赋值为 null/undefined 解除引用关系,便于垃圾回收。

    js
    window.foo = 'foo'
    window.bar = 'bar'
    
    delete window.foo
    window.bar = null // 或 undefined
  • 内存压力:当分配新对象时发现空间不足,会自动触发垃圾回收。

常见的内存泄露操作

  • 全局变量未释放

    javascript
    window.foo = {}
    bar = {} // 隐式全局变量
    
    const baz = function () {
      this.baz = 'baz' // this -> window
    }
    
    baz()
  • 未被清理的定时器

    javascript
    const foo = {}
    const timer = setInterval(() => {
      console.log(foo)
    }, 1000)
    // clearInterval(timer) 后 foo 才能被回收

    V8 会为其创建一个 DOMTimer,被挂载到 window 的内部节点上,无法直接访问到。 DOMTimer 里保存着 ScheduleAction 对象,ScheduleAction 里保存着 V8Function 对象, V8Function 里保存着重复调用的函数。不清理定时器导致定时器回调以及引用的外部变量 foo 都不能被回收。

  • 闭包引用

    javascript
    const foo = () => {
      const bar = {}
    
      return () => {}
    }
    
    const baz = foo() // baz 持有闭包,bar 无法被回收
  • DOM 引用未解除

    javascript
    const image = document.getElementById('image')
    // image 没有解除对该 DOM 的引用,image 元素不能被回收。
    document.body.removeChild(image)
    // 解除引用,之后会被回收。
    image = null

    如果引用了子元素,当把父元素删除时如果没有解除子元素的引用,父元素和子元素都不会被回收。

  • 事件监听未移除

    组件销毁时不要忘记移除事件监听。DOM 元素在绑定事件时,V8 为会其创建一个 V8EventListener 类的实例,这个对象中保存着对 DOM 的引用以及事件回调的引用。 DOM 从文档流中移除时,DOM 和 为其绑定的事件都会被标记为 Detached,如果不解除绑定, 这两个对象始终存在 window 上,无法被回收。

    js
    function cb() {}
    window.addEventListener('click', cb)
    // 组件销毁时
    window.removeEventListener('click', cb)
  • console 对象引用

    在开发调试时,console 可能会导致内存泄漏。例如,Chrome 的控制台会保留 console.log 输出的对象引用,防止其被回收,便于开发者调试。如果输出了大量大对象,或频繁在控制台查看对象,可能导致这些对象无法及时被回收,造成内存占用增加。

    js
    const bigObject = { /* 很大的对象 */ }
    console.log(bigObject)
    // bigObject 可能被控制台引用,无法及时回收