内存管理与垃圾回收

张玥 2026年9月17日 阅读时间 35分钟
JavaScript 内存管理 垃圾回收 性能优化 V8引擎
JavaScript编程性能优化之内存管理与垃圾回收

很多前端开发者写了多年 JavaScript,却从未真正理解过内存。我们享受着"自动垃圾回收"带来的便利,却也在不知不觉中写出大量内存泄漏、频繁 GC 卡顿、页面越用越慢的代码。事实上,内存管理与垃圾回收是 JavaScript 性能优化中最底层、也最容易被忽视的一环。本文将从内存模型讲起,深入 V8 引擎的垃圾回收机制,系统梳理常见内存泄漏场景、检测工具链、WeakMap/WeakRef 等现代 API,以及一整套可落地的高性能编码实践。35分钟沉浸式学习,带你从"会用 JS"进阶到"懂 JS"。

为什么前端必须关心内存

在很长一段时间里,"内存管理"被当作后端和服务端的专属话题。但今天的 Web 应用早已不是简单的页面展示——单页应用、长时间驻留的后台系统、WebGL 渲染、大表格与虚拟列表、音视频处理,任何一个环节的内存失控,都会直接表现为页面卡顿、崩溃或设备发烫。

// 一个典型的内存失控场景
// 每次切换路由都往全局数组里塞数据
const globalCache = [];

function loadPageData(page) {
  const data = new Array(100000).fill({
    page,
    payload: 'x'.repeat(1024)
  });
  // 只增不减 —— 内存永远不会被回收
  globalCache.push(data);
  return data;
}

// 用户切了 100 次路由之后……
// Heap Size: 10GB → 页面崩溃
表现一:卡顿
GC 触发频率升高,主线程被"Stop-The-World"暂停,帧率掉到 30fps 以下
表现二:泄漏
内存只增不减,长时间使用后占用持续攀升
表现三:崩溃
超出浏览器堆内存上限,触发 OOM 白屏或标签页被系统回收

JavaScript 内存模型:栈与堆

理解内存管理的第一步,是搞清楚数据到底存在哪里。JavaScript 中的内存大致分为两块:栈内存(Stack)与堆内存(Heap),它们的分工截然不同。

// 原始值 → 栈内存(值本身存在栈中)
let a = 1;
let b = a; // 复制值
b = 2;
console.log(a); // 1 —— 互不影响

// 引用值 → 堆内存(栈中只存指针)
let obj1 = { name: '张玥' };
let obj2 = obj1; // 复制引用(指针)
obj2.name = '李雷';
console.log(obj1.name); // '李雷' —— 指向同一块堆内存

// 函数执行上下文 → 调用栈
function foo() {
  const local = { big: new Array(1e6) };
  return local.big.length;
}
// foo() 执行结束后,栈帧弹出
// local 失去引用,堆中的大数组等待被 GC 回收
栈 Stack
· 原始值(number / string / boolean / null / undefined / symbol / bigint)
· 变量名与对象的引用指针
· 函数调用栈帧
· 空间小(通常 1MB 左右)
· 分配与回收极快(移动栈指针)
堆 Heap
· 对象、数组、函数、闭包
· 大小不固定,动态分配
· 空间大(可达数 GB)
· 分配较慢,需要 GC 介入回收
· 内存碎片问题突出

栈是"自动"的,堆才是垃圾回收的主战场

内存生命周期:分配 → 使用 → 释放

无论哪种编程语言,内存的生命周期都遵循同一套流程。JavaScript 的特别之处在于第三步——释放由垃圾回收器自动完成,开发者无法(也不应该)手动调用 free()。

/* 阶段一:分配内存 */
const num = 42; // 栈分配
const str = 'hello'; // 栈(短字符串)
const obj = { a: 1 }; // 堆分配
const arr = [1, 2, 3]; // 堆分配

/* 阶段二:使用内存(读 / 写) */
obj.a = 100;
arr.push(4);

