内存管理与垃圾回收

What — 是什么

JavaScript 的内存管理是自动完成的——开发者不需要手动分配和释放内存,而是由引擎的垃圾回收器(Garbage Collector, GC)负责追踪和回收不再使用的内存。理解其内部机制是写出高性能、无泄漏前端应用的关键。

核心概念:

  • 内存生命周期:分配(Allocate)→ 使用(Use)→ 释放(Release)
  • 栈内存(Stack):存储原始值和引用地址,自动分配释放,大小固定
  • 堆内存(Heap):存储对象、数组等引用类型,由 GC 管理,大小动态
  • 垃圾回收器:自动识别并回收程序不再引用的堆内存

JS 内存生命周期三阶段:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   分配内存    │ ──→ │   使用内存    │ ──→ │   释放内存    │
│  Allocate    │     │    Use      │     │   Release   │
└─────────────┘     └─────────────┘     └─────────────┘
  let obj = {}        obj.name = 'x'      obj = null (由GC回收)
  自动分配             读写操作             自动/手动解除引用

栈内存 vs 堆内存:

维度栈内存(Stack)堆内存(Heap)
存储内容原始值(number/string/boolean/null/undefined/symbol/bigint)+ 引用地址对象、数组、函数等引用类型的实际数据
大小固定,编译期确定动态,运行时分配
分配/释放自动,随函数调用/返回由 GC 管理
访问速度快(CPU缓存友好)较慢(需间接寻址)
空间小(通常 1-8MB)大(可达数GB)

V8 引擎内存结构:

┌─────────────────────────────── V8 进程内存 ───────────────────────────────┐
│                                                                           │
│  ┌──── 新生代(Young Generation)────┐  ┌──── 老生代(Old Generation)───┐ │
│  │                                   │  │                               │ │
│  │  ┌──────────┐  ┌──────────┐      │  │  ┌─────────────────────────┐  │ │
│  │  │   From   │  │    To    │      │  │  │      老生代对象区        │  │ │
│  │  │ (活动空间) │  │ (空闲空间) │      │  │  │   (Mark-Sweep/Compact) │  │ │
│  │  └──────────┘  └──────────┘      │  │  └─────────────────────────┘  │ │
│  │                                   │  │                               │ │
│  │  大小:1~8MB(64位约16MB)         │  │  大小:数百MB~1.5GB+          │ │
│  │  GC:Scavenge 算法                │  │  GC:标记-清除 + 标记-整理     │ │
│  │  存活周期短的对象                  │  │  存活周期长的对象              │ │
│  └───────────────────────────────────┘  └───────────────────────────────┘ │
│                                                                           │
│  ┌─────────────────── 大对象区(Large Object Space)────────────────────┐ │
│  │  大小超过限制(约256KB)的对象直接分配在此,不会被移动                  │ │
│  └────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
│  ┌─────────────────── 代码区(Code Space)──────────────────────────────┐ │
│  │  JIT 编译生成的代码对象                                               │ │
│  └────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘

垃圾回收器的职责:

  1. 识别垃圾:判定哪些对象不再被程序引用(不可达)
  2. 回收内存:释放垃圾对象占用的堆内存空间
  3. 整理碎片:压缩内存,减少空间碎片,提升分配效率
  4. 控制停顿:尽量减少 GC 引起的 JavaScript 主线程暂停(Stop-The-World)

Why — 为什么

适用场景:

  • 所有 JavaScript/Web 前端开发——无一例外

内存泄漏的严重后果:

阶段表现用户体验
早期内存缓慢增长无明显感知
中期页面操作变慢,GC 频繁触发卡顿、掉帧
晚期内存耗尽,浏览器标签页崩溃白屏、数据丢失

理解 GC 的必要性:

  1. 写出高性能代码:减少临时对象创建 → 减少 GC 压力 → 减少卡顿
  2. 避免内存泄漏:SPA 应用长期运行,泄漏会不断累积
  3. 排查线上问题:能定位 Memory 面板中的 Retainers 链路
  4. 前端应用日益复杂:大型 SPA、WebGL、Web Worker、实时协作等场景对内存敏感度极高

对比其他语言:

维度JavaScript(自动GC)C/C++(手动管理)Rust(所有权系统)
内存释放GC 自动回收手动 malloc/free编译期所有权规则
泄漏风险引用未释放悬垂指针/忘记释放编译器防止大部分泄漏
运行时开销GC 暂停(Stop-The-World)无运行时开销无运行时开销
开发效率
控制粒度粗(无法精确控制GC时机)

How — 怎么用

快速上手

理解 JS 内存管理的核心要点:

  1. 对象不再被任何引用链可达时,GC 会自动回收它
  2. 意外保留引用 = 内存泄漏
  3. 减少垃圾产生 = 减少 GC 压力 = 更流畅的体验
  4. 用 DevTools 检测,用 WeakRef/WeakMap 预防

