线上页面越用越卡时,我一般先看堆快照里的 Detached DOM 和不断增长的数组,再回头找引用链。
Q1: 内存泄漏的历史?为什么会有它?
1
1970s: 手动管理内存的年代
C/C++ 时代,程序员用
malloc / free 手动分配和释放内存。忘记 free 就是泄漏——简单粗暴,责任全在开发者。这个时代的泄漏是”忘了还”。2
1995年: JavaScript 引入自动垃圾回收
Brendan Eich 设计 JavaScript 时,为了让开发者不用操心内存管理,引入了**垃圾回收(Garbage Collection)**机制。引擎自动判断哪些内存不再需要,然后回收。
3
问题: 自动回收不等于不会泄漏
自动垃圾回收只能回收”不可达”的对象。如果一个对象你不再需要了,但代码中仍然存在对它的引用,垃圾回收器就认为它”还活着”——这就是内存泄漏。泄漏的本质是:你认为没用了,但引擎认为还有用。
内存泄漏的本质是:你不再需要的对象,因为仍被引用而无法被垃圾回收器回收。
🚫 手动管理内存
像自己做饭后不洗碗——用过的碗(分配的内存)堆在水池里,直到水池满了(内存溢出)才发现问题。C/C++ 开发者每天都在和这个搏斗。
✅ 自动垃圾回收
像请了一个保洁阿姨(垃圾回收器),她会定期清理你看不见的垃圾。但如果你把一件旧衣服塞在柜子深处(被引用),保洁阿姨永远不会扔掉它——因为看起来你还在用。
Q2: JavaScript 内存管理机制(栈内存 vs 堆内存)
JavaScript 的内存分为两个区域:栈内存(Stack) 和 堆内存(Heap)。栈内存由操作系统自动分配和回收,堆内存由 JavaScript 引擎的垃圾回收器管理。垃圾回收的代际策略(V8)
V8 引擎将堆内存分为两代:
新生代的对象如果经过一次 Scavenge 仍然存活(或 To 空间使用率超过阈值),就会被”晋升”到老生代。老生代使用标记清理(Mark-Sweep)和标记整理(Mark-Compact)来回收内存。
WeakRef 和 FinalizationRegistry(ES2021)
ES2021 引入了WeakRef 和 FinalizationRegistry,提供了更精细的内存控制:
Q3: 常见内存泄漏场景
场景一:闭包(Closure)持有外部引用
闭包是最经典的泄漏场景。当内部函数引用了外部函数的变量,即使外部函数执行完毕,这些变量也不会被回收。场景二:DOM 引用未清理
JavaScript 中保留了对已移除 DOM 元素的引用,导致 DOM 元素无法被回收。场景三:定时器未清理
setInterval 和忘记清理的 setTimeout 会持续持有回调函数及其闭包的引用。
场景四:事件监听器未移除
添加了事件监听器但忘记移除,监听器回调函数及其闭包会一直存在。场景五:Map / Set 未清理
Map 和 Set 对其键和值持有强引用。如果不再需要,必须手动清除。
Q4: 如何排查内存泄漏
Chrome DevTools Memory 面板
Chrome DevTools 提供了三种内存分析工具:堆快照分析步骤
Allocation Timeline 定位持续增长
Performance Monitor 实时监控
Chrome DevTools 的 Performance Monitor 可以实时查看 JS 堆大小:Q5: 内存泄漏的修复方案
原则一:及时解除引用
原则二:使用 WeakRef / WeakMap / WeakSet
原则三:组件生命周期中清理资源
原则四:避免意外的全局变量
原则五:虚拟列表处理大数据
修复内存泄漏的核心是:理解可达性规则,及时切断不需要的引用,使用 Weak 系列数据结构,在组件销毁时清理所有副作用。
Q6: 经典面试题:如何排查一个 SPA 应用的内存泄漏?
一个典型的 SPA 应用中,页面在多个路由之间切换。用户反馈”用久了页面越来越卡”。请排查并修复。 排查步骤:底层源码:V8 是如何实现垃圾回收的?
V8 的新生代回收(Scavenge)是怎么实现的?
V8 新生代使用 Cheney 算法(半区复制算法),详见 V8 源码:src/heap/spaces.cc:
老生代的 Mark-Sweep 和 Mark-Compact 是怎么实现的?
老生代使用标记清理和标记整理,详见 V8 源码:src/heap/mark-compact.cc:
七、及格回答 vs 满分回答
问题:“什么是内存泄漏?如何排查和修复?”
及格回答(60-70分):“内存泄漏就是内存用完了没有释放。常见原因有闭包、定时器、事件监听器。可以用 Chrome DevTools 的 Memory 面板排查。”问题出在:知道概念但缺乏系统性理解。 满分回答(90-100分):
内存泄漏的本质是:你不再需要的对象,因为仍被引用而无法被垃圾回收器回收。 JavaScript 使用标记清理(Mark-and-Sweep)算法从根对象出发遍历可达对象,只有不可达的对象才会被回收。所以泄漏的关键不是”你是否还需要”,而是”是否还有引用指向它”。[1]
常见泄漏场景有五类:闭包持有外部大对象的引用、保留已移除 DOM 元素的引用、setInterval/setTimeout未清理、全局对象上的事件监听器未移除、Map/Set持续增长。修复的核心原则是及时切断引用(设为null),使用WeakMap/WeakRef替代强引用,在组件生命周期中清理副作用。
排查方法是三次快照法:操作前拍快照 A,操作后拍快照 B,再操作一次拍快照 C。对比 B 和 C,如果 C 比 B 多出的对象在操作结束时仍未释放,就是泄漏。在堆快照中点击泄漏对象,查看 Retainers 面板可以找到完整的引用链,从而定位到具体代码。[2]
V8 引擎将堆分为新生代和老生代。新生代使用 Cheney 算法(半区复制),速度快但浪费一半空间;老生代使用 Mark-Sweep + Mark-Compact,同时用增量标记(Incremental Marking)避免长时间阻塞主线程。理解这些底层机制有助于判断哪些操作代价高、如何配合引擎优化。
常见追问
闭包一定会导致内存泄漏吗?
闭包一定会导致内存泄漏吗?
答题要点:
闭包本身不是泄漏,只有当闭包持有了你不再需要的大量数据时才是泄漏。高分回答:
“闭包(Closure)本身不会导致内存泄漏。闭包是 JavaScript 的核心特性——函数记住了它被创建时的词法环境。只有当闭包持有了你不再需要的大量数据时,才会构成泄漏。关键是:闭包闭包持有的是引用而不是值的拷贝。如果你只需要一个属性,就把它提取出来,让大对象可以被回收。”
WeakMap 和 Map 的区别?WeakMap 有什么限制?
WeakMap 和 Map 的区别?WeakMap 有什么限制?
答题要点:
从垃圾回收的角度解释强引用 vs 弱引用。高分回答:
“WeakMap 的限制:
Map 对键持有强引用——只要 Map 存在,键对象就永远不会被垃圾回收。WeakMap 对键持有弱引用——如果没有其他引用指向键对象,键可以被 GC 回收,对应的值也会被自动清除。- 键必须是对象(不能是 string、number 等基本类型)
- 不可遍历(没有
forEach、keys、values、entries方法) - 没有
size属性
标记清理算法是如何解决循环引用的?
标记清理算法是如何解决循环引用的?
答题要点:
对比引用计数的缺陷,解释标记清理的可达性分析。高分回答:
“早期 IE 使用引用计数(Reference Counting)算法——每个对象有一个引用计数器,当计数为 0 时回收。这个算法的致命缺陷是循环引用:现代引擎使用**标记清理(Mark-and-Sweep)**算法:从根对象(
window / globalThis)出发,遍历所有可达对象并标记,然后清除未标记的对象。在上面的例子中,leak() 执行完后,objA 和 objB 从根对象不可达,即使它们互相引用,也会被清除。”如何用 Performance Monitor 快速判断是否存在泄漏?
如何用 Performance Monitor 快速判断是否存在泄漏?
答题要点:
关注三个指标的趋势变化。高分回答:
“打开 Chrome DevTools → More tools → Performance Monitor,关注三个指标:
- JS heap size:正常情况应该在操作后回落。如果反复操作后 JS heap 只增不减,说明有泄漏。
- DOM Nodes:如果反复切换页面/弹窗后 DOM 节点数只增不减,说明 DOM 节点未被正确移除。
- JS event listeners:如果反复操作后监听器数量只增不减,说明事件监听器未移除。
- 打开 Performance Monitor
- 执行一次可疑操作(如打开弹窗再关闭)
- 手动点击垃圾回收按钮(Memory 面板左上角的垃圾桶图标)
- 观察指标是否回落
- 重复操作 5-10 次,如果指标持续增长,确认存在泄漏”
React 中如何避免内存泄漏?
React 中如何避免内存泄漏?
答题要点:
结合 useEffect cleanup、AbortController、事件清理。高分回答:
“React 中的内存泄漏主要来自 核心原则:每个
useEffect 中的副作用未清理。常见场景和修复:useEffect 都必须返回清理函数,清理所有在 effect 中创建的资源。”WeakRef 和 FinalizationRegistry 的使用场景?
WeakRef 和 FinalizationRegistry 的使用场景?
答题要点:
说明 WeakRef 解决了什么问题,以及 FinalizationRegistry 的注意事项。高分回答:
“注意:
WeakRef 创建一个不阻止垃圾回收的弱引用。典型使用场景是缓存:FinalizationRegistry 在对象被 GC 回收后执行回调,适合清理外部资源:FinalizationRegistry 的回调执行时机不确定——可能在 GC 后很久才执行,甚至不执行。永远不要用它来做关键业务逻辑,只适合做资源清理。”如何排查 Node.js 服务端的内存泄漏?
如何排查 Node.js 服务端的内存泄漏?
答题要点:
对比浏览器端,介绍 Node.js 特有的排查工具。高分回答:
“Node.js 排查内存泄漏的工具和浏览器端有所不同:1. heapdump 模块2. —inspect 远程调试3. process.memoryUsage() 监控Node.js 特有的泄漏场景:
- 未清理的 Event Emitter 监听器(超过 11 个监听器会触发警告)
- 未关闭的数据库连接池
- 未销毁的 Express 中间件持有的引用”
为什么说全局变量是最隐蔽的内存泄漏?
为什么说全局变量是最隐蔽的内存泄漏?
答题要点:
从可达性分析的角度解释,全局变量永远不会被 GC。高分回答:
“从垃圾回收的角度看,全局对象(修复方案:
window / globalThis)是标记清理算法的根对象。所有挂载到全局对象上的变量都是从根可达的——这意味着它们永远不会被垃圾回收。- 使用
'use strict'模式,意外的全局变量会抛出 ReferenceError - 用
WeakMap替代普通对象做缓存 - 每次添加监听器前先移除旧的(或使用
{ once: true })“
延伸阅读与引用参考
- MDN: Memory Management:Mozilla 官方对 JavaScript 内存管理机制的权威解释。[1]
- Chrome DevTools: Memory Profiling:Google 官方的内存排查指南,包含堆快照和 Allocation Timeline 的详细使用方法。[2]
- V8 Blog: Trash Talk:V8 引擎团队对 Orinoco 垃圾回收器的深度解析,包含增量标记、并发清理等优化策略。