/* 阶段三:释放内存 */
// 开发者无法手动释放,只能"解除引用"
// 让对象变得"不可达",交给 GC 处理
let temp = { big: new Array(1e7) };
temp = null; // 解除引用,等待回收
①
分配
Allocate
②
使用
Use
③
释放
Release
开发者负责 ①② · GC 负责 ③

"自动"不等于"免费",写代码的方式决定了 GC 的负担

垃圾回收算法(一):引用计数

最早的垃圾回收思路非常直观:给每个对象维护一个"被引用次数"的计数器,计数归零就回收。这种算法实现简单、回收及时,但存在一个致命缺陷——循环引用。

// 引用计数基本逻辑(伪代码)
let obj = {}; // obj 引用计数 = 1
let ref = obj; // 计数 = 2
ref = null; // 计数 = 1
obj = null; // 计数 = 0 → 立即回收 ✅

// 致命缺陷:循环引用
function createCycle() {
  const a = {};
  const b = {};
  a.b = b; // b 的计数 +1
  b.a = a; // a 的计数 +1
  return 'done';
}
createCycle();
// 函数执行完毕,a 和 b 已无法从外部访问
// 但两者互相引用,计数永远 ≥ 1
// → 内存永远无法回收 ❌

// 老版本 IE 的经典泄漏:DOM + JS 循环引用
// element.onclick = function() { ... element ... }
A
→ 引用 ← 引用
B
两者都不可达,但计数均不为 0 → 内存泄漏

优点:回收即时、实现简单
缺点:无法处理循环引用

垃圾回收算法(二):标记清除与标记整理

现代 JavaScript 引擎普遍采用可达性(Reachability)作为判断标准,而非引用计数。核心算法是 Mark-Sweep(标记清除)与 Mark-Compact(标记整理)。

/* 可达性分析的核心思想 */
// 从一组"根对象"(GC Roots)出发
// 能顺着引用链走到的对象 = 存活
// 走不到的 = 垃圾,可以回收

// GC Roots 包括:
// 1. 全局对象 window / globalThis
// 2. 当前调用栈上的局部变量
// 3. 闭包捕获的变量
// 4. DOM 树中的引用

/* 标记清除 Mark-Sweep */
// ① 从根遍历,标记所有可达对象
// ② 遍历堆,回收未被标记的对象
// 缺点:产生大量内存碎片

/* 标记整理 Mark-Compact */
// ① 标记可达对象
// ② 将存活对象向一端移动
// ③ 直接清理掉边界外的内存
// 优点:无碎片,但移动成本高
标记阶段
根
✓
×
✓
×
清除阶段
整理阶段

标记清除解决循环引用,标记整理解决内存碎片

V8 引擎的分代垃圾回收

V8 并没有对所有对象"一视同仁"。基于分代假说(大部分对象生命周期极短),V8 把堆划分为新生代与老生代,分别采用不同的回收策略,从而大幅降低 GC 成本。

/* 新生代(Young Generation) */
// 容量小(约 1~16MB),分为 From / To 两个半区
// 算法:Scavenge(Cheney 复制算法)
// 1. 从 From 空间标记存活对象
// 2. 复制到 To 空间(并整理,无碎片)
// 3. 清空 From,From / To 角色互换

/* 对象晋升(Promotion) */
// 条件一:经历过一次 Scavenge 仍存活
// 条件二:To 空间使用超过 25%
// → 被移动到老生代

/* 老生代(Old Generation) */
// 容量大,存放生命周期长的对象
// 算法:Mark-Sweep + Mark-Compact
// 优化:增量标记 / 并发标记 / 惰性清理

/* 为什么要"增量"? */
// 全量标记会长时间阻塞主线程
// 拆成许多小步,穿插在 JS 执行间隙
// 把一次 100ms 的停顿拆成 100 次 1ms
新生代 · Scavenge
From 空间
→
To 空间
复制存活对象 · 空间换时间 · 无碎片
老生代 · Mark-Compact
标记 → 整理 → 清理 · 增量 / 并发执行