代码示例

1. 引用计数算法(Reference Counting)

原理: 每个对象维护一个引用计数器,被引用时 +1,引用断开时 -1,计数归零时回收。

// 引用计数原理演示
let obj = { name: 'Alice' }; // 引用计数 = 1
let ref = obj;                // 引用计数 = 2
obj = null;                   // 引用计数 = 1(obj 断开引用)
ref = null;                   // 引用计数 = 0 → 可回收

// ✅ 简单场景下工作正常
function createAndRelease() {
  let arr = [1, 2, 3]; // 引用计数 = 1
  return arr.length;    // arr 离开作用域,计数 = 0 → 回收
}

致命缺陷——循环引用:

// ❌ 循环引用导致引用计数永远不为0
function circularReference() {
  const a = {};
  const b = {};
  a.ref = b; // a → b, b的计数 = 2
  b.ref = a; // b → a, a的计数 = 2
  // 函数执行完毕,a 和 b 的局部变量引用断开
  // 但 a.ref → b, b.ref → a,互相引用,计数均为1
  // 永远无法回收!
}

// IE6/7 历史案例:DOM 与 JS 对象的循环引用
function ieLeak() {
  const element = document.getElementById('myDiv');
  element.myCustomProp = { dom: element }; // DOM → JS对象 → DOM
  // 即使 element 从 DOM 树移除,引用计数也不为0
  // IE6/7 的 DOM 对象使用引用计数,导致严重内存泄漏
}

// ✅ 手动断开循环引用(IE6/7 时代的修复方式)
function ieFix() {
  const element = document.getElementById('myDiv');
  element.myCustomProp = { dom: element };
  // 清理时手动断开
  element.myCustomProp = null;
  element = null;
}

注意:现代浏览器(IE8+)的 DOM 对象已改为标记-清除算法,不再有此问题。但理解循环引用仍是理解 GC 的基础。

2. 标记-清除算法(Mark-Sweep)

原理: 从”根对象”出发,遍历所有可达对象并标记,未被标记的对象即为垃圾,直接清除。

标记-清除过程:

1. 标记阶段(Mark):
   从根对象出发,沿引用链遍历,标记所有可达对象

   Root → A → B → D ✓(可达,标记)
   Root → C ✓(可达,标记)
   E ✗(不可达,未标记)
   F → E ✗(F 不可达,E 也不可达)

2. 清除阶段(Sweep):
   遍历堆内存,回收未标记的对象

   A ✓ B ✓ C ✓ D ✓ → 保留
   E ✗ F ✗          → 回收

3. 标记-整理(Mark-Compact):
   将存活对象向一端移动,消除内存碎片

   Before: [A][ ][B][ ][ ][C][D][ ]
   After:  [A][B][C][D][ ][ ][ ][ ]
// 根对象(GC Roots)包括:
// 1. 全局对象(window / globalThis)
// 2. 当前执行上下文的局部变量和参数
// 3. 被引用的闭包变量
// 4. 活跃的 Web API 引用(DOM 节点、定时器回调等)

// 标记-清除解决循环引用
function markAndSweepDemo() {
  const a = { name: 'A' };
  const b = { name: 'B' };
  a.ref = b;
  b.ref = a; // 循环引用

  // 函数结束后,a 和 b 都不是从根可达的
  // 即使互相引用,标记阶段从根出发遍历不到它们
  // → 都被标记为不可达 → 回收 ✅
}

// 可达性分析示例
let globalRef = null;

function reachabilityDemo() {
  const obj = { data: new ArrayBuffer(1024 * 1024) }; // 1MB

  globalRef = obj; // obj 被 globalRef 引用 → 从根可达

  // 即使函数结束,obj 也不会被回收
  // 因为 globalThis.globalRef → obj 是可达路径
}

reachabilityDemo();
globalRef = null; // 断开根到 obj 的路径 → 下次 GC 时回收

3. V8 新生代 GC — Scavenge 算法

// Scavenge 算法使用 Cheney 半空间复制策略
// 新生代内存被分为两个等大的半空间:From(活动空间)和 To(空闲空间)

/*
Scavenge 过程:

1. 新对象分配在 From 空间
   ┌─ From 空间 ──────────────┐   ┌─ To 空间 ──┐
   │ [A][B][C][D][E][F][ ][ ] │   │            │
   └──────────────────────────┘   └────────────┘

2. GC 触发:从根遍历,标记 From 中的存活对象
   存活:A, B, D, F
   垃圾:C, E

3. 复制存活对象到 To 空间(连续排列,无碎片)
   ┌─ From 空间 ──────────────┐   ┌─ To 空间 ──────┐
   │ [ ][ ][ ][ ][ ][ ][ ][ ] │   │ [A][B][D][F][ ] │
   └──────────────────────────┘   └────────────────┘

4. 交换 From 和 To 角色
   ┌─ From 空间 ──────┐   ┌─ To 空间 ──────────────┐
   │ [A][B][D][F][ ]  │   │ [ ][ ][ ][ ][ ][ ][ ][ ]│
   └──────────────────┘   └──────────────────────────┘
*/

