JavaScript 垃圾回收机制与内存管理
JavaScript 垃圾回收机制与内存管理
JavaScript 是一门具有自动垃圾回收(GC,Garbage Collection)的语言,开发者不需要手动 malloc/free。但“自动”并不等于“无需关心”:理解 GC 的工作原理,有助于你写出更高效、更稳健的前端代码,避免内存泄漏与性能抖动。
本文主要回答三个问题:
- JavaScript 内存是如何分配与回收的?
- 浏览器(尤其是 V8)使用了哪些垃圾回收算法?
- 在实际开发中,哪些代码模式容易造成内存泄漏?如何规避?
一、JavaScript 的内存生命周期
一个典型变量的内存生命周期可分为三步:
- 分配:创建变量/对象/函数时,JS 引擎为其分配内存
- 使用:读写变量、调用函数、访问对象属性
- 回收:当不再有任何引用指向它时,GC 认为其“不可达”,在合适时机释放其占用的内存
“不可达”是垃圾回收的关键概念。
二、可达性与根对象
现代 JS 引擎通常基于 可达性(Reachability) 来判断对象是否还“活着”:
从一组根对象(Root)出发,通过引用链可以访问到的对象,都是“可达”的。
常见根对象包括:
- 全局对象(浏览器中的
window、globalThis) - 当前执行栈上的变量与参数
- 一些内部的根(如当前闭包中的变量)
如果某个对象从根出发再也无法通过任何路径访问到,就被视为“不可达”,可被 GC 回收。
三、V8 的垃圾回收策略概览
以 Chrome/Node.js 的 V8 引擎为例,它的堆内存大致分为:
- 新生代(New Space):存放“存活时间短”的对象
- 老生代(Old Space):存放“存活时间长”或较大的对象
对应地,会采用不同的 GC 算法:
- 新生代:Scavenge(复制算法)
- 老生代:标记-清除(Mark-Sweep)+ 标记-整理(Mark-Compact)
1. 新生代:复制算法(Scavenge)
新生代通常很小(几十 MB),GC 频率较高。典型做法:
- 把新生代堆分成两个区域:
From和To - 分配对象时只在
From区域 - 触发 GC 时:
- 从根对象出发标记活跃对象
- 把存活对象复制到
To区域,按顺序紧凑排列 - 交换
From和To角色
好处:
- 实现简单、速度快
- 自动完成内存碎片整理
当对象在新生代经历多次 GC 仍然存活,或体积较大时,会“晋升”到老生代。
2. 老生代:标记-清除 + 标记-整理
老生代对象多、体积大,不能频繁整体复制。典型流程:
- 标记(Mark):从根出发,递归标记所有可达对象
- 清除(Sweep):遍历堆,将未标记对象的内存回收
标记-清除会产生碎片,所以通常配合:
- 标记-整理(Mark-Compact):在清除阶段,把存活对象向一端移动,释放连续空间,减少碎片
为减少对主线程的长时间阻塞,V8 还会对 GC 过程做:
- 增量标记(Incremental Marking)
- 并行/并发 GC 等优化
四、常见的内存泄漏场景
虽然 JS 有 GC,但如果你的代码对“引用关系”处理不当,仍会导致对象不可被 GC 认为“不可达”,从而发生内存泄漏。
1. 意外的全局变量
1 | |
解决:
- 始终使用严格模式(
"use strict") - 使用 ESLint 等工具禁止隐式全局
2. 未清理的定时器 / 事件监听
1 | |
同理,未清理的 setInterval、自定义事件、发布订阅等都可能导致对象被长期引用。
建议:
- 在组件卸载/页面切换时成对清理监听与定时器
- 在框架(Vue/React)中使用生命周期钩子统一清理
3. 闭包使用不当
闭包本身不是问题,但如果在闭包中捕获了不必要的大对象,且一直被外部引用,就会阻止这些对象被回收。
1 | |
优化:
- 只在闭包中保留必要的数据
- 大对象处理完后尽早断开引用(如赋值为 null 或重新赋值)
4. 缓存/单例未清理
1 | |
如果没有对缓存策略(如 LRU、最大容量)做设计,缓存会无限增长。
解决:
- 设计合理的缓存淘汰策略
- 长时间不用的数据主动清理
五、如何观察和排查内存问题?
1. Chrome DevTools 内存工具
在 DevTools 中:
- Performance 面板:
- 可以观察内存使用随时间的趋势
- 是否持续增长且不回落(疑似泄漏)
- Memory 面板:
- Heap snapshot(堆快照):对比多次快照,观察哪些对象数量持续增长
- Allocation instrumentation on timeline:查看在时间轴上分配的对象
2. 常见排查思路
- 观察内存曲线是否“锯齿状”(正常)还是“单边上涨”(异常)
- 对比多份 Heap 快照,查找:
- 某类对象数量/大小不断增加
- 某些对象被意外地长时间持有引用
- 回到代码中查找:
- 未清理的事件/定时器
- 久未清理的缓存
- 闭包中持有的大对象
六、工程实践建议
- 减少全局变量与单例滥用:能局部的就不要全局,使用模块化管理。
- 成对管理监听与资源:
addEventListener↔removeEventListenersetInterval↔clearInterval- 第三方 SDK 初始化 ↔ 销毁
- 谨慎使用闭包保存大对象:必要时使用弱引用或显式释放。
- 使用弱引用结构(WeakMap、WeakSet):
- 适合用来存储“与对象生命周期绑定”的元数据
- 当 key 对象被 GC 回收时,WeakMap/WeakSet 中的条目会自动消失
- 监控与预警:
- 对长时间运行的单页应用(SPA)做好内存监控
- 在埋点中采集内存使用趋势(如
performance.memory,注意兼容性)
七、总结
JavaScript 的垃圾回收机制并不神秘,它大致遵循:
- 以“可达性”为标准判断对象是否可以被回收
- 使用分代回收策略(新生代与老生代)
- 基于复制算法与标记-清除/标记-整理等经典 GC 算法
作为前端开发者,不必深入到每个 GC 优化细节,但需要在日常开发中保持“内存意识”:
- 避免意外全局变量与长久引用
- 规范地清理事件/定时器等资源
- 定期检查长时间运行页面的内存趋势
这样,你写出的应用不仅“功能上能跑”,也能长时间稳定运行而不会越用越卡。