分代 + 并发,让 GC 停顿从百毫秒降到毫秒级

六大常见内存泄漏场景

内存泄漏的本质是:对象已经不再需要,却依然可以从 GC Roots 可达。下面是实际项目中最常踩的六个坑。

/* ① 意外的全局变量 */
function leak() {
  name = '张玥'; // 漏写 let/const → window.name
  this.data = new Array(1e6); // this 指向全局
}

/* ② 未清理的定时器 / 回调 */
const timer = setInterval(() => {
  doSomething(hugeData); // 永久持有 hugeData
}, 1000);
// 组件卸载时必须 clearInterval(timer)

/* ③ 未解绑的 DOM 事件监听 */
element.addEventListener('click', handler);
// element 被移除后,监听器仍持有引用
// → element.removeEventListener('click', handler)

/* ④ 闭包持有大对象 */
function outer() {
  const massive = new Array(1e7).fill('x');
  return () => massive.length; // 闭包捕获 massive
}
// 只要返回的函数还被引用,massive 就永不释放

/* ⑤ 游离 DOM 引用 */
const cache = [];
cache.push(document.getElementById('node'));
// 即使 node 从 DOM 树移除,cache 仍持有它

/* ⑥ 控制台打印与 Map 缓存 */
console.log(bigObject); // DevTools 会保持引用
const map = new Map();
map.set(domNode, data); // 强引用,节点无法回收
01 意外的全局变量
02 未清理的定时器与回调
03 未解绑的事件监听
04 闭包持有大对象
05 游离 DOM 引用
06 控制台打印与 Map 缓存

识别场景,是治理泄漏的第一步

内存泄漏的检测与定位

看不到的东西无法优化。Chrome DevTools 提供了一整套内存分析工具,掌握它们就掌握了"内存显微镜"。

/* 方法一:Performance 面板看趋势 */
// 1. 打开 DevTools → Performance
// 2. 勾选 Memory 复选框
// 3. 录制 → 反复执行可疑操作 → 停止
// 4. 观察 JS Heap 曲线
// 锯齿形 → 正常(GC 在回收)
// 阶梯形 → 泄漏(每次操作后基线抬升)

/* 方法二:Heap Snapshot 三快照法 */
// ① 操作前:拍快照 A
// ② 执行可疑操作 10 次
// ③ 手动触发 GC(垃圾桶图标)
// ④ 拍快照 B,再操作 10 次,拍快照 C
// ⑤ 选 Comparison 视图,对比 B 与 C
// Delta 持续为正的对象 = 泄漏嫌疑

/* 方法三:Allocation Timeline */
// 实时记录内存分配栈
// 蓝色条 = 仍存活,灰色条 = 已回收
// 直接点开蓝条查看分配位置

/* 方法四:performance.memory(仅 Chrome) */
console.log(performance.memory);
// { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit }
JS Heap 趋势对比
✓ 健康
锯齿形 · 持续回收
✗ 泄漏
阶梯形 · 基线持续抬升
Performance Heap Snapshot Allocation Timeline

WeakMap / WeakSet / WeakRef:弱引用三剑客

ES6 引入的 WeakMap、WeakSet 以及 ES2021 的 WeakRef、FinalizationRegistry,为内存敏感场景提供了标准解法。它们持有的引用不阻止垃圾回收。

/* WeakMap:键必须是对象,且为弱引用 */
const wm = new WeakMap();

let node = document.querySelector('#card');
wm.set(node, { visits: 0 });
// node 被移除 DOM 并解除外部引用后
// WeakMap 中的条目会自动消失 → 不泄漏 ✅

// 对比 Map:强引用,会阻止回收 ❌
const strongMap = new Map();
strongMap.set(node, data); // node 被永久持有

/* WeakSet:存对象集合,不阻止回收 */
const visited = new WeakSet();
function walk(el) {
  if (visited.has(el)) return;
  visited.add(el);
  // ... 遍历逻辑
}

