全面解析 JavaScript 垃圾回收机制:从原理到优化

js 是一种高级语言,内存分配和释放由引擎自动完成。垃圾回收(Garbage Collection, GC)作为引擎的一部分,负责释放不再使用的内存。

什么是垃圾回收?

垃圾回收是为了释放不再使用的内存,从而避免内存泄漏和性能下降。js 的垃圾回收主要通过检测对象是否仍然可达(reachable)来确定是否可以回收。

标记-清除(Mark-and-Sweep)

  • 从“根对象”(如全局对象和当前作用域的变量)开始,递归检查所有被引用的对象。
  • 没有被引用的对象会被清除。
  • 优势:解决了循环引用问题。
let globalVar = {};
function foo() {
    let localVar = {}; // localVar 在函数结束后变得不可达
}
foo();

引用计数

  • 每个对象记录引用它的次数。
  • 引用次数为零时,对象被垃圾回收器回收。
  • 缺点:无法处理循环引用
const obj1 = {};
const obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1; // 循环引用,引用计数永不为零

垃圾回收的触发条件

引擎会根据分代模型和内存使用情况,动态调整垃圾回收频率。

分配新内存时,可能触发 GC。

内存压力过大时,强制触发 GC。

V8 引擎中的垃圾回收实现

V8 的分代回收模型

  • 新生代对象 (Young Generation)
    • 存储生命周期较短的对象(如函数内部变量)。
    • 使用 Scavenge 算法:将活跃对象复制到新的内存空间,释放旧内存空间。
let temp = { x: 1 }; // temp 在函数外无用,会快速被回收
  • 老生代对象 (Old Generation)
    • 存储生命周期较长的对象(如全局变量和缓存)。
    • 使用 标记清除 + 标记整理
      • 标记清除 (Mark-and-Sweep):回收不可达对象。
      • 标记整理 (Mark-and-Compact):将存活对象压缩到内存的一端,避免碎片化。

增量式和并发式垃圾回收

  • 增量式垃圾回收
    • 将标记阶段拆分成多个小任务,以减少单次 GC 的暂停时间。
  • 并发式垃圾回收
    • 标记和回收在主线程与后台线程同时进行,提升性能。

示例代码与生命周期分析

// 新生代对象分配
let shortLived = { data: "temporary" };

// 触发晋升:老生代对象
let longLived = { data: "persistent" };
// longLived 被多次存活,会被移到老生代

垃圾回收中常见问题与示例

1. 意外的全局变量

问题:未正确声明变量会创建全局变量,占用全局作用域,导致内存泄漏。

function leak() {
    leakedVar = "I am a leak!"; // 漏洞:`leakedVar` 被自动添加到全局对象
}
leak();
console.log(window.leakedVar); // 全局变量泄漏

解决方法: 严格模式可以防止意外创建全局变量:

"use strict";
function noLeak() {
    let localVar = "I am safe!";
}

未清理的定时器和回调

问题:如果未清理定时器或事件监听器,相关对象会被引用,无法被回收。

const button = document.getElementById("btn");
button.addEventListener("click", () => {
    console.log("Clicked!");
});
// 如果不移除监听器,即使按钮被移除,回调仍然占用内存

解决方法: 手动清理事件监听器或定时器:

button.removeEventListener("click", callback);
clearInterval(timerId);

3. 闭包的内存占用

问题:闭包会保留外部作用域的变量,可能导致意外的内存泄漏。

function createClosure() {
    let data = new Array(1000000).fill("leak");
    return function () {
        console.log(data.length);
    };
}
const closure = createClosure();
// 即使不再需要 `data`,它仍然被闭包引用

解决方法: 确保在闭包中只保留必要的数据:

function createOptimizedClosure() {
    let data = new Array(1000000).fill("leak");
    return function () {
        console.log(data.length);
        data = null; // 手动释放引用
    };
}

4. DOM 节点的未清理引用

问题:移除 DOM 节点时,如果仍然保留对节点的引用,会导致无法回收。

let element = document.getElementById("myDiv");
document.body.removeChild(element);
// `element` 仍然保留对 DOM 的引用,未被释放

解决方法: 将变量设为 null 来手动清理引用:

element = null;

5. 大量缓存数据

问题:使用全局变量或单例模式时,大量保存数据可能导致内存占用过多。

const cache = {};
for (let i = 0; i < 1000000; i++) {
    cache[i] = `Data ${i}`;
}

解决方法

  • 使用弱引用数据结构(如 WeakMapWeakSet
const weakCache = new WeakMap();
let obj = {};
weakCache.set(obj, "value");
obj = null; // 自动回收
  • 定期清理无用数据。

调试工具与方法

  • Chrome DevTools
    • Memory 面板:捕获堆快照,分析未回收对象。
    • Performance 面板:查看 GC 的频率与耗时。
  • Node.js
    • 使用 --trace-gc 查看垃圾回收日志:
node --trace-gc app.js

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容