// 对象晋升条件:
// 1. 经历过一次 Scavenge 回收(存活过一次 GC)
// 2. To 空间使用率超过 25%(避免新对象分配时空间不足)

// 示例:短生命周期对象 → 新生代
function shortLived() {
  const temp = { x: 1, y: 2 }; // 分配在新生代
  return temp.x + temp.y;       // temp 很快不可达,下次 Scavenge 回收
}

// 示例:长生命周期对象 → 晋升到老生代
const longLived = []; // 每次GC都存活 → 晋升到老生代
function addToLongLived(item) {
  longLived.push(item);
}

// Scavenge 的特点:
// - 速度极快:只处理少量存活对象(新生代存活率低,约 1-10%)
// - 无碎片:复制后对象连续排列
// - 空间换时间:只能使用一半新生代内存

4. V8 老生代 GC — 标记-清除 + 标记-整理

// 老生代GC采用三种优化策略协同工作:

// ===== 1. 标记-清除(Mark-Sweep)=====
// 快速回收,但会产生内存碎片
// 适用于大多数场景

// ===== 2. 标记-整理(Mark-Compact)=====
// 移动存活对象,消除碎片
// 在碎片率高时触发,速度较慢

// ===== 3. 增量标记(Incremental Marking)=====
// 将长停顿拆分为多个短步骤,穿插在JS执行之间

/*
传统标记:一次性完成,长停顿
JS执行: ████
GC标记:          ████████████████  ← 长停顿!用户可感知卡顿
JS执行:                         ████

增量标记:拆分为多个步骤
JS执行: ████
GC标记:      ██ ██ ██ ██ ██ ██  ← 多个短停顿
JS执行:   ██  ██  ██  ██  ██  ████
*/

// ===== 4. 并发回收(Concurrent)=====
// GC 的部分工作在辅助线程执行,不阻塞主线程
// V8 的 Orinoco 项目逐步引入:
// - 并发标记(Concurrent Marking)
// - 并发清除(Concurrent Sweeping)
// - 并发整理(Concurrent Compaction)
// - 主线程只做少量同步工作

// V8 GC 演进:
/*
V8 版本    | 策略
-----------|---------------------------
v5.6 之前  | 全量 Stop-The-World
v5.6+     | 增量标记(Incremental Marking)
v6.2+     | 并发标记(Concurrent Marking)
v7.0+     | 并发标记 + 并发清除
v8.0+     | 并发标记 + 并发清除 + 并发整理
*/

// 实际影响示例:游戏循环中的 GC 停顿
let lastTime = 0;
function gameLoop(timestamp) {
  const delta = timestamp - lastTime;
  lastTime = timestamp;

  // ❌ 每帧创建新对象 → 触发频繁GC → 掉帧
  function badLoop() {
    const particles = [];
    for (let i = 0; i < 1000; i++) {
      particles.push({ x: Math.random(), y: Math.random() });
    }
    // particles 在每帧结束后成为垃圾
  }

  // ✅ 复用对象 → 减少GC压力
  const particlePool = Array.from({ length: 1000 }, () => ({ x: 0, y: 0 }));
  function goodLoop() {
    for (let i = 0; i < 1000; i++) {
      const p = particlePool[i];
      p.x = Math.random();
      p.y = Math.random();
    }
  }

  requestAnimationFrame(gameLoop);
}

5. 内存泄漏常见模式

5.1 意外的全局变量
// ❌ 未声明变量 → 挂载到全局 → 永远不会被回收
function leak() {
  bar = 'This is a global variable'; // 忘记 let/const/var
  this.baz = 'Also global in non-strict mode'; // this → window
}

// ✅ 使用严格模式 + 显式声明
'use strict';
function noLeak() {
  const bar = 'local variable';
  // 严格模式下 this 为 undefined,不会意外挂载到全局
}
5.2 闭包引用
// ❌ 闭包持有不必要的巨大数据
function createLeakyClosure() {
  const hugeData = new Array(1000000).fill('*'); // 1M元素的大数组

  return function getResult() {
    // 只用了 hugeData 的长度,但整个 hugeData 都被闭包持有
    return hugeData.length;
  };
}

const leaky = createLeakyClosure(); // hugeData 无法被回收!

