老司机带你了解JS的GC过程

153 阅读5分钟

一、内存管理:JS世界的自动清洁工

想象你正在玩一款开放世界游戏,场景中的NPC会不断生成和消失。如果消失的NPC不及时清理,内存很快会被占满导致游戏崩溃。JavaScript的垃圾回收器(GC)就是游戏世界的自动清洁工,它的任务是:

  1. 标记不再使用的对象(垃圾)
  2. 回收这些对象占用的内存
  3. 整理内存碎片提升利用率

但不同对象的生命周期差异极大,于是V8引擎祭出了杀手锏——分代回收策略


二、新生代:临时对象的快速通道

1. 适用场景

  • 存活时间短的对象(如函数局部变量、循环临时变量)
  • 内存区域较小(1-8MB)

2. Scavenge算法:双空间搬运工

内存被均分为From-SpaceTo-Space

// 伪代码示意
function scavengeGC() {
  // 1. 标记存活对象
  let liveObjects = markLiveObjects(fromSpace);
  
  // 2. 复制到To-Space
  copyToSpace(liveObjects, toSpace);
  
  // 3. 角色互换
  [fromSpace, toSpace] = [toSpace, fromSpace];
}

过程动画
(想象两间教室,每次打扫时把好课桌搬到对面教室,然后清空原教室)

3. 对象晋升

当满足以下条件时,对象会被移到老生代:

  • 经历过一次Scavenge回收仍存活
  • To-Space使用超过25%(防止空间不足)

三、老生代:长寿对象的重度监护室

1. 适用场景

  • 长期存活对象(全局变量、闭包引用)
  • 内存较大(几百MB~几GB)

2. 回收算法组合拳

  • 标记-清除(Mark-Sweep):标记无用对象后直接清除
  • 标记-整理(Mark-Compact):清除后整理内存碎片
// 标记阶段伪代码
function mark(root) {
  let queue = [root];
  while (queue.length > 0) {
    let obj = queue.pop();
    if (!obj.marked) {
      obj.marked = true;
      queue.push(...obj.children);
    }
  }
}

四、全停顿(Stop-The-World):性能杀手

问题本质

传统GC需要暂停JS主线程,老生代回收可能导致数百毫秒卡顿:

| JS执行 | GC暂停 | JS执行 | GC暂停 |
|-------|--------|-------|--------|
           ↑ 用户感觉页面卡死

破局三剑客

  1. 增量标记(Incremental Marking)
    把标记过程拆分成多个小任务,穿插在JS任务之间执行。在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记,但如果一次GC并未执行完毕,又去进行JS代码任务,对象的引用关系被修改了该如何?故而引出了【三色标记法】。

  2. 并行回收(Parallel)
    开启多个辅助线程并行处理,缩短主线程暂停时间。但问题是还会存在主线程的暂停,在量级较大的老生代可能并不适用,新生代量级较小,适合辅线程的并行处理。

  3. 惰性清理(Lazy Sweeping)
    标记完成后不立即清理,等到内存不足时再清理。即每次增量标记完成,如果当前的内存足以支撑代码的运行,那就没必要立刻清理,先让子弹飞一会儿,允许JS代码继续执行,并且也无需一次性全部清理完那些所有非活动对象内存,可以逐步清理直到清理完毕,再去接着执行增量标记任务


五、三色标记法:增量标记的基石

颜色状态机

颜色含义示意图
未访问(待回收)⚪️
已访问但子节点未处理🟡
已访问且子节点处理完成⚫️

标记流程

graph TD
    Root-->A(灰色)
    A-->B(灰色)
    A-->C(灰色)
    B-->D(白色)
    C-->E(白色)
    
    style Root fill:#000000,color:#fff
    style A fill:#808080
    style B fill:#808080
    style C fill:#808080
  1. 从根对象出发,所有直接引用标记为灰色
  2. 将灰色对象逐个处理:
    • 将其子引用标记为灰色
    • 自身标记为黑色
  3. 重复直到没有灰色对象

六、写屏障(Write Barrier):防止漏标的守护者

问题场景

增量标记期间,若已标记为黑色的父对象新增了对白色子对象的引用:

// 伪代码演示问题
let blackObj = { marked: true }; // 已标记为黑色
let whiteObj = { marked: false }; 

// 在标记过程中新增引用
blackObj.child = whiteObj; // 此时白色对象会被漏标!

写屏障机制

V8通过劫持对象写操作来维护标记正确性:

// 写屏障伪代码
function writeBarrier(parent, child) {
  if (isMarking() && isBlack(parent) && isWhite(child)) {
    // 将子对象变为灰色重新处理
    markGray(child);
    addToMarkQueue(child);
  }
  // 实际执行引用赋值
  parent.child = child;
}

七、惰性清理:化整为零的智慧

执行策略

  1. 标记阶段完成后,内存中已明确黑白对象分布
  2. 不立即清理所有白色对象,而是:
    • 优先分配新对象到空闲区域
    • 按需分块清理白色对象区域

优势对比

策略单次耗时内存利用率
全量清理立即提升
惰性清理逐步提升

八、实战:避免内存泄漏的四大法则

  1. 警惕隐蔽的全局变量
function leak() {
  leakedVar = '这是一个全局变量!'; // 未使用let/const
}
  1. 及时清理DOM引用
const cache = document.getElementById('oldElement');
document.body.removeChild(oldElement);
// 仍持有cache引用,DOM节点无法被回收!
  1. 销毁定时器与事件监听
const timer = setInterval(() => {}, 1000);
// 忘记clearInterval(timer)会导致回调函数持续持有闭包
  1. 谨慎使用闭包
function createClosure() {
  const hugeData = new Array(1000000).fill('*');
  return () => console.log('闭包持有hugeData!');
}
// 闭包虽好,但注意释放闭包(下一篇教你如何正确使用闭包)

九、调试工具推荐

  1. Chrome DevTools
    • Memory面板:拍摄堆快照分析内存分配
    • Performance面板:追踪GC事件与性能指标

十、总结与思考

核心知识点回顾

  1. 分代回收:新生代用Scavenge快速清理,老生代用标记组合拳
  2. 增量标记+写屏障+惰性清理:三位一体解决全停顿问题
  3. 三色标记法:通过颜色状态实现高效增量处理

十一、知识点梳理Xmind

垃圾回收机制.png

参考资料

  1. V8官方文档 - Memory Management
  2. 《深入理解JavaScript特性》
  3. MDN Web Docs - Memory Management

作者:jacklzhang
发布时间:205-02-24
版权声明:转载请注明出处,禁止商用