Skip to main content
线上页面越用越卡时,我一般先看堆快照里的 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 引擎的垃圾回收器管理。
JavaScript 引擎使用两种垃圾回收算法:
  • 引用计数(Reference Counting):早期 IE 使用。每个对象有一个引用计数器,计数为 0 时回收。致命缺陷:循环引用时计数永远不为 0,导致泄漏。
  • 标记清理(Mark-and-Sweep):现代引擎的主流方案。从根对象(window)出发,遍历所有可达对象并标记,然后清除未标记的对象。解决了循环引用问题。
栈内存就像图书馆的索引卡——每张卡上直接写着书的内容(值),读者还书后卡片立刻被回收。堆内存就像图书馆的书架——索引卡上只写着书的位置(指针),书在书架上。垃圾回收器就是图书管理员,定期巡视书架,把”没有任何索引卡指向的书”清理掉。如果两张索引卡互相指向对方的书(循环引用),但读者都不再需要这些书了,管理员仍会清理它们(标记清理算法从根出发,发现读者不可达)。

垃圾回收的代际策略(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 对其键和值持有强引用。如果不再需要,必须手动清除。
Map 对键持有强引用——只要 Map 存在,键就不会被回收。WeakMap 对键持有弱引用——如果没有其他引用指向键,键可以被垃圾回收,对应的值也会被自动清除。关键限制:WeakMap 的键必须是对象(不能是基本类型),且不可遍历(没有 forEach、keys、values 方法)。

Q4: 如何排查内存泄漏

Chrome DevTools Memory 面板

Chrome DevTools 提供了三种内存分析工具:

堆快照分析步骤

Allocation Timeline 定位持续增长

最可靠的泄漏排查方法是三次快照法:
  1. 执行操作前拍摄快照 A
  2. 执行操作一次后拍摄快照 B
  3. 执行操作一次后拍摄快照 C(理论上应该和 B 一样)
  4. 对比 B 和 C——如果 C 比 B 多出的对象没有在操作结束时被释放,就说明存在泄漏

Performance Monitor 实时监控

Chrome DevTools 的 Performance Monitor 可以实时查看 JS 堆大小:

Q5: 内存泄漏的修复方案

原则一:及时解除引用

原则二:使用 WeakRef / WeakMap / WeakSet

原则三:组件生命周期中清理资源

原则四:避免意外的全局变量

原则五:虚拟列表处理大数据

修复内存泄漏的核心是:理解可达性规则,及时切断不需要的引用,使用 Weak 系列数据结构,在组件销毁时清理所有副作用。

Q6: 经典面试题:如何排查一个 SPA 应用的内存泄漏?

一个典型的 SPA 应用中,页面在多个路由之间切换。用户反馈”用久了页面越来越卡”。请排查并修复。 排查步骤:
常见泄漏模式:

底层源码:V8 是如何实现垃圾回收的?

V8 新生代使用 Cheney 算法(半区复制算法),详见 V8 源码:src/heap/spaces.cc:
优点:速度快,适合新生代(大部分对象短命)。缺点:浪费一半内存空间。 老生代使用标记清理和标记整理,详见 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 的核心特性——函数记住了它被创建时的词法环境。只有当闭包持有了你不再需要的大量数据时,才会构成泄漏。
关键是:闭包闭包持有的是引用而不是值的拷贝。如果你只需要一个属性,就把它提取出来,让大对象可以被回收。”
答题要点: 从垃圾回收的角度解释强引用 vs 弱引用。高分回答: “Map 对键持有强引用——只要 Map 存在,键对象就永远不会被垃圾回收。WeakMap 对键持有弱引用——如果没有其他引用指向键对象,键可以被 GC 回收,对应的值也会被自动清除。
WeakMap 的限制:
  1. 键必须是对象(不能是 string、number 等基本类型)
  2. 不可遍历(没有 forEach、keys、values、entries 方法)
  3. 没有 size 属性
最佳使用场景:给对象关联额外数据(如 DOM 元素的缓存),当 DOM 元素被移除后,关联数据自动清除。”
答题要点: 对比引用计数的缺陷,解释标记清理的可达性分析。高分回答: “早期 IE 使用引用计数(Reference Counting)算法——每个对象有一个引用计数器,当计数为 0 时回收。这个算法的致命缺陷是循环引用:
现代引擎使用**标记清理(Mark-and-Sweep)**算法:从根对象(window / globalThis)出发,遍历所有可达对象并标记,然后清除未标记的对象。在上面的例子中,leak() 执行完后,objA 和 objB 从根对象不可达,即使它们互相引用,也会被清除。”
答题要点: 关注三个指标的趋势变化。高分回答: “打开 Chrome DevTools → More tools → Performance Monitor,关注三个指标:
  1. JS heap size:正常情况应该在操作后回落。如果反复操作后 JS heap 只增不减,说明有泄漏。
  2. DOM Nodes:如果反复切换页面/弹窗后 DOM 节点数只增不减,说明 DOM 节点未被正确移除。
  3. JS event listeners:如果反复操作后监听器数量只增不减,说明事件监听器未移除。
操作步骤:
  1. 打开 Performance Monitor
  2. 执行一次可疑操作(如打开弹窗再关闭)
  3. 手动点击垃圾回收按钮(Memory 面板左上角的垃圾桶图标)
  4. 观察指标是否回落
  5. 重复操作 5-10 次,如果指标持续增长,确认存在泄漏”
答题要点: 结合 useEffect cleanup、AbortController、事件清理。高分回答: “React 中的内存泄漏主要来自 useEffect 中的副作用未清理。常见场景和修复:
核心原则:每个 useEffect 都必须返回清理函数,清理所有在 effect 中创建的资源。”
答题要点: 说明 WeakRef 解决了什么问题,以及 FinalizationRegistry 的注意事项。高分回答: “WeakRef 创建一个不阻止垃圾回收的弱引用。典型使用场景是缓存:
FinalizationRegistry 在对象被 GC 回收后执行回调,适合清理外部资源:
注意:FinalizationRegistry 的回调执行时机不确定——可能在 GC 后很久才执行,甚至不执行。永远不要用它来做关键业务逻辑,只适合做资源清理。”
答题要点: 对比浏览器端,介绍 Node.js 特有的排查工具。高分回答: “Node.js 排查内存泄漏的工具和浏览器端有所不同:1. heapdump 模块
2. —inspect 远程调试
3. process.memoryUsage() 监控
Node.js 特有的泄漏场景:
  • 未清理的 Event Emitter 监听器(超过 11 个监听器会触发警告)
  • 未关闭的数据库连接池
  • 未销毁的 Express 中间件持有的引用”
答题要点: 从可达性分析的角度解释,全局变量永远不会被 GC。高分回答: “从垃圾回收的角度看,全局对象(window / globalThis)是标记清理算法的根对象。所有挂载到全局对象上的变量都是从根可达的——这意味着它们永远不会被垃圾回收。
修复方案:
  1. 使用 'use strict' 模式,意外的全局变量会抛出 ReferenceError
  2. 用 WeakMap 替代普通对象做缓存
  3. 每次添加监听器前先移除旧的(或使用 { once: true })“

延伸阅读与引用参考