【每日一面】内存泄漏与垃圾回收

2026-09-15 18 min 6412 字 -- 次阅读
摘要

内存泄漏的本质是对象从根仍然可达、GC 回收不掉,所以要掌握的是标记-清除与代际回收的判定逻辑,以及全局变量、定时器、闭包、DOM 引用、事件监听、第三方库这六类高频泄漏场景的定位路径。

内存泄漏是 JS 中隐蔽性最强的问题之一——它不会让你立即报错,而是悄悄吃掉内存,直到页面卡顿、崩溃。搞懂垃圾回收机制和常见泄漏场景,是每个前端工程师的必修课。

基础问答

问:什么是内存泄漏?JavaScript 的垃圾回收机制是怎么工作的?

:内存泄漏(Memory Leak)是指程序中已不再使用的对象,因为仍然被某个引用持有,导致垃圾回收器无法将其回收,从而持续占用内存。泄漏累积到一定程度就会引发页面卡顿、性能下降乃至浏览器崩溃。

JavaScript 采用自动垃圾回收,开发者不需要手动 free 内存。现代浏览器主流使用标记-清除(Mark-and-Sweep)算法:

  1. 标记阶段:从根对象(windowglobal、调用栈等)出发,深度遍历所有可达对象并打上标记

  2. 清除阶段:遍历堆中所有对象,清除未被标记的对象,释放其内存

  3. 整理阶段(可选):部分引擎会做内存整理,减少碎片

早期还有引用计数方案(跟踪每个对象的被引用次数,降为 0 时回收),但无法处理循环引用(如两个对象互相引用),现在基本已被标记-清除取代。

javascript
// 引用计数的死穴:循环引用
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 只能回收从根不可达的对象。只要有一条引用路径从根连到某个对象,它就永远不会被回收,这就是出现内存泄漏问题的本质。

常见内存泄漏场景

  1. 意外的全局变量

    非严格模式下,未声明变量的赋值会挂到 window 上:

    javascript
    function leak() {
      // 未声明变量,非严格模式下成为 window.someData
      someData = new Array(1000000).fill('leak');
    }
    leak(); // someData 永久可达,无法回收

    这种问题的修复比较简单,可以启用 "use strict";使用 let/const 声明;全局变量用完后赋 null

  2. 被遗忘的定时器和回调

    javascript
    function startTimer() {
      const heavyData = new Array(1000000).fill('data');
    
      setInterval(() => {
        // 回调引用了 heavyData,且定时器未清理
        console.log(heavyData.length);
      }, 1000);
    }
    startTimer();
    // 即使不再需要定时器,它和它的闭包(含 heavyData)永远存在

    在组件卸载时清除定时器,或用 setTimeout 递归替代(便于控制生命周期),就可以避免问题。

  3. 闭包持有外部变量

    javascript
    function createClosure() {
      const largeArray = new Array(1000000).fill('😅');
    
      return function useClosure() {
        // 即使只用了少量数据,整个 largeArray 都被闭包持有
        console.log('closure alive');
      };
    }
    
    const leakedFn = createClosure();
    // createClosure 执行完,largeArray 本应回收
    // 但因为 leakedFn(闭包)仍引用它,所以无法释放

    修复方案是,只让闭包访问真正需要的变量,用完赋 null 就切断引用。

  4. 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 或事件委托减少直接持有避免内存泄漏。

  5. 事件监听器未移除

    javascript
    class ChatComponent {
      mount() {
        this._boundResize = this.handleResize.bind(this);
        window.addEventListener('resize', this._boundResize);
        // 组件卸载后,resize 监听器依然活着
        // 如果多次 mount/unmount,会反复注册,造成"监听器堆积"
      }
    
      unmount() {
        // 忘记移除监听器!
      }
    }

    unmount 时用 removeEventListener 移除,或在 SPA 框架中用生命周期钩子确保成对注册/注销。

  6. 第三方库资源未释放

    ECharts、地图 SDK、视频播放器等往往自己管理内存:

    javascript
    const 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/>修复后验证"]

实操步骤

  1. Allocation Timeline(分配时间轴):录制 → 操作页面 → 停止,观察蓝色竖条是否只升不降。找到只分配不回收的操作区域

  2. Heap Snapshot(堆快照):操作前后各拍一次,切换到 Comparison 模式,关注 Delta(正数 = 新增未释放的对象)

  3. Retainers(保留树):选中可疑对象,展开 Retainers 链追踪是谁引用了它

  4. 手动 GC:点击垃圾桶按钮强制回收,如果内存仍高则确认存在泄漏