// ✅ 只保留需要的数据
function createFixedClosure() {
  const hugeData = new Array(1000000).fill('*');
  const length = hugeData.length; // 提前提取需要的值

  return function getResult() {
    return length; // 闭包只持有 length(一个数字)
  };
  // hugeData 在函数返回后即可被回收
}
5.3 被遗忘的 DOM 引用
// ❌ JS 中仍持有已移除 DOM 的引用
const elements = {
  button: document.getElementById('myButton'),
  image: document.getElementById('myImage')
};

function removeButton() {
  document.body.removeChild(elements.button);
  // DOM 树中已移除,但 elements.button 仍引用该 DOM 节点
  // DOM 节点及其内部引用树都无法被 GC 回收
}

// ✅ 移除 DOM 后同时清除 JS 引用
function removeButtonFixed() {
  document.body.removeChild(elements.button);
  elements.button = null; // 断开 JS 引用
}
5.4 未清理的定时器
// ❌ 定时器回调持有外部引用,永远不会被回收
function startLeakyTimer() {
  const hugeData = new Array(1000000).fill('*');

  setInterval(() => {
    // 回调闭包持有 hugeData 的引用
    console.log(hugeData.length);
  }, 1000);
}

// ✅ 不需要时清除定时器
function startCleanTimer() {
  const hugeData = new Array(1000000).fill('*');
  let count = 0;

  const timerId = setInterval(() => {
    count++;
    console.log(hugeData.length);
    if (count >= 10) {
      clearInterval(timerId); // 执行完毕后清除
    }
  }, 1000);
}
5.5 未移除的事件监听器
// ❌ 匿名事件监听器无法移除
function attachLeakyListener() {
  const hugeData = loadHugeData();

  window.addEventListener('resize', () => {
    // 匿名函数 → 无法 removeEventListener
    processData(hugeData);
  });
}

// ✅ 保存引用,适时移除
class ResizeHandler {
  constructor() {
    this.hugeData = loadHugeData();
    this.handler = this.onResize.bind(this);
    window.addEventListener('resize', this.handler);
  }

  onResize() {
    processData(this.hugeData);
  }

  destroy() {
    window.removeEventListener('resize', this.handler);
    this.hugeData = null;
  }
}
5.6 Map/Set 缓存膨胀
// ❌ 无限增长的缓存
const cache = new Map();

function getData(key) {
  if (!cache.has(key)) {
    cache.set(key, expensiveComputation(key));
  }
  return cache.get(key);
}
// cache 永远只增不减 → 内存持续增长

// ✅ 使用 WeakMap 自动回收(键必须是对象)
const weakCache = new WeakMap();

function getDataWeak(objKey) {
  if (!weakCache.has(objKey)) {
    weakCache.set(objKey, expensiveComputation(objKey));
  }
  return weakCache.get(objKey);
}
// 当 objKey 被回收时,对应的缓存条目也会自动消失

// ✅ LRU 缓存限制大小
class LRUCache {
  constructor(maxSize = 100) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    // 移到末尾(最近使用)
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);
    // 超出容量时删除最旧的
    if (this.cache.size > this.maxSize) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
  }
}

6. Chrome DevTools 检测内存泄漏

// ===== Memory 面板 — Heap Snapshot =====
// 步骤:
// 1. 打开 DevTools → Memory → 堆快照(Heap Snapshot)
// 2. 拍摄快照 A(操作前)
// 3. 执行操作(如打开/关闭弹窗、路由切换)
// 4. 拍摄快照 B
// 5. 选择快照 B,切换视图为 "Comparison"(比较)
// 6. 按 # Delta(增量)排序,关注新增的对象

// ===== Memory 面板 — Allocation Timeline =====
// 实时观察内存分配:
// 1. 选择 "Allocation instrumentation on timeline"
// 2. 开始录制
// 3. 操作页面
// 4. 停止录制
// 5. 蓝色柱子 = 分配后仍存活  灰色柱子 = 已被回收
// 6. 持续增长的蓝色 = 可能泄漏

// ===== Performance 面板 — 内存趋势 =====
// 1. 勾选 Memory 复选框
// 2. 录制操作过程
// 3. 观察 JS Heap 内存曲线
// 4. 正常:锯齿形(上升→GC→下降→上升→GC)
// 5. 泄漏:阶梯式上升(每次操作后基线抬高)

// 代码辅助:手动检查内存
function checkMemory() {
  if (performance.memory) {
    console.log('已使用堆大小:', (performance.memory.usedJSHeapSize / 1048576).toFixed(2), 'MB');
    console.log('堆大小限制:', (performance.memory.jsHeapSizeLimit / 1048576).toFixed(2), 'MB');
  }
}

