内存泄漏排查

21 分钟

背景:页面越用越卡,内存去哪了

你可能遇到过这样的场景:一个 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 等现代引擎采用的主流算法。核心思路:

  1. 从根对象(全局对象、当前调用栈中的变量)出发
  2. 递归遍历所有可达对象,标记为"活跃"
  3. 未被标记的对象视为垃圾,释放其内存

关键点:判断标准是"可达性"(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 会让目标对象持有回调的强引用。如果目标是 windowdocument 这类长生命周期对象,回调及其闭包中引用的所有对象都不会被回收。

4. 闭包持有不必要的引用

function createProcessor() {
  const hugeData = loadHugeDataset(); // 100MB 数据

  return function process(item) {
    // 只用到了 item,但闭包捕获了整个作用域
    return item.id;
  };
}

const processor = createProcessor();
// hugeData 永远不会被回收,因为 processor 的闭包作用域持有它

V8 的优化器通常会分析闭包实际引用了哪些变量,只保留必要的。但在某些情况下(如使用 evalwith,或多个闭包共享同一作用域),这个优化会失效。

解决方案:手动解除不需要的引用。

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(堆快照)

用途:拍摄当前时刻的完整堆内存快照,分析哪些对象占了多少内存。

操作流程

  1. 打开 DevTools → Memory 面板
  2. 选择 "Heap snapshot"
  3. 点击 "Take snapshot"

核心视图

  • Summary:按构造函数分组,展示对象数量和占用大小
  • Comparison:对比两次快照的差异,找出新增/未释放的对象
  • Containment:按引用链层级展示,从 GC Root 到目标对象的完整路径
  • Statistics:内存分类统计(代码、字符串、数组、对象等)

关键指标

  • Shallow Size:对象自身占用的内存
  • Retained Size:对象被回收后能释放的总内存(包含它独占引用的所有子对象)

实战技巧:对比两次快照时,在 Comparison 视图中关注 #Delta 为正数的类型,这些就是两次快照之间新增但未释放的对象。

Allocation Timeline(分配时间线)

用途:持续录制内存分配情况,观察哪些时间点产生了内存分配,且分配后没有被回收。

操作流程

  1. 选择 "Allocation instrumentation on timeline"
  2. 点击开始录制
  3. 执行可能导致泄漏的操作(如反复切换路由)
  4. 停止录制

时间轴上蓝色柱状表示分配了且仍存活的内存,灰色表示已被回收。如果蓝色柱状持续增长且不消退,就是泄漏的信号。

Allocation Sampling(分配采样)

用途:低开销的采样式分析,适合长时间运行的场景。不会像 Timeline 那样严重影响性能。

输出类似火焰图的调用栈信息,展示哪些函数分配了最多内存。适合在生产环境附近的 staging 环境做长时间监控。

内存泄漏排查流程

第一步:确认泄漏存在

使用 Performance Monitor(DevTools → More tools → Performance Monitor)观察 JS Heap Size 指标:

  1. 打开目标页面,记录初始内存
  2. 执行可疑操作(路由切换、弹窗打开关闭、列表滚动)
  3. 点击 GC 按钮强制垃圾回收
  4. 观察内存是否回落到初始水平

判断标准:如果每次操作后强制 GC,内存仍然呈阶梯式上升,则可以确认存在泄漏。

第二步:定位泄漏对象

  1. 在操作前拍一次 Heap Snapshot(Snapshot 1)
  2. 执行可疑操作
  3. 强制 GC
  4. 再拍一次 Heap Snapshot(Snapshot 2)
  5. 用 Comparison 视图对比两次快照

在对比结果中,重点关注:

  • Detached HTMLDivElement 等分离 DOM 节点
  • 自定义类名的实例(如 VueComponentSubscription
  • 大量新增的 (closure) 闭包对象
  • 异常增长的 ArrayMapSet

第三步:追溯引用链

选中可疑对象,查看下方的 "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() 模式

防泄漏最佳实践

编码层面

  1. 组件清理模式统一化:React 用 useEffect 的返回函数,Vue 用 onUnmounted,确保每个订阅都有对应的取消订阅
// React: 订阅和清理成对出现
useEffect(() => {
  const subscription = dataSource.subscribe(handleUpdate);
  return () => subscription.unsubscribe();
}, [dataSource]);
  1. 使用 WeakMap/WeakSet 做关联数据存储:当你需要在对象上附加额外数据,但不想影响其生命周期时
// ✅ 目标对象被回收时,关联数据自动释放
const metadata = new WeakMap();
metadata.set(domNode, { clickCount: 0 });

// ❌ 普通 Map 会阻止 domNode 被回收
const metadata = new Map();
metadata.set(domNode, { clickCount: 0 });
  1. 缓存设上限和过期策略:任何内存缓存都应该有大小上限
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);
    }
  }
}
  1. 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 在同一个页面实例中运行所有"页面",如果路由切换时旧组件的资源没有被正确清理,它们就会一直留在内存中。单页的生命周期等同于用户的整个使用会话,泄漏的影响被累积放大。

总结

内存泄漏的排查归纳为三个阶段:

  1. 确认:Performance Monitor 观察趋势,强制 GC 后内存是否回落
  2. 定位:Heap Snapshot 对比找到未释放对象,通过 Retainers 追溯引用链
  3. 修复:断开不合理的引用链,补上遗漏的清理逻辑,验证修复效果

核心认知:GC 能回收的前提是"不可达"。所有泄漏的根因都是——某条从 GC Root 出发的引用链本该断开,但没有断开。理解了这一点,遇到泄漏问题就不会迷失方向。