/* WeakRef:不阻止回收的引用 */
const ref = new WeakRef(someBigObject);
// 需要时尝试取回,可能已被回收
const obj = ref.deref();
if (obj) { /* 仍然存活 */ }

/* FinalizationRegistry:回收后的回调 */
const registry = new FinalizationRegistry((id) => {
  console.log(`资源 ${id} 已被回收`);
});
registry.register(bigObj, 'bigObj-1');
WeakMap
键为对象 · 弱引用 · 不可枚举 · 适合做元数据缓存
WeakSet
对象集合 · 弱引用 · 适合做标记与去重
WeakRef
弱引用单个对象 · 需用 deref() 校验 · 慎用
FinalizationRegistry
对象被回收后的清理回调 · 时机不确定

弱引用不是万能药,不要用它替代正常的生命周期管理

降低内存压力的编码实践

除了避免泄漏,我们还可以主动减少内存分配频率与对象体积,从源头降低 GC 压力。

/* ① 复用对象,减少频繁分配 */
// 反例:每帧创建新对象
function render() {
  const pos = { x: 0, y: 0 }; // 每次都新建
}
// 正例:复用同一个对象
const posPool = { x: 0, y: 0 };

/* ② 及时解除引用 */
let bigData = await fetchLargeData();
process(bigData);
bigData = null; // 尽早释放

/* ③ 虚拟列表替代全量渲染 */
// 10000 条数据只渲染可视区域的 20 条

/* ④ 分片处理大任务,避免长任务 */
function chunkProcess(list, size = 1000) {
  let i = 0;
  function step() {
    const end = Math.min(i + size, list.length);
    for (; i < end; i++) processItem(list[i]);
    if (i < list.length) requestIdleCallback(step);
  }
  step();
}

/* ⑤ 用 TypedArray 处理数值密集型数据 */
const buffer = new Float64Array(1000000);
// 相比普通数组,内存占用更低、无装箱开销

/* ⑥ 避免隐式类型转换与对象形状突变 */
// 反例:动态增删属性,破坏 Hidden Class
const p = {}; p.a = 1; p.b = 2; delete p.a;
// 正例:构造函数中一次性声明所有属性
function Point(x, y) { this.x = x; this.y = y; }
对象池复用
↓ 分配
及时置 null
↓ 驻留
虚拟列表
↓ 节点
分片执行
↓ 卡顿
核心原则
少分配 · 早释放 · 控体积 · 分批次

内存管理最佳实践总结

  • 理解可达性:内存能否回收,取决于是否可从 GC Roots 到达,而非引用计数
  • 成对操作原则:addEventListener / removeEventListener、setInterval / clearInterval、subscribe / unsubscribe 必须成对出现
  • 组件卸载即清理:在 React、Vue 等框架中,善用 useEffect 清理函数、onUnmounted 钩子
  • 缓存要有边界:任何缓存都必须有淘汰策略(LRU / TTL),无限增长的缓存等于泄漏
  • 优先弱引用:需要把对象作为键做关联数据时,优先考虑 WeakMap
  • 用数据说话:不要凭感觉优化,用 Performance 与 Heap Snapshot 定位真实瓶颈
  • 警惕长任务:单次执行超过 50ms 的任务会阻塞渲染,也会推迟 GC 执行时机
  • 真机验证:桌面浏览器内存充裕,移动端才是内存问题的真实战场
/* 内存优化的黄金法则 */
/* 能少分配就少分配 → 能早释放就早释放 */
/* 能弱引用就弱引用 → 能可测量就可优化 */

/* 一个完整的清理示例 */
class Component {
  constructor(el) {
    this.el = el;
    this._onClick = this.handleClick.bind(this);
    this.el.addEventListener('click', this._onClick);
    this._timer = setInterval(() => this.tick(), 1000);
  }

  destroy() {
    clearInterval(this._timer);
    this.el.removeEventListener('click', this._onClick);
    this.el = null;
    this._timer = null;
    this._onClick = null;
  }
}