闭包中变量的垃圾回收机制详解

121 阅读6分钟

闭包中变量的垃圾回收机制详解

在 JavaScript 中,闭包中的变量(如示例代码中的变量n)的垃圾回收时机与 JavaScript 的内存管理机制密切相关。

闭包变量的生命周期

在示例代码中:

function f1() {
  var n = 999; // 自由变量
  function f2() {
    console.log(n);
    n += 1;
    return n;
  }
  return f2;
}

var f2 = f1();
f2(); // 输出999,返回1000
console.log(f2()); // 输出1000,返回1001

变量n的生命周期如下:

  1. 创建阶段:当执行f1()时,变量nf1的作用域中被创建并初始化为 999。
  2. 保留阶段:当f1执行完毕后,通常其内部变量会被销毁。但由于内部函数f2引用了变量n,并且f2被返回并赋值给外部变量,所以n不会被立即回收。
  3. 回收阶段:变量n只有在没有任何引用指向它时才会被垃圾回收。

垃圾回收的具体时机

变量n会在以下情况下被垃圾回收:

  1. 当闭包函数被解除引用时

    var f2 = f1(); // 创建闭包
    f2 = null; // 解除对闭包函数的引用
    

    f2被赋值为null或其他值,原来的闭包函数失去了引用,垃圾回收器会在下一次回收周期中回收闭包函数及其引用的变量n

  2. 当包含闭包的作用域被销毁时: 例如,如果闭包函数是在某个事件处理函数中创建的,当该 DOM 元素被移除且事件处理函数被解绑时,闭包及其引用的变量可能会被回收。

  3. 当页面关闭或导航到其他页面时: 整个 JavaScript 环境被销毁,所有的变量和函数都会被回收。

注意事项

  1. JavaScript 的垃圾回收是自动的:开发者不能直接控制垃圾回收的精确时机。

  2. 引用计数与标记清除

    JavaScript 引擎使用两种主要的垃圾回收算法来管理内存:引用计数和标记清除。

    引用计数算法(Reference Counting)

    工作原理

    • 每个对象都有一个引用计数器,记录有多少个引用指向该对象
    • 当创建新引用指向对象时,引用计数加 1
    • 当引用离开作用域或被赋值为其他值时,引用计数减 1
    • 当引用计数变为 0 时,对象被视为"垃圾"并可以被回收

    示例

    let obj = { name: "示例对象" }; // 引用计数 = 1
    let reference = obj; // 引用计数 = 2
    obj = null; // 引用计数 = 1
    reference = null; // 引用计数 = 0,此时对象可被回收
    

    优点

    • 垃圾回收发生的时机很明确(当引用计数为 0 时)
    • 可以立即回收不再使用的对象,减少内存占用
    • 分散在程序运行过程中执行,不需要暂停程序执行

    缺点

    • 循环引用问题:如果两个对象互相引用,即使它们都不再被程序使用,它们的引用计数也不会变为 0,导致内存泄漏
    function createCycle() {
      let obj1 = {};
      let obj2 = {};
      
      // 创建循环引用
      obj1.ref = obj2;
      obj2.ref = obj1;
      
      return "函数执行完毕";
    }
    
    createCycle(); // 即使函数执行完毕,obj1 和 obj2 也不会被回收
    
    • 维护引用计数的开销较大,每次引用变化都需要更新计数
    • 无法检测到隐藏的引用(如闭包中的引用)

    标记清除算法(Mark and Sweep)

    工作原理

    1. 标记阶段:从根对象(如全局对象、当前执行上下文中的变量)开始,递归遍历所有可达对象,并标记它们
    2. 清除阶段:扫描整个内存,回收所有未被标记的对象

    示例

    // 假设垃圾回收开始时,从全局作用域出发
    let reachable = { name: "可达对象" };
    
    function createUnreachable() {
      let unreachable = { name: "不可达对象" };
      // 函数执行完毕后,unreachable 无法从根对象访问到
    }
    
    createUnreachable();
    // 垃圾回收时,reachable 会被标记,而 unreachable 不会被标记,因此会被回收
    

    优点

    • 能够解决循环引用问题
    • 可以回收所有不再使用的对象,即使它们之间存在引用关系
    • 相对于引用计数,减少了维护引用计数的开销

    缺点

    • 需要定期暂停程序执行("停顿")来进行垃圾回收
    • 如果对象数量很多,标记和清除过程可能会很耗时
    • 可能导致内存碎片化

    现代 JavaScript 引擎的优化

    现代 JavaScript 引擎(如 V8、SpiderMonkey)采用了多种优化技术来提高垃圾回收的效率:

    1. 分代回收:将对象分为"新生代"和"老生代"

      • 新生代:存活时间短的对象,使用 Scavenge 算法(一种复制算法)快速回收
      • 老生代:存活时间长的对象,使用改进的标记清除和标记整理算法
    2. 增量标记:将标记过程分解为多个小步骤,穿插在程序执行中,减少停顿时间

    3. 并发回收:在后台线程中执行部分垃圾回收工作,减少对主线程的影响

    4. 惰性清理:不立即清理所有未标记对象,而是根据需要逐步清理

    实际应用中的考虑

    1. 避免循环引用:虽然现代引擎可以处理循环引用,但养成良好习惯仍然重要

    2. 手动解除引用:对于大型对象,在不再需要时显式设置为 null 可以帮助垃圾回收器更快识别

    3. 使用 WeakMap 和 WeakSet:这些集合不会阻止其键所引用的对象被垃圾回收

    // 使用 WeakMap 避免内存泄漏
    const cache = new WeakMap();
    
    function processObject(obj) {
      if (cache.has(obj)) {
        return cache.get(obj);
      }
      
      const result = /* 复杂计算 */;
      cache.set(obj, result);
      return result;
    }
    
    // 当 obj 不再被引用时,cache 中的相应条目也会被自动回收
    
    1. 注意闭包:闭包会保持对其外部作用域变量的引用,可能导致这些变量无法被回收
  3. 内存泄漏风险: 闭包如果使用不当,可能导致内存泄漏。例如,如果闭包函数持有对大型数据结构的引用,而这个闭包又长期存在,就会阻止这些数据被回收。

  4. 手动解除引用: 在不再需要闭包时,可以通过将闭包函数变量设为null来帮助垃圾回收器回收内存:

    f2 = null; // 解除对闭包的引用,允许垃圾回收器回收闭包及其引用的变量
    

总结来说,闭包中的变量会在闭包函数本身成为垃圾可回收对象时被回收,而不是在闭包函数被调用时被回收。这也是为什么多次调用闭包函数可以持续访问并修改同一个变量的原因。