// 使用 snapshot 对比检测泄漏
function detectLeak() {
  // 强制 GC(仅 DevTools 开启时可用)
  if (typeof gc === 'function') gc();

  const snapshot1 = performance.memory.usedJSHeapSize;

  // 执行可疑操作
  for (let i = 0; i < 100; i++) {
    suspiciousOperation();
  }

  if (typeof gc === 'function') gc();
  const snapshot2 = performance.memory.usedJSHeapSize;

  const leaked = (snapshot2 - snapshot1) / 1048576;
  console.log(`内存变化: ${leaked.toFixed(2)} MB`);
  if (leaked > 1) {
    console.warn('可能存在内存泄漏!');
  }
}

7. WeakRef 与 FinalizationRegistry

// ===== WeakRef:弱引用,不阻止 GC 回收 =====
let target = { name: 'Important Data', payload: new ArrayBuffer(1024 * 1024) };
const weakRef = new WeakRef(target);

// 读取弱引用的值
console.log(weakRef.deref()); // { name: 'Important Data', payload: ArrayBuffer }

// 当 target 没有其他强引用时,GC 可以回收它
target = null; // 断开强引用

// 之后某次 GC 后
console.log(weakRef.deref()); // undefined(已被回收)

// 实际应用:缓存
class WeakCache {
  #cache = new Map();

  get(key) {
    const ref = this.#cache.get(key);
    if (!ref) return undefined;

    const value = ref.deref();
    if (!value) {
      this.#cache.delete(key); // 已被GC回收,清理条目
      return undefined;
    }
    return value;
  }

  set(key, value) {
    this.#cache.set(key, new WeakRef(value));
  }
}

// ===== FinalizationRegistry:对象被GC回收时的回调 =====
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`对象被回收了,关联值: ${heldValue}`);
  // 可用于清理关联资源(如关闭文件句柄、取消网络请求等)
});

let obj = { data: 'some data' };
registry.register(obj, 'my-obj-id'); // 注册,关联一个 heldValue

obj = null; // 某次GC后触发回调,打印 "对象被回收了,关联值: my-obj-id"

// 实际应用:追踪资源清理
class ResourceManager {
  #registry = new FinalizationRegistry((id) => {
    this.cleanup(id);
    console.log(`资源 ${id} 已自动清理`);
  });

  #resources = new Map();

  create(id, resource) {
    this.#resources.set(id, resource);
    this.#registry.register(resource, id);
  }

  cleanup(id) {
    const resource = this.#resources.get(id);
    if (resource) {
      // 清理关联的副作用
      resource.close?.();
      this.#resources.delete(id);
    }
  }
}

// ⚠️ 注意事项:
// 1. FinalizationRegistry 回调时机不确定,不能依赖它做关键逻辑
// 2. WeakRef.deref() 返回值可能在下一行就变了(多线程场景)
// 3. 优先使用 WeakMap/WeakSet,只在需要"值是原始类型"时用 WeakRef
// 4. 不要用 WeakRef 做核心业务逻辑,只用于可选的优化/缓存

8. 手动内存管理技巧

// ===== 技巧1:及时解除引用 =====
function processLargeData() {
  let data = loadHugeDataset(); // 假设占 100MB

  const result = data.filter(item => item.active);

  // 处理完后立即释放
  data = null; // 不等函数结束,提前让 GC 回收

  return result;
}

// ===== 技巧2:对象池(Object Pool)=====
class ObjectPool {
  #pool = [];
  #factory;
  #reset;

  constructor(factory, reset, initialSize = 10) {
    this.#factory = factory;
    this.#reset = reset;
    // 预分配
    for (let i = 0; i < initialSize; i++) {
      this.#pool.push(factory());
    }
  }

  acquire() {
    return this.#pool.length > 0
      ? this.#pool.pop()
      : this.#factory();
  }

  release(obj) {
    this.#reset(obj);
    this.#pool.push(obj);
  }

  get size() {
    return this.#pool.length;
  }
}

// 使用对象池管理粒子系统
const particlePool = new ObjectPool(
  () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, active: false }),
  (p) => { p.x = 0; p.y = 0; p.vx = 0; p.vy = 0; p.life = 0; p.active = false; },
  1000
);

function emitParticle(x, y) {
  const p = particlePool.acquire();
  p.x = x;
  p.y = y;
  p.vx = (Math.random() - 0.5) * 10;
  p.vy = (Math.random() - 0.5) * 10;
  p.life = 60;
  p.active = true;
  return p;
}

function recycleParticle(p) {
  p.active = false;
  particlePool.release(p);
}

// ===== 技巧3:数组复用 =====
// ❌ 每帧创建新数组
function badFrameProcessing(entities) {
  const active = entities.filter(e => e.active); // 每帧新数组
  active.forEach(e => e.update());
}

// ✅ 复用数组
const activeBuffer = []; // 复用的数组

