Skip to content

内存管理与垃圾回收

MDN JavaScript 文档

JavaScript 的内存管理是自动的,但理解其机制对于编写高性能、无泄漏的代码至关重要。

1. 内存生命周期

  1. 分配内存:声明变量时自动分配。
  2. 使用内存:读写变量。
  3. 释放内存:垃圾回收器 (GC) 回收不再使用的内存。

栈内存 (Stack) vs 堆内存 (Heap)

  • 栈 (Stack): 存储基本数据类型 (Number, String, Boolean, Null, Undefined, Symbol, BigInt) 和 引用类型的地址指针
    • 特点:空间小,速度快,系统自动管理。
  • 堆 (Heap): 存储引用类型 (Object, Array, Function...) 的实体
    • 特点:空间大,速度慢,通过指针访问。

2. 垃圾回收机制 (GC)

2.1 引用计数 (Reference Counting) - 已过时

  • 原理:跟踪记录每个值被引用的次数。如果一个值的引用次数变为 0,就表示这个值不再用到了,可以回收。
  • 致命缺陷循环引用。如果两个对象互相引用,但不再被其他任何对象引用,它们的引用计数永远是 1,导致无法回收。
javascript
function cycle() {
  let o1 = {};
  let o2 = {};
  o1.a = o2; // o1 引用 o2
  o2.a = o1; // o2 引用 o1

  // 函数结束后,o1 和 o2 本应被回收
  // 但因为互相引用,引用计数不为 0,导致内存泄漏
}

2.2 标记清除 (Mark-and-Sweep) - 现代标准

  • 原理
    1. 根 (Roots):通常是 window (全局对象) 以及当前执行栈中的变量。
    2. 标记:垃圾回收器从根开始遍历,所有能从根到达的对象,都标记为活动对象 (Reachable)。
    3. 清除:那些没有被标记的对象(即从根出发无法到达的对象),被视为垃圾,进行回收。
  • 优势:完美解决了循环引用问题。在上面的 cycle 例子中,函数结束后,o1o2window 出发无法到达,因此会被判定为垃圾并回收。

2.3 V8 优化:分代回收

  • 新生代 (Young Generation):存活时间短的对象。使用 Scavenge 算法 (复制算法),将内存分为 From 和 To 两个空间,存活对象复制到 To,清空 From,速度极快。
  • 老生代 (Old Generation):存活时间长或晋升的对象。使用 标记-清除 (Mark-Sweep)标记-整理 (Mark-Compact) 算法。

3. 常见内存泄漏场景与排查

3.1 意外的全局变量

javascript
function foo() {
  bar = "I am global"; // 没有声明变量 (未用 let/const/var),自动挂载到 window
  this.baz = "I am also global"; // 非严格模式下 this 指向 window
}

修复:使用严格模式 'use strict',或正确声明变量。

3.2 被遗忘的定时器

javascript
const element = document.getElementById("button");
const intervalId = setInterval(() => {
  // 定时器回调函数持有 element 的引用 (闭包)
  // 即使在 DOM 中删除了 button,element 也无法被回收
  const node = element;
  if (node) {
    node.innerHTML = Date.now();
  }
}, 1000);

// 修复:组件销毁或不再需要时,必须清除定时器
clearInterval(intervalId);

3.3 脱离 DOM 的引用 (Detached DOM)

虽然在页面上删除了节点,但 JS 变量还指着它,导致无法回收。

javascript
let detachedNodes = [];
function create() {
  const ul = document.createElement("ul");
  for (let i = 0; i < 10; i++) {
    const li = document.createElement("li");
    ul.appendChild(li);
  }
  // 这里的 ul 虽然没有添加到 document.body 中 (或者被 remove 了)
  // 但因为它被 detachedNodes 数组引用,所以整个 ul 树都无法回收
  detachedNodes.push(ul);
}

修复:使用完后将变量置为 null,如 detachedNodes = null

3.4 闭包导致的内存泄漏

闭包允许函数访问其外部作用域的变量,这本身是 JS 强大的特性。但如果不当使用,会导致这些变量一直常驻内存,无法被 GC 回收。