对比总结

对比维度正常内存管理内存泄漏
GC 可达性不可达 → 正常回收意外可达 → 无法回收
内存曲线锯齿状(分配 → 回收 → 分配)持续上升,无回落
性能影响平稳页面逐步卡顿,最终崩溃
典型原因全局变量、闭包、定时器、DOM 引用、事件监听
排查工具Chrome Memory / Performance Monitor
预防泄漏严格模式、生命周期管理、WeakRef、事件委托忽略清理导致泄漏

面试追问

追问1:WeakMapWeakSet 为什么能帮助避免内存泄漏?用在什么场景?

WeakMap 的 key 是弱引用,当 key 对象外部不再被引用时,WeakMap 中的条目会自动被 GC 回收,不会阻止 key 的销毁。WeakSet 同理。

经典场景:给 DOM 元素关联数据,DOM 删除后关联数据自动释放:

javascript
const domData = new WeakMap();

function associateData(el, data) {
  domData.set(el, data);
}

// 当 el 从 DOM 树移除且外部无引用时
// domData 中的条目自动消失,无需手动清理

如果用 Map 代替,即使元素已删除,Map 仍持有引用导致泄漏。WeakMap 的弱引用特性让它天然适合做元数据存储和缓存

追问2:以下代码会导致内存泄漏吗?为什么?

javascript
const arr = [];
for (let i = 0; i < 10000; i++) {
  arr.push(function() {
    console.log(i);
  });
}

:会造成较大内存开销,但不一定是泄漏。arr 持有 10000 个闭包,每个闭包持有了自己的 i(let 声明的块级变量,每次循环创建独立绑定),但这 10000 个函数在外部仍然被需要(通过 arr 可访问),所以它们可达,不构成泄漏,除非 arr 本应被释放但未释放。

如果这个 arr 被意外挂到了全局对象上(如下),就是泄漏:

javascript
window.cache = arr; // 意外全局引用 → 泄漏

追问3:在 React 中,useEffect 的 cleanup 函数与内存泄漏有什么关系?给一个典型例子。

useEffect 的 cleanup 函数在组件卸载或依赖变化时执行,是 React 防止泄漏的关键机制:

javascript
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,如何快速定位并止损?

止损优先

  1. 运维层面滚动重启服务实例,恢复可用性

  2. 若近期有发布,评估回滚到上一个稳定版本

  3. 配置 k8s livenessProbe + 资源限制,让 Pod 自动重启

定位根因

  1. 启动 Node.js 时加 --inspect 标志,连接 Chrome chrome://inspect 分析堆快照

  2. 加载 heapdump 模块,在内存阈值触发时 dump 堆快照

  3. 分析快照:找大对象(内存占用高)、找增长异常的全局缓存/Map/数组

  4. 结合 APM 工具(如 Sentry、Alibaba Node.js Performance Platform)追踪请求链路

典型线上泄漏

javascript
// 无淘汰策略的全局缓存
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:给你一个疑似泄漏的页面,怎么设计排查步骤来确认和定位泄漏?

排查步骤

  1. 确认泄漏存在

    • 打开 DevTools Performance Monitor,观察 JS Heap 曲线

    • 反复执行进入页面 → 操作核心功能 → 返回的完整循环 10 次

    • 如果 JS Heap 曲线持续上升且每次循环后不回落到基准值 → 确认存在泄漏

  2. 缩小范围(二分法)

    • 逐个禁用功能模块,重复 Step 1,找出触发泄漏的具体操作

    • 例如:禁用图表渲染后泄漏消失 → 锁定 ECharts 实例未正确 dispose

  3. 精确定位

    • 锁定操作后,用 Allocation Timeline 录制该操作的完整闭环

    • 找到只分配不回收的蓝色竖条区域

    • 拍摄操作前后的 Heap Snapshot,Comparison 模式排序 Delta

    • 找到 Delta 最大的构造函数,展开 Retainers 追踪引用路径

  4. 修复验证

    • 修复后,重复 Step 1 的完整循环,确认 JS Heap 曲线重新呈现锯齿状(有升有降)

    • 手动触发 GC 后内存回到基准水位

这个排查流程在阿里巴巴、字节等大厂的前端面试中经常被问到,关键在于要有实验设计的思维,而不是靠猜,严谨的通过二分法和工具链逐步定位。

评论