【每日一面】内存泄漏与垃圾回收
内存泄漏的本质是对象从根仍然可达、GC 回收不掉,所以要掌握的是标记-清除与代际回收的判定逻辑,以及全局变量、定时器、闭包、DOM 引用、事件监听、第三方库这六类高频泄漏场景的定位路径。
内存泄漏是 JS 中隐蔽性最强的问题之一——它不会让你立即报错,而是悄悄吃掉内存,直到页面卡顿、崩溃。搞懂垃圾回收机制和常见泄漏场景,是每个前端工程师的必修课。
基础问答
问:什么是内存泄漏?JavaScript 的垃圾回收机制是怎么工作的?
答:内存泄漏(Memory Leak)是指程序中已不再使用的对象,因为仍然被某个引用持有,导致垃圾回收器无法将其回收,从而持续占用内存。泄漏累积到一定程度就会引发页面卡顿、性能下降乃至浏览器崩溃。
JavaScript 采用自动垃圾回收,开发者不需要手动 free 内存。现代浏览器主流使用标记-清除(Mark-and-Sweep)算法:
-
标记阶段:从根对象(
window、global、调用栈等)出发,深度遍历所有可达对象并打上标记 -
清除阶段:遍历堆中所有对象,清除未被标记的对象,释放其内存
-
整理阶段(可选):部分引擎会做内存整理,减少碎片
早期还有引用计数方案(跟踪每个对象的被引用次数,降为 0 时回收),但无法处理循环引用(如两个对象互相引用),现在基本已被标记-清除取代。
// 引用计数的死穴:循环引用
function createLoop() {
const a = {};
const b = {};
a.ref = b;
b.ref = a;
// 函数执行完,a 和 b 引用计数都不为 0,无法回收
// 标记-清除则能从根出发判断它们不可达,正常回收
}扩展延伸
垃圾回收的细节
理解 GC 工作方式,才能知道泄漏为什么发生。
代际假说(Generational Hypothesis):大部分对象朝生夕死,创建后很快就不再使用。V8 引擎据此将堆分为两代:
-
新生代(Young Generation):空间小、GC 频繁。使用 Scavenge 算法(基于 Cheney 算法的复制式回收),将空间分为 From 和 To 两个半区,存活对象复制到 To 区后角色互换,速度极快
-
老生代(Old Generation):空间大、GC 频率低。对象经过多次新生代 GC 仍存活则晋升。使用标记-清除 + 标记-整理,可配合增量标记和并发标记减少主线程暂停
flowchart TD
A["分配新对象"] --> B["放入新生代 From 区"]
B --> C["触发新生代 GC"]
C --> D{"对象是否存活?"}
D -->|否| E["回收"]
D -->|是| F{"是否经历过<br/>多次 GC?"}
F -->|否| G["复制到 To 区<br/>并交换 From/To"]
F -->|是| H["晋升到老生代"]
H --> I["老生代满时触发<br/>标记-清除/整理"]
G --> J["继续使用"]
I --> K["增量/并发标记<br/>减少主线程阻塞"]
K --> L["清除不可达对象<br/>整理碎片"]
值得注意的是,GC 只能回收从根不可达的对象。只要有一条引用路径从根连到某个对象,它就永远不会被回收,这就是出现内存泄漏问题的本质。
常见内存泄漏场景
-
意外的全局变量
非严格模式下,未声明变量的赋值会挂到
window上:javascriptfunction leak() { // 未声明变量,非严格模式下成为 window.someData someData = new Array(1000000).fill('leak'); } leak(); // someData 永久可达,无法回收这种问题的修复比较简单,可以启用
"use strict";使用let/const声明;全局变量用完后赋null。 -
被遗忘的定时器和回调
javascriptfunction startTimer() { const heavyData = new Array(1000000).fill('data'); setInterval(() => { // 回调引用了 heavyData,且定时器未清理 console.log(heavyData.length); }, 1000); } startTimer(); // 即使不再需要定时器,它和它的闭包(含 heavyData)永远存在在组件卸载时清除定时器,或用
setTimeout递归替代(便于控制生命周期),就可以避免问题。 -
闭包持有外部变量
javascriptfunction createClosure() { const largeArray = new Array(1000000).fill('😅'); return function useClosure() { // 即使只用了少量数据,整个 largeArray 都被闭包持有 console.log('closure alive'); }; } const leakedFn = createClosure(); // createClosure 执行完,largeArray 本应回收 // 但因为 leakedFn(闭包)仍引用它,所以无法释放修复方案是,只让闭包访问真正需要的变量,用完赋
null就切断引用。 -
DOM 引用残留
从 DOM 树移除元素后,JS 中仍持有引用:
javascript// 场景一:模块级缓存持久持有 DOM 引用 const domCache = new Map(); function mountList() { const items = document.querySelectorAll('.item'); items.forEach((el, idx) => { domCache.set(idx, el); // 即使后续从 DOM 树移除,缓存仍持有引用 }); } mountList(); // 组件卸载后忘记清空 domCache → 所有 DOM 节点永久无法回收 // 场景二:数组残留 DOM 引用 const elements = []; function render() { elements.length = 0; // 看似清空了?不,旧引用没断 document.querySelectorAll('.item').forEach(el => elements.push(el)); } // 如果 .item 被替换为新元素,旧元素仍在 elements 中某个位置被引用删除 DOM 节点后,将 JS 引用也清空或使用
WeakRef或事件委托减少直接持有避免内存泄漏。 -
事件监听器未移除
javascriptclass ChatComponent { mount() { this._boundResize = this.handleResize.bind(this); window.addEventListener('resize', this._boundResize); // 组件卸载后,resize 监听器依然活着 // 如果多次 mount/unmount,会反复注册,造成"监听器堆积" } unmount() { // 忘记移除监听器! } }在
unmount时用removeEventListener移除,或在 SPA 框架中用生命周期钩子确保成对注册/注销。 -
第三方库资源未释放
ECharts、地图 SDK、视频播放器等往往自己管理内存:
javascriptconst chart = echarts.init(document.getElementById('main')); chart.setOption({ /* 配置 */ }); // 页面离开时忘记调用 chart.dispose() // chart 实例及其内部事件、定时器、DOM 引用全部无法回收这种第三方库,需要严格遵循第三方库的生命周期 API,记得在组件销毁时调用
dispose/destroy。
检测与定位泄漏
Chrome DevTools Memory 面板是主力工具。
flowchart LR
A["打开 DevTools<br/>→ Memory 面板"] --> B["拍摄初始 Heap Snapshot"]
B --> C["操作页面<br/>重复疑似泄漏步骤"]
C --> D["拍摄第二次 Heap Snapshot"]
D --> E["切换到 Comparison 模式<br/>对比两次快照"]
E --> F{"Delta 中有大量<br/>新增对象且未释放?"}
F -->|是| G["查看 Retainers 链<br/>追踪引用路径"]
F -->|否| H["排除该场景"]
G --> I["定位泄漏源头<br/>修复后验证"]
实操步骤:
-
Allocation Timeline(分配时间轴):录制 → 操作页面 → 停止,观察蓝色竖条是否只升不降。找到只分配不回收的操作区域
-
Heap Snapshot(堆快照):操作前后各拍一次,切换到 Comparison 模式,关注 Delta(正数 = 新增未释放的对象)
-
Retainers(保留树):选中可疑对象,展开 Retainers 链追踪是谁引用了它
-
手动 GC:点击垃圾桶按钮强制回收,如果内存仍高则确认存在泄漏
对比总结
| 对比维度 | 正常内存管理 | 内存泄漏 |
|---|---|---|
| GC 可达性 | 不可达 → 正常回收 | 意外可达 → 无法回收 |
| 内存曲线 | 锯齿状(分配 → 回收 → 分配) | 持续上升,无回落 |
| 性能影响 | 平稳 | 页面逐步卡顿,最终崩溃 |
| 典型原因 | — | 全局变量、闭包、定时器、DOM 引用、事件监听 |
| 排查工具 | — | Chrome Memory / Performance Monitor |
| 预防泄漏 | 严格模式、生命周期管理、WeakRef、事件委托 | 忽略清理导致泄漏 |
面试追问
追问1:WeakMap 和 WeakSet 为什么能帮助避免内存泄漏?用在什么场景?
WeakMap 的 key 是弱引用,当 key 对象外部不再被引用时,WeakMap 中的条目会自动被 GC 回收,不会阻止 key 的销毁。WeakSet 同理。
经典场景:给 DOM 元素关联数据,DOM 删除后关联数据自动释放:
const domData = new WeakMap();
function associateData(el, data) {
domData.set(el, data);
}
// 当 el 从 DOM 树移除且外部无引用时
// domData 中的条目自动消失,无需手动清理如果用 Map 代替,即使元素已删除,Map 仍持有引用导致泄漏。WeakMap 的弱引用特性让它天然适合做元数据存储和缓存。
追问2:以下代码会导致内存泄漏吗?为什么?
const arr = [];
for (let i = 0; i < 10000; i++) {
arr.push(function() {
console.log(i);
});
}答:会造成较大内存开销,但不一定是泄漏。arr 持有 10000 个闭包,每个闭包持有了自己的 i(let 声明的块级变量,每次循环创建独立绑定),但这 10000 个函数在外部仍然被需要(通过 arr 可访问),所以它们可达,不构成泄漏,除非 arr 本应被释放但未释放。
如果这个 arr 被意外挂到了全局对象上(如下),就是泄漏:
window.cache = arr; // 意外全局引用 → 泄漏追问3:在 React 中,useEffect 的 cleanup 函数与内存泄漏有什么关系?给一个典型例子。
useEffect 的 cleanup 函数在组件卸载或依赖变化时执行,是 React 防止泄漏的关键机制:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = new WebSocket(`wss://chat/${roomId}`);
socket.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
// 忘记 cleanup:组件卸载后 socket 仍在,onmessage 回调会调用已卸载组件的 setState
// return () => {
// socket.close(); // 必须清理
// };
}, [roomId]);
return <ul>{messages.map(m => <li key={m}>{m}</li>)}</ul>;
}不写 cleanup 的后果:
-
socket 连接持续存在,浪费服务器资源
-
回调中调用已卸载组件的
setState→ React 报 warning -
如果依赖频繁变化(如
roomId),旧的setInterval/socket会不断堆积
修复方案:始终在 cleanup 中清理订阅、定时器、DOM 事件监听。
追问4:线上 Node.js 服务出现 OOM,如何快速定位并止损?
止损优先:
-
运维层面滚动重启服务实例,恢复可用性
-
若近期有发布,评估回滚到上一个稳定版本
-
配置 k8s
livenessProbe+ 资源限制,让 Pod 自动重启
定位根因:
-
启动 Node.js 时加
--inspect标志,连接 Chromechrome://inspect分析堆快照 -
加载
heapdump模块,在内存阈值触发时 dump 堆快照 -
分析快照:找大对象(内存占用高)、找增长异常的全局缓存/Map/数组
-
结合 APM 工具(如 Sentry、Alibaba Node.js Performance Platform)追踪请求链路
典型线上泄漏:
// 无淘汰策略的全局缓存
const userCache = {};
app.get('/api/user/:id', async (req, res) => {
const { id } = req.params;
if (!userCache[id]) {
userCache[id] = await fetchUserFromDB(id); // 只增不减
}
res.json(userCache[id]);
});
// 用户量持续增长 → 缓存无限膨胀 → OOM修复方案:引入 LRU 缓存库(如 lru-cache),或设置 TTL 过期策略。
追问5:给你一个疑似泄漏的页面,怎么设计排查步骤来确认和定位泄漏?
排查步骤:
-
确认泄漏存在
-
打开 DevTools Performance Monitor,观察 JS Heap 曲线
-
反复执行进入页面 → 操作核心功能 → 返回的完整循环 10 次
-
如果 JS Heap 曲线持续上升且每次循环后不回落到基准值 → 确认存在泄漏
-
-
缩小范围(二分法)
-
逐个禁用功能模块,重复 Step 1,找出触发泄漏的具体操作
-
例如:禁用图表渲染后泄漏消失 → 锁定 ECharts 实例未正确 dispose
-
-
精确定位
-
锁定操作后,用 Allocation Timeline 录制该操作的完整闭环
-
找到只分配不回收的蓝色竖条区域
-
拍摄操作前后的 Heap Snapshot,Comparison 模式排序 Delta
-
找到 Delta 最大的构造函数,展开 Retainers 追踪引用路径
-
-
修复验证
-
修复后,重复 Step 1 的完整循环,确认 JS Heap 曲线重新呈现锯齿状(有升有降)
-
手动触发 GC 后内存回到基准水位
-
这个排查流程在阿里巴巴、字节等大厂的前端面试中经常被问到,关键在于要有实验设计的思维,而不是靠猜,严谨的通过二分法和工具链逐步定位。