function goodFrameProcessing(entities) {
  activeBuffer.length = 0; // 清空但保留内存
  for (let i = 0; i < entities.length; i++) {
    if (entities[i].active) activeBuffer.push(entities[i]);
  }
  for (let i = 0; i < activeBuffer.length; i++) {
    activeBuffer[i].update();
  }
}

// ===== 技巧4:Transferable Objects(零拷贝转移)=====
// 主线程与 Worker 之间传递 ArrayBuffer 时避免复制
const largeBuffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB

// ❌ 结构化克隆:复制 100MB 数据
// worker.postMessage({ buffer: largeBuffer });

// ✅ Transferable:零拷贝,所有权转移
worker.postMessage({ buffer: largeBuffer }, [largeBuffer]);
// postMessage 执行后,主线程的 largeBuffer 变为 0 字节
// Worker 端获得完整的 100MB 数据,无需复制

9. 性能优化:避免频繁 GC

// ===== 减少临时对象 =====

// ❌ 字符串拼接产生大量临时字符串
function badStringBuild(items) {
  let html = '';
  for (const item of items) {
    html += `<div>${item.name}</div>`; // 每次创建新字符串
  }
  return html;
}

// ✅ 使用数组 join(一次拼接)
function goodStringBuild(items) {
  const parts = [];
  for (const item of items) {
    parts.push(`<div>${item.name}</div>`);
  }
  return parts.join('');
}

// ✅ 更优:模板字面量
function betterStringBuild(items) {
  return items.map(item => `<div>${item.name}</div>`).join('');
}

// ===== 结构化克隆 vs JSON =====
const data = { a: 1, b: [2, 3], c: { d: 4 } };

// JSON 序列化/反序列化
const jsonCopy = JSON.parse(JSON.stringify(data));
// 缺点:无法处理 undefined、函数、Symbol、循环引用、Date、RegExp 等
// 每次创建新的字符串中间对象 → 额外GC压力

// structuredClone(原生深拷贝)
const structCopy = structuredClone(data);
// 优点:支持更多类型、无中间字符串对象
// 支持:ArrayBuffer、Date、RegExp、Map、Set、Blob 等
// 不支持:函数、Error、DOM 节点

// ===== 避免在热路径中创建对象 =====
// ❌ 热路径中创建对象
function badHotPath(positions) {
  return positions.map(p => ({ x: p.x * 2, y: p.y * 2 }));
  // 每次调用创建 N 个新对象
}

// ✅ 就地修改
function goodHotPath(positions) {
  for (let i = 0; i < positions.length; i++) {
    positions[i].x *= 2;
    positions[i].y *= 2;
  }
  return positions; // 复用原数组
}

// ===== 使用 TypedArray 处理数值数据 =====
// ❌ 普通数组存储数值 → 每个元素是一个 Number 对象
const badCoords = [];
for (let i = 0; i < 10000; i++) {
  badCoords.push(Math.random(), Math.random());
}

// ✅ Float32Array → 连续内存,无装箱开销
const goodCoords = new Float32Array(20000);
for (let i = 0; i < 20000; i++) {
  goodCoords[i] = Math.random();
}
// 内存占用:Float32Array ~80KB vs 普通数组 ~320KB+
// GC 压力:Float32Array 是单一对象 vs 普通数组有 20000 个 Number 对象

常见问题与踩坑

Q1:闭包导致的泄漏如何检测?

// Chrome DevTools 中检测闭包泄漏:
// 1. Heap Snapshot → 搜索 "closure"
// 2. 查看 Retainers(保持者)路径
// 3. 关注 "(closure)" 类型的对象

// 典型闭包泄漏模式
function setupHandler() {
  const hugeData = new Array(1000000).fill('x');

  // 内部函数形成闭包,持有 hugeData
  const handler = function onClick() {
    console.log(hugeData.length); // 即使只用 .length
  };

  document.getElementById('btn').addEventListener('click', handler);

  // 问题:handler 闭包持有 hugeData 的完整引用
  // 解决:只提取需要的值
  const dataLength = hugeData.length;
  const fixedHandler = function onClick() {
    console.log(dataLength); // 闭包只持有 dataLength
  };
}

Q2:React/Vue 组件卸载后的泄漏