(1) 典型案例:无意中持有大对象

javascript
function outer() {
  // 一个占用内存很大的对象
  const bigData = new Array(1000000).fill("🍔");

  // 即使 unused 没被使用,但因为与 inner 共享了词法作用域
  // 某些 JS 引擎可能会为了闭包上下文而保留整个作用域链
  const unused = function () {
    if (bigData) console.log("hi");
  };

  return function inner() {
    console.log("I am inner");
  };
}

const fn = outer();
// outer 执行完了,但返回的 inner 函数引用了 outer 的作用域
// 如果 inner 一直存在 (比如挂在全局或定时器里),bigData 可能就无法回收

(2) 修复方案

  1. 手动置空:不再需要时,将持有闭包的变量置为 null
    javascript
    let fn = outer();
    // 使用完后...
    fn = null; // 切断引用,bigData 就可以被回收了
  2. 避免在闭包中引用不必要的变量:不要在闭包外层定义巨大的、只用一次的数据,或者用完后手动设为 null

(3) 并不是所有闭包都是泄漏

只有当闭包长期存在(如挂在全局、事件监听、定时器)且持有了不再需要的资源时,才叫内存泄漏。正常的函数调用产生的闭包,执行完销毁,是不会泄漏的。

4. 深拷贝与浅拷贝详解

4.1 浅拷贝 (Shallow Copy)

只拷贝对象的第一层属性。

  • 基本类型:拷贝值。
  • 引用类型:拷贝内存地址

这意味着,如果你修改了新对象中的嵌套属性(引用类型),原始对象也会跟着变,因为它们指向同一个内存地址。

代码示例

javascript
const obj1 = {
  name: "小明",
  age: 18,
  info: {
    city: "北京", // 嵌套对象
  },
};

// 使用扩展运算符实现浅拷贝
const obj2 = { ...obj1 };

// 1. 修改第一层属性 (基本类型) -> 互不影响
obj2.name = "小红";
console.log(obj1.name); // '小明' (未变)

// 2. 修改嵌套属性 (引用类型) -> 互相影响!
obj2.info.city = "上海";
console.log(obj1.info.city); // '上海' (变了!)

其他常见浅拷贝方法

  • Object.assign({}, obj)
  • 数组方法:arr.slice(), arr.concat(), [...arr]

4.2 深拷贝 (Deep Copy)

递归拷贝所有层级,新对象与原对象完全独立,互不影响。

方案一:JSON 序列化 (乞丐版)

javascript
const newObj = JSON.parse(JSON.stringify(oldObj));
  • 优点:简单代码少。
  • 缺点
    1. 忽略 undefinedsymbolfunction
    2. Date 对象变为字符串。
    3. RegExp 变为空对象 {}
    4. 无法处理循环引用 (会报错)。

方案二:手写递归 (进阶版)

解决循环引用需要用到 WeakMap

javascript
function deepClone(target, map = new WeakMap()) {
  // 1. 基础类型直接返回
  if (target === null || typeof target !== "object") {
    return target;
  }

  // 2. 处理 Date 和 RegExp
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target);

  // 3. 处理循环引用 (如果已经拷贝过,直接返回 map 中的值)
  if (map.has(target)) return map.get(target);

  // 4. 创建新容器 (数组或对象)
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget); // 先存进去,防止递归调用死循环

  // 5. 递归拷贝属性
  for (const key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}

5. 在线演示:浅拷贝 vs 深拷贝

点击下方按钮,观察修改拷贝对象时,对原始对象的影响。

原始对象 (Original)
{
  "name": "小明",
  "age": 18,
  "info": {
    "city": "北京",
    "school": "北大"
  }
}

演示说明

  1. 浅拷贝:使用 ... 扩展运算符。修改 info.city (嵌套属性) 时,原始对象也会变,因为它们共享同一个内存地址。
  2. 深拷贝:使用 JSON 序列化。修改任何属性,原始对象都不会变,因为它们完全独立。

Power by VitePress