内存泄漏排查
背景:页面越用越卡,内存去哪了
你可能遇到过这样的场景:一个 SPA 应用,用户在页面上操作了十几分钟后,页面明显变卡,打开任务管理器一看,浏览器标签页的内存占用从 100MB 飙到了 800MB。刷新页面后恢复正常,但过一段时间又卡了。
这就是典型的内存泄漏(Memory Leak)。它不会让页面直接崩溃,但会像慢性病一样逐渐拖垮用户体验,甚至触发浏览器的 OOM(Out of Memory)强制回收。
什么是内存泄漏
内存泄漏的本质:程序中已经不再需要使用的内存,由于某种原因无法被垃圾回收器(GC)回收,导致可用内存持续减少。
注意区分两个概念:
- 内存膨胀(Memory Bloat):程序确实需要这么多内存,属于设计问题
- 内存泄漏(Memory Leak):程序不再需要这些内存,但 GC 无法释放,属于 Bug
判断标准很简单:如果某块内存已经不会再被程序访问到,但仍然存在于堆中没有被回收——这就是泄漏。
JavaScript 垃圾回收机制
理解泄漏之前,必须理解 GC 是怎么判断"这块内存不再需要"的。
引用计数(Reference Counting)
最朴素的策略:每个对象维护一个引用计数器,有新引用指向它时加 1,引用断开时减 1,计数归零时回收。
let obj = { name: 'test' }; // 引用计数 = 1
let ref = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1
ref = null; // 引用计数 = 0 → 可回收
致命缺陷是循环引用:
function createCycle() {
const a = {};
const b = {};
a.ref = b;
b.ref = a;
// 函数执行完后,a 和 b 互相引用,计数永远不为 0
}
现代浏览器已经不再单独使用引用计数,但理解它有助于理解为什么某些场景会泄漏。
标记清除(Mark-and-Sweep)
V8 等现代引擎采用的主流算法。核心思路:
- 从根对象(全局对象、当前调用栈中的变量)出发
- 递归遍历所有可达对象,标记为"活跃"
- 未被标记的对象视为垃圾,释放其内存
关键点:判断标准是"可达性"(Reachability),而非引用计数。 即使存在循环引用,只要从根对象不可达,就会被回收。
V8 的分代回收
V8 将堆内存分为两个区域:
| 区域 | 特点 | 回收策略 |
|---|---|---|
| 新生代(Young Generation) | 存放存活时间短的对象,容量小(1-8MB) | Scavenge(复制算法),频繁执行 |
| 老生代(Old Generation) | 存放存活时间长的对象,容量大 | Mark-Sweep + Mark-Compact,执行频率低 |
对象在新生代经历两次 GC 仍存活,会被晋升到老生代。这意味着:泄漏的对象最终都会进入老生代,而老生代 GC 频率低、成本高,导致内存回收更加困难。
常见内存泄漏场景
1. 意外的全局变量
function processData() {
// 忘记 let/const,变量挂到 window 上
leakedData = new Array(1000000).fill('x');
}
// 或者 this 指向 window
function Handler() {
this.cache = new Map(); // 非严格模式下 this === window
}
Handler(); // 没有 new
全局变量的生命周期等同于页面生命周期,只要页面不关闭就不会被回收。
防范:开启 'use strict',使用 ESLint 的 no-implicit-globals 规则。
2. 未清除的定时器和回调
// 组件挂载时设置了定时器
const timer = setInterval(() => {
const node = document.getElementById('status');
if (node) {
node.textContent = fetchStatus();
}
}, 1000);
// 组件卸载时忘记清除
// clearInterval(timer) ← 缺少这行
即使 DOM 节点已被移除,定时器的回调函数仍然持有对外部变量的引用,形成引用链,阻止 GC 回收整个闭包作用域中的对象。
3. 事件监听器未移除
class VideoPlayer {
constructor(element) {
this.element = element;
this.buffer = new ArrayBuffer(50 * 1024 * 1024); // 50MB
// 绑定了事件但从未解绑
window.addEventListener('resize', this.handleResize.bindthis));
}
handleResize() {
this.element.style.width = window.innerWidth + 'px';
}
destroy() {
this.element.remove();
// 忘记 removeEventListener
// window 仍然持有对 this.handleResize 的引用
// 进而持有对整个实例(包括 50MB buffer)的引用
}
}
关键点:addEventListener 会让目标对象持有回调的强引用。如果目标是 window 或 document 这类长生命周期对象,回调及其闭包中引用的所有对象都不会被回收。
4. 闭包持有不必要的引用
function createProcessor() {
const hugeData = loadHugeDataset(); // 100MB 数据
return function process(item) {
// 只用到了 item,但闭包捕获了整个作用域
return item.id;
};
}
const processor = createProcessor();
// hugeData 永远不会被回收,因为 processor 的闭包作用域持有它
V8 的优化器通常会分析闭包实际引用了哪些变量,只保留必要的。但在某些情况下(如使用 eval、with,或多个闭包共享同一作用域),这个优化会失效。
解决方案:手动解除不需要的引用。
function createProcessor() {
let hugeData = loadHugeDataset();
const index = buildIndex(hugeData);
hugeData = null; // 显式释放
return function process(item) {
return index.get(item.id);
};
}
5. 分离的 DOM 节点(Detached DOM)
let detachedTree;
function createList() {
const ul = document.createElement('ul');
for (let i = 0; i < 10000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
ul.appendChild(li);
}
// 存储了引用但没有插入文档
detachedTree = ul;
}
function removeList() {
document.getElementById('container').innerHTML = '';
// detachedTree 仍然持有整棵 DOM 树的引用
}
DOM 节点从文档中移除后,如果 JS 中仍有变量引用它,就变成了"分离的 DOM 节点"——既不在页面中渲染,也无法被 GC 回收。
6. 未取消的网络请求和 AbortController
function fetchUserData(userId) {
// 每次路由切换都发起请求,但从不取消之前的
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
// 组件可能已经卸载,但回调仍在执行
this.setState({ userData: data });
});
}
正确做法:
function fetchUserData(userId, signal) {
return fetch(`/api/users/${userId}`, { signal })
.then(res => res.json());
}
// 组件卸载时
const controller = new AbortController();
fetchUserData(userId, controller.signal);
// cleanup
controller.abort();
7. Vue/React 中的典型泄漏
React 中常见的泄漏模式:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
// ❌ 缺少清理函数,组件卸载后 WebSocket 仍然活跃
// return () => ws.close();
}, [roomId]);
return <MessageList messages={messages} />;
}
Vue 中常见的泄漏模式:
export default {
mounted() {
this.observer = new IntersectionObserver(this.handleIntersect);
this.observer.observe(this.$refs.target);
window.addEventListener('scroll', this.handleScroll);
this.$eventBus.$on('data-update', this.handleUpdate);
},
// ❌ 缺少 beforeUnmount/beforeDestroy 清理
// beforeUnmount() {
// this.observer.disconnect();
// window.removeEventListener('scroll', this.handleScroll);
// this.$eventBus.$off('data-update', this.handleUpdate);
// }
};
通用原则:凡是在组件生命周期中"订阅"了外部资源的,必须在组件销毁时"取消订阅"。 包括但不限于:WebSocket、EventSource、IntersectionObserver、MutationObserver、ResizeObserver、全局事件、第三方 SDK 回调。
DevTools Memory 面板
Chrome DevTools 提供了三种内存分析工具,各有适用场景。
Heap Snapshot(堆快照)
用途:拍摄当前时刻的完整堆内存快照,分析哪些对象占了多少内存。
操作流程:
- 打开 DevTools → Memory 面板
- 选择 "Heap snapshot"
- 点击 "Take snapshot"
核心视图:
- Summary:按构造函数分组,展示对象数量和占用大小
- Comparison:对比两次快照的差异,找出新增/未释放的对象
- Containment:按引用链层级展示,从 GC Root 到目标对象的完整路径
- Statistics:内存分类统计(代码、字符串、数组、对象等)
关键指标:
- Shallow Size:对象自身占用的内存
- Retained Size:对象被回收后能释放的总内存(包含它独占引用的所有子对象)
实战技巧:对比两次快照时,在 Comparison 视图中关注 #Delta 为正数的类型,这些就是两次快照之间新增但未释放的对象。
Allocation Timeline(分配时间线)
用途:持续录制内存分配情况,观察哪些时间点产生了内存分配,且分配后没有被回收。
操作流程:
- 选择 "Allocation instrumentation on timeline"
- 点击开始录制
- 执行可能导致泄漏的操作(如反复切换路由)
- 停止录制
时间轴上蓝色柱状表示分配了且仍存活的内存,灰色表示已被回收。如果蓝色柱状持续增长且不消退,就是泄漏的信号。
Allocation Sampling(分配采样)
用途:低开销的采样式分析,适合长时间运行的场景。不会像 Timeline 那样严重影响性能。
输出类似火焰图的调用栈信息,展示哪些函数分配了最多内存。适合在生产环境附近的 staging 环境做长时间监控。
内存泄漏排查流程
第一步:确认泄漏存在
使用 Performance Monitor(DevTools → More tools → Performance Monitor)观察 JS Heap Size 指标:
- 打开目标页面,记录初始内存
- 执行可疑操作(路由切换、弹窗打开关闭、列表滚动)
- 点击 GC 按钮强制垃圾回收
- 观察内存是否回落到初始水平
判断标准:如果每次操作后强制 GC,内存仍然呈阶梯式上升,则可以确认存在泄漏。
第二步:定位泄漏对象
- 在操作前拍一次 Heap Snapshot(Snapshot 1)
- 执行可疑操作
- 强制 GC
- 再拍一次 Heap Snapshot(Snapshot 2)
- 用 Comparison 视图对比两次快照
在对比结果中,重点关注:
Detached HTMLDivElement等分离 DOM 节点- 自定义类名的实例(如
VueComponent、Subscription) - 大量新增的
(closure)闭包对象 - 异常增长的
Array、Map、Set
第三步:追溯引用链
选中可疑对象,查看下方的 "Retainers"(保持者)列表。这个列表展示了从 GC Root 到该对象的引用路径——正是这条引用链阻止了 GC 回收该对象。
常见的 Retainer 模式:
Window → handler → closure → leakedObject:全局事件监听Window → setInterval → closure → leakedObject:未清除的定时器Map → entries → leakedObject:缓存未设上限
第四步:修复并验证
修复后重复第一步的操作,确认内存可以正常回落。
Performance Monitor 实时监控
DevTools 的 Performance Monitor 提供了实时的性能指标面板,不需要录制,打开就能看。
与内存相关的关键指标:
- JS Heap Size:当前 JS 堆大小,持续上升说明有泄漏
- DOM Nodes:当前文档中的 DOM 节点数,页面切换后不下降说明有分离节点
- JS Event Listeners:事件监听器数量,持续增加说明有未移除的监听
一个简单的自测脚本,在控制台执行后可以辅助观察:
// 每 5 秒输出一次内存使用情况
setInterval(() => {
if (performance.memory) {
const { usedJSHeapSize, totalJSHeapSize } = performance.memory;
console.log(
`Heap: ${(usedJSHeapSize / 1048576).toFixed(1)}MB / ${(totalJSHeapSize / 1048576).toFixed(1)}MB`
);
}
}, 5000);
注意:performance.memory 是 Chrome 私有 API,其他浏览器不支持。
WeakRef 和 FinalizationRegistry
ES2021 引入了两个与 GC 交互的底层 API,在特定场景下可以帮助避免泄漏。
WeakRef
创建对对象的弱引用,不会阻止 GC 回收目标对象。
class ImageCache {
#cache = new Map();
set(url, imageData) {
// 使用 WeakRef,当 imageData 在其他地方不再被引用时可以被 GC
this.#cache.set(url, new WeakRef(imageData));
}
get(url) {
const ref = this.#cache.get(url);
if (!ref) return undefined;
const data = ref.deref(); // 获取目标对象,如果已被回收则返回 undefined
if (!data) {
this.#cache.delete(url); // 清理无效条目
return undefined;
}
return data;
}
}
FinalizationRegistry
注册一个回调,在目标对象被 GC 回收后执行清理逻辑。
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object associated with "${heldValue}" was garbage collected`);
// 执行清理:关闭文件句柄、释放外部资源等
});
function createManagedResource(name) {
const resource = acquireExpensiveResource();
registry.register(resource, name);
return resource;
}
使用注意事项:
- 回调执行时机不确定,不要依赖它做关键逻辑
- 主要用于"尽力而为"的资源清理和泄漏监测
- 不能替代显式的
destroy()/dispose()模式
防泄漏最佳实践
编码层面
- 组件清理模式统一化:React 用
useEffect的返回函数,Vue 用onUnmounted,确保每个订阅都有对应的取消订阅
// React: 订阅和清理成对出现
useEffect(() => {
const subscription = dataSource.subscribe(handleUpdate);
return () => subscription.unsubscribe();
}, [dataSource]);
- 使用 WeakMap/WeakSet 做关联数据存储:当你需要在对象上附加额外数据,但不想影响其生命周期时
// ✅ 目标对象被回收时,关联数据自动释放
const metadata = new WeakMap();
metadata.set(domNode, { clickCount: 0 });
// ❌ 普通 Map 会阻止 domNode 被回收
const metadata = new Map();
metadata.set(domNode, { clickCount: 0 });
- 缓存设上限和过期策略:任何内存缓存都应该有大小上限
class LRUCache {
#maxSize;
#cache = new Map();
constructor(maxSize = 100) {
this.#maxSize = maxSize;
}
set(key, value) {
if (this.#cache.has(key)) this.#cache.delete(key);
this.#cache.set(key, value);
if (this.#cache.size > this.#maxSize) {
const firstKey = this.#cache.keys().next().value;
this.#cache.delete(firstKey);
}
}
}
- AbortController 管理异步生命周期:所有可取消的异步操作都应使用 AbortController
架构层面
- 路由切换时的清理检查:SPA 的路由切换是泄漏高发区,每个页面组件的卸载逻辑必须覆盖全部外部订阅
- 全局状态的生命周期管理:Vuex/Redux 中的数据在页面切换后是否需要清空
- 第三方 SDK 的销毁方法:地图、编辑器、播放器等重型组件都有
destroy()方法,必须调用
CI/监控层面
- 在 E2E 测试中加入内存断言:操作前后的内存差值不超过阈值
- 生产环境通过
performance.memory上报关键页面的内存水位 - 配合 Lighthouse CI 的
total-byte-weight指标做回归检测
面试高频追问
Q:如何判断是内存泄漏还是内存膨胀?
操作后强制 GC,内存不回落 → 泄漏;内存能回落但基线就很高 → 膨胀。泄漏是 Bug 要修,膨胀是设计问题要优化。
Q:闭包一定会导致内存泄漏吗?
不会。闭包本身是正常的语言特性,只有当闭包的生命周期超出预期(比如被全局对象持有),且闭包中捕获了不再需要的大对象时,才构成泄漏。V8 会优化只保留闭包实际引用的变量。
Q:WeakMap 和 Map 的区别是什么?为什么能防泄漏?
WeakMap 的 key 必须是对象,且是弱引用。当 key 对象在外部不再被强引用时,对应的键值对会自动被 GC 清除。所以用 WeakMap 存储关联数据不会阻止原对象被回收。
Q:React 的 useEffect 不写清理函数会怎样?
如果 effect 中创建了订阅(WebSocket、事件监听、定时器等),不写清理函数意味着组件卸载后这些订阅仍然活跃。回调中如果引用了组件状态或 DOM,就会阻止相关内存被回收,同时可能导致"对已卸载组件调用 setState"的警告。
Q:Heap Snapshot 中 Shallow Size 和 Retained Size 的区别?
Shallow Size 是对象本身在堆中的大小。Retained Size 是如果这个对象被回收,总共能释放多少内存——包括它独占引用的所有子对象。排查泄漏时,Retained Size 大的对象更值得关注,因为回收它能释放更多内存。
Q:为什么 SPA 比多页应用更容易泄漏?
多页应用每次导航都是完整的页面刷新,浏览器会释放当前页面的全部内存。SPA 在同一个页面实例中运行所有"页面",如果路由切换时旧组件的资源没有被正确清理,它们就会一直留在内存中。单页的生命周期等同于用户的整个使用会话,泄漏的影响被累积放大。
总结
内存泄漏的排查归纳为三个阶段:
- 确认:Performance Monitor 观察趋势,强制 GC 后内存是否回落
- 定位:Heap Snapshot 对比找到未释放对象,通过 Retainers 追溯引用链
- 修复:断开不合理的引用链,补上遗漏的清理逻辑,验证修复效果
核心认知:GC 能回收的前提是"不可达"。所有泄漏的根因都是——某条从 GC Root 出发的引用链本该断开,但没有断开。理解了这一点,遇到泄漏问题就不会迷失方向。