// React 组件泄漏常见场景
function LeakComponent({ userId }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    // ❌ 未取消的异步请求
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(result => {
        // 组件可能已卸载,但仍然 setState
        setData(result); // React 18+ 不再警告,但仍是内存泄漏
      });

    // ❌ 未清理的定时器
    const timer = setInterval(() => {
      refreshData();
    }, 5000);

    // ✅ 正确做法:清理所有副作用
    return () => {
      cancelled = true;
      clearInterval(timer);
    };
  }, [userId]);

  // ✅ 使用 AbortController 取消请求
  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(result => setData(result))
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controller.abort();
  }, [userId]);
}
// Vue 组件泄漏常见场景
export default {
  data() {
    return {
      resizeObserver: null,
      eventSource: null,
    };
  },

  mounted() {
    // ❌ 未在 unmounted 中清理
    this.resizeObserver = new ResizeObserver(() => {
      this.handleResize();
    });
    this.resizeObserver.observe(this.$el);

    window.addEventListener('resize', this.onResize);
  },

  // ✅ 正确做法:在 unmounted 中清理所有
  unmounted() {
    this.resizeObserver?.disconnect();
    this.resizeObserver = null;

    window.removeEventListener('resize', this.onResize);

    this.eventSource?.close();
    this.eventSource = null;
  },

  // Vue 3 组合式 API 更优雅
  // setup() {
  //   onMounted(() => {
  //     const observer = new ResizeObserver(handleResize);
  //     observer.observe(el);
  //     onUnmounted(() => observer.disconnect()); // 自动清理
  //   });
  // }
};

Q3:console.log 持有引用

// ❌ console.log 会持有对象的引用!
function debugLeak() {
  const hugeData = { items: new Array(1000000).fill('*') };
  console.log('Debug data:', hugeData); // DevTools 开启时,hugeData 无法被回收
  // 即使 hugeData 离开作用域,console.log 仍持有引用
}

// ✅ 生产环境移除 console
// 构建工具配置(如 Webpack/Vite)
// Terser: drop_console: true

// ✅ 只打印基本信息
console.log('Debug data length:', hugeData.length);
console.log('Debug data keys:', Object.keys(hugeData));

// ✅ 使用条件日志
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
  console.log('Debug data:', hugeData);
}

// ✅ 使用 console.table 对比 console.dir
// console.table 只打印表格摘要,不保留完整引用
console.table(hugeData.items.slice(0, 10)); // 只打印前10条

最佳实践

  1. 优先使用 WeakMap/WeakSet 存储与对象关联的元数据,避免阻止 GC
  2. 组件卸载时清理所有副作用:定时器、事件监听器、Observer、WebSocket 等
  3. 热路径避免创建临时对象:使用对象池、TypedArray、就地修改
  4. 用 LRU 缓存替代无限增长 Map:控制缓存上限
  5. 开启严格模式:防止意外全局变量
  6. 生产环境移除 console:避免 DevTools 持有对象引用
  7. 使用 structuredClone 替代 JSON.parse(JSON.stringify()):更快、支持更多类型
  8. 定期做 Heap Snapshot 对比:在 CI/CD 中加入内存泄漏检测
  9. Transferable Objects 传递大数据给 Worker:零拷贝,避免 GC 压力
  10. 理解框架生命周期:React useEffect 清理、Vue onUnmounted、Angular ngOnDestroy

面试题(8题)

1. V8 引擎的垃圾回收算法是什么?

答: V8 采用分代回收策略,结合多种算法:

  • 新生代:Scavenge 算法(Cheney 半空间复制),将内存分为 From/To 两个半空间,GC 时将 From 中的存活对象复制到 To,然后交换角色。速度快,适合存活率低的新生代对象。
  • 老生代:标记-清除(Mark-Sweep)+ 标记-整理(Mark-Compact),先标记可达对象,再清除不可达对象。碎片率高时用标记-整理压缩内存。
  • 优化策略:增量标记(Incremental Marking)将长停顿拆为短步骤;并发回收(Concurrent)在辅助线程执行 GC,减少主线程停顿。

2. V8 新生代和老生代的区别是什么?

答:

维度新生代老生代
大小1~16MB数百MB~1.5GB+
GC 算法Scavenge(复制算法)标记-清除 + 标记-整理
GC 频率高(分配频繁)
存活率低(1-10%)
停顿时间极短(ms级)较长(需增量/并发优化)
存储对象短生命周期、刚创建的对象长生命周期、晋升的对象
碎片问题无(复制后连续)有(需标记-整理)

对象晋升条件:经历一次 Scavenge 仍存活,或 To 空间使用率超过 25%。

3. 标记-清除算法与引用计数算法的区别?为什么现代引擎选择标记-清除?

答:

维度引用计数标记-清除
原理引用数归零即回收从根遍历,不可达即回收
循环引用无法处理可以处理
回收时机即时(引用归零立刻回收)周期性(GC 触发时批量回收)
性能开销每次引用变化都要更新计数只在 GC 时遍历
内存碎片无(即时回收)有(需标记-整理补充)

现代引擎选择标记-清除的核心原因:能正确处理循环引用。引用计数的致命缺陷是循环引用导致内存永远无法回收,而标记-清除从根对象做可达性分析,即使对象互相引用,只要从根不可达就能正确回收。

