JavaScript 垃圾回收机制与内存管理

JavaScript 垃圾回收机制与内存管理

JavaScript 是一门具有自动垃圾回收(GC,Garbage Collection)的语言,开发者不需要手动 malloc/free。但“自动”并不等于“无需关心”:理解 GC 的工作原理,有助于你写出更高效、更稳健的前端代码,避免内存泄漏与性能抖动。

本文主要回答三个问题:

  • JavaScript 内存是如何分配与回收的?
  • 浏览器(尤其是 V8)使用了哪些垃圾回收算法?
  • 在实际开发中,哪些代码模式容易造成内存泄漏?如何规避?

一、JavaScript 的内存生命周期

一个典型变量的内存生命周期可分为三步:

  1. 分配:创建变量/对象/函数时,JS 引擎为其分配内存
  2. 使用:读写变量、调用函数、访问对象属性
  3. 回收:当不再有任何引用指向它时,GC 认为其“不可达”,在合适时机释放其占用的内存

“不可达”是垃圾回收的关键概念。


二、可达性与根对象

现代 JS 引擎通常基于 可达性(Reachability) 来判断对象是否还“活着”:

从一组根对象(Root)出发,通过引用链可以访问到的对象,都是“可达”的。

常见根对象包括:

  • 全局对象(浏览器中的 windowglobalThis
  • 当前执行栈上的变量与参数
  • 一些内部的根(如当前闭包中的变量)

如果某个对象从根出发再也无法通过任何路径访问到,就被视为“不可达”,可被 GC 回收。


三、V8 的垃圾回收策略概览

以 Chrome/Node.js 的 V8 引擎为例,它的堆内存大致分为:

  • 新生代(New Space):存放“存活时间短”的对象
  • 老生代(Old Space):存放“存活时间长”或较大的对象

对应地,会采用不同的 GC 算法:

  • 新生代:Scavenge(复制算法)
  • 老生代:标记-清除(Mark-Sweep)+ 标记-整理(Mark-Compact)

1. 新生代:复制算法(Scavenge)

新生代通常很小(几十 MB),GC 频率较高。典型做法:

  1. 把新生代堆分成两个区域:FromTo
  2. 分配对象时只在 From 区域
  3. 触发 GC 时:
    • 从根对象出发标记活跃对象
    • 把存活对象复制到 To 区域,按顺序紧凑排列
    • 交换 FromTo 角色

好处:

  • 实现简单、速度快
  • 自动完成内存碎片整理

当对象在新生代经历多次 GC 仍然存活,或体积较大时,会“晋升”到老生代。

2. 老生代:标记-清除 + 标记-整理

老生代对象多、体积大,不能频繁整体复制。典型流程:

  1. 标记(Mark):从根出发,递归标记所有可达对象
  2. 清除(Sweep):遍历堆,将未标记对象的内存回收

标记-清除会产生碎片,所以通常配合:

  • 标记-整理(Mark-Compact):在清除阶段,把存活对象向一端移动,释放连续空间,减少碎片

为减少对主线程的长时间阻塞,V8 还会对 GC 过程做:

  • 增量标记(Incremental Marking)
  • 并行/并发 GC 等优化

四、常见的内存泄漏场景

虽然 JS 有 GC,但如果你的代码对“引用关系”处理不当,仍会导致对象不可被 GC 认为“不可达”,从而发生内存泄漏。

1. 意外的全局变量

1
2
3
function foo() {
bar = 123; // 没有使用 var/let/const,成为全局变量(非严格模式)
}

解决:

  • 始终使用严格模式("use strict"
  • 使用 ESLint 等工具禁止隐式全局

2. 未清理的定时器 / 事件监听

1
2
3
4
5
6
7
8
const element = document.getElementById("btn");

function handleClick() {
console.log("clicked");
}

element.addEventListener("click", handleClick);
// 当 element 被移除时,如不 removeEventListener,可能导致引用链仍然存在

同理,未清理的 setInterval、自定义事件、发布订阅等都可能导致对象被长期引用。

建议:

  • 在组件卸载/页面切换时成对清理监听与定时器
  • 在框架(Vue/React)中使用生命周期钩子统一清理

3. 闭包使用不当

闭包本身不是问题,但如果在闭包中捕获了不必要的大对象,且一直被外部引用,就会阻止这些对象被回收。

1
2
3
4
5
6
7
8
function createBigClosure() {
const bigData = new Array(1000000).fill("xxx");
return function () {
console.log(bigData.length);
};
}

const fn = createBigClosure(); // bigData 将一直存活,直到 fn 被释放

优化:

  • 只在闭包中保留必要的数据
  • 大对象处理完后尽早断开引用(如赋值为 null 或重新赋值)

4. 缓存/单例未清理

1
2
3
4
5
6
7
8
const cache = {};

function getData(key) {
if (!cache[key]) {
cache[key] = fetchData(key);
}
return cache[key];
}

如果没有对缓存策略(如 LRU、最大容量)做设计,缓存会无限增长。

解决:

  • 设计合理的缓存淘汰策略
  • 长时间不用的数据主动清理

五、如何观察和排查内存问题?

1. Chrome DevTools 内存工具

在 DevTools 中:

  • Performance 面板:
    • 可以观察内存使用随时间的趋势
    • 是否持续增长且不回落(疑似泄漏)
  • Memory 面板:
    • Heap snapshot(堆快照):对比多次快照,观察哪些对象数量持续增长
    • Allocation instrumentation on timeline:查看在时间轴上分配的对象

2. 常见排查思路

  1. 观察内存曲线是否“锯齿状”(正常)还是“单边上涨”(异常)
  2. 对比多份 Heap 快照,查找:
    • 某类对象数量/大小不断增加
    • 某些对象被意外地长时间持有引用
  3. 回到代码中查找:
    • 未清理的事件/定时器
    • 久未清理的缓存
    • 闭包中持有的大对象

六、工程实践建议

  1. 减少全局变量与单例滥用:能局部的就不要全局,使用模块化管理。
  2. 成对管理监听与资源
    • addEventListenerremoveEventListener
    • setIntervalclearInterval
    • 第三方 SDK 初始化 ↔ 销毁
  3. 谨慎使用闭包保存大对象:必要时使用弱引用或显式释放。
  4. 使用弱引用结构(WeakMap、WeakSet)
    • 适合用来存储“与对象生命周期绑定”的元数据
    • 当 key 对象被 GC 回收时,WeakMap/WeakSet 中的条目会自动消失
  5. 监控与预警
    • 对长时间运行的单页应用(SPA)做好内存监控
    • 在埋点中采集内存使用趋势(如 performance.memory,注意兼容性)

七、总结

JavaScript 的垃圾回收机制并不神秘,它大致遵循:

  • 以“可达性”为标准判断对象是否可以被回收
  • 使用分代回收策略(新生代与老生代)
  • 基于复制算法与标记-清除/标记-整理等经典 GC 算法

作为前端开发者,不必深入到每个 GC 优化细节,但需要在日常开发中保持“内存意识”:

  • 避免意外全局变量与长久引用
  • 规范地清理事件/定时器等资源
  • 定期检查长时间运行页面的内存趋势

这样,你写出的应用不仅“功能上能跑”,也能长时间稳定运行而不会越用越卡。


JavaScript 垃圾回收机制与内存管理
https://sunjc.vip/2024/09/03/JavaScript垃圾回收机制与内存管理/
作者
Sunjc
发布于
2024年9月3日
许可协议