4. 常见的内存泄漏场景有哪些?如何避免?

答:

  1. 意外全局变量:未声明变量 → 挂载到 window。避免:严格模式 + let/const
  2. 闭包引用:闭包持有不需要的大对象。避免:只提取需要的值,不持有整个对象
  3. 被遗忘的 DOM 引用:JS 持有已移除的 DOM 节点。避免:移除 DOM 时同步置 null
  4. 未清除的定时器:setInterval/setTimeout 持有闭包引用。避免:组件卸载时 clearInterval
  5. 未移除的事件监听器:addEventListener 未对应 removeEventListener。避免:保存引用,及时移除
  6. Map/Set 缓存膨胀:缓存只增不减。避免:使用 WeakMap/WeakSet 或 LRU 缓存
  7. console.log:DevTools 开启时持有对象引用。避免:生产环境移除 console

5. 如何检测和定位前端内存泄漏?

答:

Chrome DevTools 三步法:

  1. Heap Snapshot 对比:拍快照A → 操作 → 拍快照B → Comparison 视图看 Delta(增量),关注只增不减的对象
  2. Allocation Timeline:录制内存分配,蓝色柱=存活、灰色=已回收,持续增长的蓝色=泄漏
  3. Performance 面板:观察 JS Heap 曲线,正常为锯齿形,泄漏为阶梯式上升

定位泄漏对象:在 Heap Snapshot 中查看 Retainers(保持者)链路,找到从 GC Root 到泄漏对象的最短路径,确定是谁持有引用导致无法回收。

自动化检测:使用 Puppeteer + Chrome DevTools Protocol 在 CI 中自动检测内存增长。

6. WeakRef 和 WeakMap 的用途是什么?什么场景下使用?

答:

  • WeakMap:键必须是对象,且是弱引用。键对象被 GC 回收时,对应条目自动消失。适用场景:为对象附加私有数据(如 DOM 节点关联元数据)、缓存计算结果(键是对象)
  • WeakSet:值必须是对象,弱引用。适用场景:标记对象(如”已访问”标记)、跟踪对象状态
  • WeakRef:对任意对象创建弱引用,deref() 获取原对象(可能返回 undefined)。适用场景:可被回收的缓存、FinalizationRegistry 配合使用
  • FinalizationRegistry:注册对象,当对象被 GC 回收时执行回调。适用场景:资源清理通知(不保证时机,不能做关键逻辑)

核心原则:优先使用 WeakMap/WeakSet,WeakRef + FinalizationRegistry 是最后手段。不要用弱引用做核心业务逻辑,只用于可丢弃的缓存/优化。

7. 为什么 JavaScript 不提供手动 GC 的能力?

答:

  1. 安全性:手动 GC 可能误回收仍在使用的对象,导致运行时崩溃或数据损坏
  2. 确定性:JS 是单线程的,手动触发 GC 会强制 Stop-The-World 停顿,影响用户体验
  3. 引擎优化:V8 的 GC 是高度优化的,分代策略、增量标记、并发回收等需要引擎内部的全局信息来决策最优时机,手动触发反而可能打断引擎的优化调度
  4. 语言设计哲学:JS 定位为高级语言,自动内存管理是核心特性,手动 GC 违背设计初衷
  5. 开发者负担:手动管理内存是 C/C++ 的主要 bug 来源之一,JS 选择消除这类错误

补充:globalThis.gc() 仅在启动 Node.js 时加 --expose-gc 标志才可用,是调试工具而非生产 API。

8. 增量标记(Incremental Marking)为什么必要?它的工作原理是什么?

答:

必要性:老生代对象多,全量标记可能需要 100ms+,导致页面掉帧(1帧=16.6ms)。用户会感知明显卡顿。增量标记将长停顿拆分为多个短步骤(每个 1-5ms),穿插在 JS 执行之间,使总停顿时间分散,避免单次长时间阻塞。

工作原理

  1. V8 使用三色标记法:白色(未访问)、灰色(已访问但子引用未处理)、黑色(已访问且子引用已处理)
  2. GC 启动时所有对象为白色,从根出发标记为灰色
  3. 每次增量步骤:从灰色集合取一个对象,将其引用的对象标记为灰色,自身标记为黑色
  4. 增量步骤之间允许 JS 执行
  5. JS 执行期间可能修改引用关系 → 使用**写屏障(Write Barrier)**记录变更,确保不遗漏
  6. 所有灰色对象处理完毕后,白色对象即为不可达,执行清除

三色不变式:增量标记保证——不存在黑色对象指向白色对象(强三色不变式),或所有黑色对象指向的白色对象都在灰色集合中(弱三色不变式)。写屏障维护此不变式,确保标记正确性。


相关链接: